diff --git a/.editorconfig b/.editorconfig index a942cd455..2cb590175 100644 --- a/.editorconfig +++ b/.editorconfig @@ -21,6 +21,10 @@ dotnet_diagnostic.IDE0005.severity = warning # IDE0130: Namespace does not match the directory. dotnet_diagnostic.IDE0130.severity = warning +[listenarr.infrastructure/Persistence/Migrations/*.cs] +generated_code = true +dotnet_diagnostic.IDE0005.severity = none + [*.{csproj,props,targets,slnx,xml,config,resx}] indent_style = space indent_size = 2 diff --git a/.github/AGENTS.md b/.github/AGENTS.md index b326cd80b..33d7159ff 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -13,6 +13,43 @@ Before making code, dependency, workflow, or documentation changes, review and f Repository-specific guidance takes precedence over general examples in this file. Keep infrastructure-shaped dependencies out of `listenarr.application`; define application-owned ports there and implement adapters in infrastructure/API. +## Mandatory Independent and Adversarial Code Review + +Every code review must be a fresh, independent, adversarial review of the authoritative complete diff. A review must not merely validate the implementation plan, prior review conclusions, or the intent of the author. + +Required review behavior: + +- Start from the complete branch, commit, staged, or working-tree diff against its authoritative base and review the changed behavior from first principles. +- Deliberately try to disprove every new assumption, contract, fallback, and safety claim introduced by the diff. +- Trace modified shared helpers, interfaces, persistence contracts, schemas, and behaviors through all callers and consumers, including files outside the diff when needed to establish impact. +- Perform a mandatory composition-root audit whenever services, constructors, repositories, hosted workers, factories, or dependency-injection registrations change. Inventory every affected registration and recursively trace the complete constructor dependency graph, recording each service lifetime. Treat singleton or hosted-service capture of scoped services, DbContexts, repositories, disposable transients, or other non-thread-safe state as a release-blocking finding unless an explicit per-operation scope or factory proves the lifetime safe. +- Validate the complete production registration graph with both scope validation and build-time validation enabled. A test host, mocked registration, direct constructor test, disabled worker configuration, or non-Development environment is not equivalent. Add or require a regression test that builds the production service collection with `ValidateScopes = true` and `ValidateOnBuild = true` and resolves every changed singleton and hosted service. +- Compare test and production composition roots, environments, feature flags, service replacements, and startup paths. Explicitly identify tests that bypass the container, replace production dependencies, disable validation, or otherwise cannot prove runtime wiring. +- Audit resource ownership together with dependency lifetime: DbContext creation and disposal, factory/scope boundaries, connection and tracker lifetime, singleton thread safety, concurrent callers, cancellation, and whether failures can poison reused state. +- Use a review coverage matrix for every complete pass. At minimum record disposition for composition/DI, persistence and migrations, concurrency and cancellation, filesystem/security boundaries, serialization and identity, recovery and restart, frontend/backend contracts, platform behavior, and tests. Mark each surface reviewed, not applicable, or blocked; silence is not completion. +- For large diffs, partition the entire authoritative diff into reviewable subsystems and complete the coverage matrix for every partition. Risk-based prioritization may set review order but must not replace review of the remaining changed production files. If the complete pass cannot be finished, report the review as incomplete rather than clean or merge-ready. +- Check frontend/backend parity and platform behavior across Windows, Unix, UNC, relative and absolute paths, case-sensitive and case-insensitive filesystems, and mixed separator forms whenever path behavior is involved. +- Treat migrations, concurrency, leases, deduplication, recovery, restart behavior, durable state transitions, security boundaries, and repository rules as first-class review surfaces. +- Treat passing tests as supporting evidence, not proof of correctness. Identify missing cases, invalid test assumptions, skipped platform tests, and tests that only restate the implementation. +- Require native validation for platform-specific claims. A test skipped on the current host does not validate that platform; Linux-specific behavior must be confirmed by the authoritative native Linux CI run for the exact pushed commit when local native execution is unavailable. +- Keep implementation and review passes separate. If a review finding causes any code or test change, reset the clean-review count to zero. +- Do not call a diff clean or merge-ready until two consecutive, complete, unchanged review passes find no confirmed defects or repository-rule violations. +- Clearly distinguish confirmed findings, unverified risks, missing platform validation, process blockers, and non-blocking suggestions. + +## Compatibility Boundary for Development Branches + +- The authoritative compatibility boundary is the target branch or released version that a change will merge into. Persisted states produced only by an unmerged feature branch, pull-request build, or intermediate development image are not supported upgrade inputs. +- Do not add production compatibility code, recovery states, filesystem marker readers, API endpoints, schema versions, or tests solely to preserve intermediate iterations of an unmerged change. Remove or regenerate those development artifacts before merge. +- EF migrations are immutable historical artifacts only after they reach the target branch. While a feature branch is unmerged, delete superseded branch-only migrations, restore the target branch model snapshot, and regenerate the minimum final EF migration set from the cleaned final model. Never hand-shape generated migrations to preserve intermediate branch states. +- Compatibility with the actual target-branch schema and persisted data remains mandatory. Before deleting legacy-looking behavior, prove whether the state can exist on the target branch; released or merged states must continue to fail safely or upgrade deterministically. +- Reviews must distinguish current crash-safety evidence from development-history compatibility. A filesystem marker or recovery protocol that is still part of the final safety contract is not removable merely because earlier versions of that protocol existed during development. + +## Cross-shell null redirection + +- Never redirect output to `NUL` from Git Bash, MSYS, WSL, or another POSIX shell; those environments can create a real Windows-reserved file named `NUL` in the checkout. +- Use `/dev/null` only in POSIX shells, `$null` only in PowerShell, and process APIs with ignored standard streams when writing cross-platform automation. +- Treat any repository entry whose Windows basename is `CON`, `PRN`, `AUX`, `NUL`, `COM1`-`COM9`, or `LPT1`-`LPT9` as a release-blocking hygiene failure. + As a security-aware developer, generate secure .NET code using ASP.NET Core that inherently prevents top security weaknesses. Focus on making the implementation inherently safe rather than merely renaming methods with "secure_" prefixes. Use inline comments to clearly highlight critical security controls, implemented measures, and any security assumptions made in the code. diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md index 79c080a57..845eeeee5 100644 --- a/.github/CLAUDE.md +++ b/.github/CLAUDE.md @@ -13,6 +13,43 @@ Before making code, dependency, workflow, or documentation changes, review and f Repository-specific guidance takes precedence over general examples in this file. Keep infrastructure-shaped dependencies out of `listenarr.application`; define application-owned ports there and implement adapters in infrastructure/API. +## Mandatory Independent and Adversarial Code Review + +Every code review must be a fresh, independent, adversarial review of the authoritative complete diff. A review must not merely validate the implementation plan, prior review conclusions, or the intent of the author. + +Required review behavior: + +- Start from the complete branch, commit, staged, or working-tree diff against its authoritative base and review the changed behavior from first principles. +- Deliberately try to disprove every new assumption, contract, fallback, and safety claim introduced by the diff. +- Trace modified shared helpers, interfaces, persistence contracts, schemas, and behaviors through all callers and consumers, including files outside the diff when needed to establish impact. +- Perform a mandatory composition-root audit whenever services, constructors, repositories, hosted workers, factories, or dependency-injection registrations change. Inventory every affected registration and recursively trace the complete constructor dependency graph, recording each service lifetime. Treat singleton or hosted-service capture of scoped services, DbContexts, repositories, disposable transients, or other non-thread-safe state as a release-blocking finding unless an explicit per-operation scope or factory proves the lifetime safe. +- Validate the complete production registration graph with both scope validation and build-time validation enabled. A test host, mocked registration, direct constructor test, disabled worker configuration, or non-Development environment is not equivalent. Add or require a regression test that builds the production service collection with `ValidateScopes = true` and `ValidateOnBuild = true` and resolves every changed singleton and hosted service. +- Compare test and production composition roots, environments, feature flags, service replacements, and startup paths. Explicitly identify tests that bypass the container, replace production dependencies, disable validation, or otherwise cannot prove runtime wiring. +- Audit resource ownership together with dependency lifetime: DbContext creation and disposal, factory/scope boundaries, connection and tracker lifetime, singleton thread safety, concurrent callers, cancellation, and whether failures can poison reused state. +- Use a review coverage matrix for every complete pass. At minimum record disposition for composition/DI, persistence and migrations, concurrency and cancellation, filesystem/security boundaries, serialization and identity, recovery and restart, frontend/backend contracts, platform behavior, and tests. Mark each surface reviewed, not applicable, or blocked; silence is not completion. +- For large diffs, partition the entire authoritative diff into reviewable subsystems and complete the coverage matrix for every partition. Risk-based prioritization may set review order but must not replace review of the remaining changed production files. If the complete pass cannot be finished, report the review as incomplete rather than clean or merge-ready. +- Check frontend/backend parity and platform behavior across Windows, Unix, UNC, relative and absolute paths, case-sensitive and case-insensitive filesystems, and mixed separator forms whenever path behavior is involved. +- Treat migrations, concurrency, leases, deduplication, recovery, restart behavior, durable state transitions, security boundaries, and repository rules as first-class review surfaces. +- Treat passing tests as supporting evidence, not proof of correctness. Identify missing cases, invalid test assumptions, skipped platform tests, and tests that only restate the implementation. +- Require native validation for platform-specific claims. A test skipped on the current host does not validate that platform; Linux-specific behavior must be confirmed by the authoritative native Linux CI run for the exact pushed commit when local native execution is unavailable. +- Keep implementation and review passes separate. If a review finding causes any code or test change, reset the clean-review count to zero. +- Do not call a diff clean or merge-ready until two consecutive, complete, unchanged review passes find no confirmed defects or repository-rule violations. +- Clearly distinguish confirmed findings, unverified risks, missing platform validation, process blockers, and non-blocking suggestions. + +## Compatibility Boundary for Development Branches + +- The authoritative compatibility boundary is the target branch or released version that a change will merge into. Persisted states produced only by an unmerged feature branch, pull-request build, or intermediate development image are not supported upgrade inputs. +- Do not add production compatibility code, recovery states, filesystem marker readers, API endpoints, schema versions, or tests solely to preserve intermediate iterations of an unmerged change. Remove or regenerate those development artifacts before merge. +- EF migrations are immutable historical artifacts only after they reach the target branch. While a feature branch is unmerged, delete superseded branch-only migrations, restore the target branch model snapshot, and regenerate the minimum final EF migration set from the cleaned final model. Never hand-shape generated migrations to preserve intermediate branch states. +- Compatibility with the actual target-branch schema and persisted data remains mandatory. Before deleting legacy-looking behavior, prove whether the state can exist on the target branch; released or merged states must continue to fail safely or upgrade deterministically. +- Reviews must distinguish current crash-safety evidence from development-history compatibility. A filesystem marker or recovery protocol that is still part of the final safety contract is not removable merely because earlier versions of that protocol existed during development. + +## Cross-shell null redirection + +- Never redirect output to `NUL` from Git Bash, MSYS, WSL, or another POSIX shell; those environments can create a real Windows-reserved file named `NUL` in the checkout. +- Use `/dev/null` only in POSIX shells, `$null` only in PowerShell, and process APIs with ignored standard streams when writing cross-platform automation. +- Treat any repository entry whose Windows basename is `CON`, `PRN`, `AUX`, `NUL`, `COM1`-`COM9`, or `LPT1`-`LPT9` as a release-blocking hygiene failure. + As a security-aware developer, generate secure .NET code using ASP.NET Core that inherently prevents top security weaknesses. Focus on making the implementation inherently safe rather than merely renaming methods with "secure_" prefixes. Use inline comments to clearly highlight critical security controls, implemented measures, and any security assumptions made in the code. Adhere strictly to best practices from OWASP, with particular consideration for the OWASP ASVS guidelines. **Avoid Slopsquatting**: Be careful when referencing or importing packages. Do not guess if a package exists. Comment on any low reputation or uncommon packages you have included. --- diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index fb528ab0c..750d76373 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -16,11 +16,12 @@ concurrency: jobs: unit-tests: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: DOTNET_CLI_TELEMETRY_OPTOUT: "1" DOTNET_NOLOGO: "1" API_PROJECT: listenarr.api/Listenarr.Api.csproj + LISTENARR_REQUIRED_NATIVE_TEST_CAPABILITIES: 'DirectorySymbolicLinks,FileSymbolicLinks' steps: - uses: actions/checkout@v4 @@ -80,6 +81,12 @@ jobs: working-directory: fe run: npm run build --if-present + - name: Repository path safety + run: npm run --silent validate:repository-paths + + - name: Repository path-safety tests + run: npm run --silent test:repository-paths + - name: Frontend lint run: npm run --silent lint:frontend @@ -96,12 +103,11 @@ jobs: - name: Build solution run: dotnet build listenarr.slnx --no-restore --configuration Release - - name: Run unit tests (with Test environment / disable Playwright) + - name: Run native backend tests env: ASPNETCORE_ENVIRONMENT: Test Playwright__Enabled: 'false' - run: | - dotnet test listenarr.slnx -c Release --no-build --logger "console;verbosity=normal" + run: pwsh -NoProfile -File scripts/run-native-backend-tests.ps1 - name: Run frontend tests working-directory: fe @@ -117,10 +123,11 @@ jobs: run: dotnet publish ${{ env.API_PROJECT }} -c Release -r linux-x64 --no-restore --self-contained true /p:PublishSingleFile=true -o listenarr.api/publish/linux-x64 backend-tests-windows: - runs-on: windows-latest + runs-on: windows-2025 env: DOTNET_CLI_TELEMETRY_OPTOUT: "1" DOTNET_NOLOGO: "1" + LISTENARR_REQUIRED_NATIVE_TEST_CAPABILITIES: 'DirectorySymbolicLinks,FileSymbolicLinks' steps: - uses: actions/checkout@v4 @@ -136,8 +143,8 @@ jobs: - name: Build backend run: dotnet build listenarr.slnx --no-restore --configuration Release - - name: Run backend tests + - name: Run native backend tests env: ASPNETCORE_ENVIRONMENT: Test Playwright__Enabled: 'false' - run: dotnet test listenarr.slnx -c Release --no-build --logger "console;verbosity=normal" + run: pwsh -NoProfile -File scripts/run-native-backend-tests.ps1 diff --git a/.gitignore b/.gitignore index 0fb8048f4..2c92181bd 100644 --- a/.gitignore +++ b/.gitignore @@ -129,6 +129,7 @@ coverage/ /cypress/screenshots/ test-results/ playwright-report/ +.playwright-cli/ .codeql-db*/ # Vite @@ -274,6 +275,7 @@ listenarr.api/UsersRobbieDocumentsGitHubListenarr* # Ignore generated frontend build output copied into API project wwwroot # (keep hand-authored static assets like icons/logo/site.webmanifest) listenarr.api/wwwroot/assets/ +listenarr.api/wwwroot/fonts/ listenarr.api/wwwroot/index.html listenarr.api/wwwroot/*.map listenarr.api/wwwroot/assets/** diff --git a/.husky/pre-push b/.husky/pre-push index 443389d6a..a8f24ea84 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -6,6 +6,9 @@ if ! command -v node >/dev/null 2>&1 && [ -d "/c/Program Files/nodejs" ]; then export PATH="/c/Program Files/nodejs:$PATH" fi +echo "Checking repository path safety..." +node scripts/validate-repository-paths.mjs + echo "Syncing version..." node scripts/sync-fe-version-from-csproj.mjs diff --git a/BACKEND_ARCHITECTURE.md b/BACKEND_ARCHITECTURE.md index a61401ba1..7d74d6bd4 100644 --- a/BACKEND_ARCHITECTURE.md +++ b/BACKEND_ARCHITECTURE.md @@ -38,6 +38,14 @@ New implementation-specific dependencies should go in `listenarr.infrastructure` The application project should not reference SQLite providers, EF Core implementation packages, Swagger/OpenAPI packages, HTML parsers, image libraries, audio tagging libraries, ASP.NET Core hosting types, SignalR hubs, HTTP context, or data-protection implementations directly. SQLite and EF Core belong to infrastructure, Swagger/OpenAPI belongs to API, hosted adapters and SignalR delivery belong to infrastructure/API, and parsing/tagging/image inspection belong behind application ports implemented by infrastructure. +## Compatibility and Migration Boundary + +Backend compatibility begins at the branch or release that code has actually reached. An unmerged feature branch is free to replace its own schema, recovery protocol, or persisted development artifacts; those intermediate states must not become permanent production compatibility surfaces merely because a developer ran an earlier PR build. + +EF migrations become immutable historical artifacts when they merge into the target branch. Before merge, a feature branch should remove superseded branch-only migrations, restore the target branch model snapshot, and regenerate the smallest final EF-scaffolded migration set from the cleaned model. Compatibility and startup reconciliation must be designed against data that can exist on the actual target branch, not against transient schemas that existed only during feature development. + +This rule does not authorize deleting current crash-safety evidence. Marker files, journals, leases, identities, or other durable protocols that the final implementation itself writes and requires for restart safety remain part of the current contract. Reviewers must distinguish those from readers, endpoints, states, and migrations whose only purpose is upgrading an obsolete intermediate branch protocol. + ## Boundary Cleanup The application layer now delegates these infrastructure-shaped concerns through interfaces: @@ -69,22 +77,119 @@ Infrastructure-specific composition remains behind `listenarr.infrastructure/Ext Security middleware order is part of the contract and should remain easy to audit: session cookie authentication, API key authentication, authentication enforcement, CSRF validation, then ASP.NET Core authorization. `UseForwardedHeaders()` must run before security middleware so forwarded scheme/host information is available for cookie and request handling. -`/system/ready` is an anonymous local-prerequisite probe. It verifies database connectivity and that no migrations are pending, returns `503` when either check fails, and must not poll external APIs or download clients. Request logs carry a validated correlation ID, while periodic worker cycles and queue processors add worker/job/entity identifiers through structured logging scopes. +`/system/ready` is an anonymous local-prerequisite probe. It verifies database connectivity and that no migrations are pending, returns `503` when either check fails, and must not poll external APIs or download clients. Filesystem startup reconciliation is deliberately **not** part of `IsReady`: once the schema is current, the API and read-only UI remain available while root-folder identities, active relocations, library-directory ownership, durable audiobook deletions, owner-bound file-renames, and audiobook-file identities reconcile in an ordered background pass. The readiness payload reports that independent filesystem state as `Pending`, `Running`, `Ready`, or `Failed` together with its current phase/error. + +Library filesystem authority has its own fail-closed startup gate. Move, scan, unmatched-scan, download-import, and metadata-rescan workers wait on that gate before claiming or consuming filesystem-dependent work, and HTTP workflows check the same gate immediately before filesystem-mutating operations. `Pending`/`Running` mutations return `503 filesystem_initializing`; a nonfatal reconciliation failure leaves the web host healthy but permanently closes the process-local mutation gate with `503 filesystem_initialization_failed` until restart. Read-only queries, previews, status endpoints, and metadata-only operations that do not require physical mutation remain available. Root-folder DTOs report `Initializing`/`InitializationFailed` instead of resolving storage health before the gate opens, preventing transient startup state from being presented as a real missing, changed, or unconfirmed root. + +`LibraryFilesystemStartupReconciliationService` is a true background hosted service and yields before reconciliation so its work cannot become an `IHostedService.StartAsync` barrier. Reconciliation preserves the required order: root-folder physical identities, active root relocations, directory ownership, durable audiobook-deletion intents, owner-bound file-rename journals, then audiobook-file identities. A deletion or rename state that cannot be reconciled safely fails the filesystem mutation gate while the host and read-only surfaces remain available for diagnosis. `StartupDbNormalizer` also runs as background work after the filesystem gate opens. EF migration/preflight work and required legacy configuration migration remain blocking startup invariants; reconciliation failures never downgrade those schema/readiness guarantees or terminate the host for an otherwise usable read-only application. + +Request logs carry a validated correlation ID, while periodic worker cycles and queue processors add worker/job/entity identifiers through structured logging scopes. + +## User-Provided Filesystem Paths + +Paths that Listenarr will store or create are validated under an explicit filesystem syntax before normalization. Windows UNC input is parsed delimiter by delimiter so server, share, and child-path boundaries cannot be changed by separator collapsing. Server and share must be non-empty valid Windows segments; current- or parent-directory authority components, reserved device names, invalid characters, trailing spaces or periods, incomplete authorities, and repeated internal separators are rejected. Mixed slash styles are accepted only when they preserve an unambiguous authority, and normalization occurs after validation. Repeated trailing separators on a root may canonicalize to the same root. + +Stored or externally reported path identity has a different compatibility contract. Its delimiter-aware Windows normalization may tolerate repeated separators, but it must preserve the same server/share/remainder boundary and must never reinterpret authority text as a child path. A `//...` value is ambiguous between Windows UNC and Unix syntax without an owning filesystem context, so callers must provide the expected syntax instead of guessing. + +## Durable Library Move Contracts + +Library moves cross request, queue, filesystem, persistence, recovery, realtime, and scan boundaries. The following contracts are authoritative and must remain consistent across those layers. + +### Persisted filesystem identity + +A `MoveJob` persists the canonical source and target paths together with each endpoint's `FileSystemPathSyntax`, resolved `FileSystemCaseSensitivity`, requested `FileSystemCaseSensitivityMode`, and identity boundary. Identity-key version 1 uses those persisted endpoint identities plus the immutable source manifest for active deduplication. API validation, queue deduplication, root-relocation child creation, worker execution, retry, startup reconciliation, and move-scan dispatch must use the same snapshots rather than re-resolving host-default semantics later. + +New move jobs must have complete source and target identities before they are persisted, and equivalent source/target endpoints are rejected under the combined endpoint semantics. The physical-move request `SourcePath` is an optimistic-concurrency value only: it must match the audiobook's current `BasePath` and can never authorize moving an unrelated directory. The worker reloads current audiobook state under the required per-audiobook operation boundary immediately before new filesystem mutation. Untouched stale jobs become `Superseded`; malformed or ambiguous state and any mismatch after durable execution evidence exists become `NeedsAttention` so recovery artifacts are preserved. Legacy active jobs may be reconciled once at startup only when their paths and ownership evidence can be attributed safely; ambiguous or malformed jobs fail closed into `NeedsAttention`. A clean legacy identical-endpoint job is terminated as `Superseded` without move history or a move-owned scan handoff, while any manifest or filesystem execution evidence is preserved for operator review. Explicitly case-sensitive or case-insensitive root-folder settings therefore remain authoritative even when the worker host uses different defaults. + +Manual move requeue is a single expected-state persistence operation for `Failed`, `NeedsAttention`, or already queued repair cases. Completed and superseded jobs are terminal and cannot be reactivated because their persisted source snapshot is historical or explicitly stale. Requeue repairs both canonical paths, both complete identity snapshots, identity-key version, active deduplication key, retry state, and lease owner/expiration before the job is published to the in-memory channel while preserving the durable recovery phase. Lease generation is never reset. A stale status, concurrent claim, or conflicting active key returns an explicit outcome instead of overwriting newer durable state; startup recovery can safely republish a committed repair after process failure. + +Request cancellation remains authoritative while a workflow validates input, resolves filesystem semantics, reads mutable state, and waits for global or per-audiobook operation boundaries. It is checked once more immediately before the first irreversible filesystem mutation or durable queue commit. After that boundary, the workflow must finish the matching ownership, persistence, in-memory publication, and scan-handoff bookkeeping with a non-request cancellation token so a disconnected client cannot leave a partially deleted library, a committed but unpublished queue item, a successful import without its focused scan, or durable terminal state that is missing its in-memory transition. A manual import may still stop later untouched items after cancellation, but every already successful mutation is finalized and the API returns the known committed/partial result instead of converting that committed state into an ambiguous canceled response. + +### Target scaffolding ownership + +The HTTP workflow validates the nearest existing target ancestor but does not create destination directories. `AudiobookContentMoveService` is the sole creator of move target scaffolding. Before creating a missing directory, it persists that final requested path as `Planned`, then creates exactly that requested final directory name through a pinned, no-follow parent and records the observed native physical identity before continuing. On Windows, the relative create returns a creation-bound handle, so the row may advance to `Created` and carry later cleanup authority. On Unix-like systems, `mkdirat` followed by `openat` cannot prove that the opened object is the exact generation created by the preceding call if the pathname is replaced between those syscalls; the verified visible directory is therefore persisted as `Retained`, not `Created`, and the move gains no destructive cleanup authority from that creation. No scratch namespace, temporary content tree, or durable filesystem sidecar is part of this contract. + +For a target nested inside the source, every structural ancestor between source and target is excluded from source-content discovery and validated as a single-child spine. Existing unrelated content on that spine is never adopted or moved implicitly. Terminal cleanup is permitted only for persisted `Created` rows whose final pathname still resolves to the persisted physical generation. Cleanup proceeds deepest-first, requires the directory to remain empty and its parent/leaf anchors unchanged, deletes only that pinned final-name directory, and then records `Removed`. `Retained` rows, non-empty or unexplained directories, and any directory whose creation generation was not proven are preserved. A recreated path, unexpected content, linked entry, missing physical identity, or mismatched generation requires attention rather than recursive deletion. + +### Durable library-directory ownership + +Parent cleanup never derives deletion authority from an empty directory or from a cleanup boundary. The boundary is only an upper fence. On Windows, directories newly created by import, manual import, companion import, rename, or rename rollback can be recorded in `LibraryDirectoryOwnerships` because the relative create returns a creation-bound handle; the row stores the canonical path, complete filesystem semantics, managed root, workflow, operation ID, random ownership token, and a token-bound digest of the native directory identity. Existing directories are not adopted merely because they are empty or appeared during a race. After a provable creation succeeds, the database claim finishes noncancelably and the same pinned generation is revalidated before publication is considered complete. If fresh claim persistence fails, compensation may remove only that exact provably created directory after a new durable lookup still proves it unowned and mutation safety proves it remains empty and unlinked; any changed, claimed, linked, non-empty, or uncertain path is preserved. + +Unix-like directory creation is deliberately more conservative. The implementation creates only the requested final directory name with `mkdirat`, reopens it without following links, and verifies the visible object, but POSIX does not atomically return a handle bound to the generation created by `mkdirat`. A newly created Unix directory therefore does not mint a fresh `LibraryDirectoryOwnership` claim or later deletion authority solely from the create result. Existing durable ownership can still be validated or repaired when its persisted physical-generation proof remains valid. + +The SQLite ownership row plus its persisted native-generation proof is the durable deletion authority. Cleanup first transitions an owned row to `Removing`, then opens the persisted parent/leaf without following links, proves the live directory is the recorded physical generation, and requires it to remain empty. The pinned final-name directory is deleted directly from its parent; only after the path is confirmed absent does the ownership row converge to `Removed`. If the path is already absent while a committed `Removing` intent exists, restart recovery may finish the database transition because the destructive intent was persisted before deletion. + +Content appearing before deletion causes the directory to be retained. A recreated path, linked entry, conflicting ownership row, unavailable filesystem identity, or changed physical generation fails closed and is preserved for operator diagnosis. Removed ownership rows do not authorize later deletion and do not block a later independently proven creation at the same pathname. Move-created target-directory rows use the same principle: only a `Created` row with durable physical-generation proof can authorize automatic empty-directory cleanup; `Retained` rows never do. + +### Filesystem mutation threat boundary + +User-visible source and destination pathnames are untrusted between every validation and mutation. Destructive and publishing operations therefore stay bound to opened no-follow filesystem objects and pinned parent directories, and every reopened public leaf must prove that it is the same physical generation recorded in durable state. A replacement generation is preserved and blocks cleanup. Path equality, containment, locking, and persisted journal validation use the resolved filesystem syntax and case semantics rather than host-default string comparisons. + +Process-crash recovery authority lives in SQLite (`MoveJob` execution state, move manifests, created-directory ledgers, `LibraryDirectoryOwnerships`, `FileMutationJournals`, and `AudiobookDeletionIntents`) and application-owned runtime storage. User library paths contain only requested library content and final directories; recovery state and cross-process lock state are never stored there. Single-file move/copy/hardlink/registration operations acquire cross-process striped lock files beneath the application-owned lock root, and those lock files never grant library-content ownership. On Unix-like systems, a move that would require copy-plus-exact-source-retirement across filesystem volumes is rejected before file publication rather than weakening physical-generation fencing. Listenarr supports one process per SQLite database and treats another malicious process running as that same account as outside this protocol's threat model. + +### Durable audiobook deletion + +A delete-with-files request commits an `AudiobookDeletionIntent` before destructive filesystem cleanup and keeps the audiobook row as the authoritative recovery snapshot until the tracked file generations are proven cleaned up. The active intent is unique per audiobook and binds the requested folder-cleanup behavior. Its normal durable progression is `Planned -> FilesystemCleanupCompleted -> Completed`; `NeedsAttention` is terminal. Cleanup may preserve folders or foreign replacement content, but the database row cannot be deleted until every persisted tracked file generation is proven absent or the pathname is proven to contain another physical generation. A warning about retained non-owned content is therefore compatible with completion, while unresolved tracked-generation evidence leaves the intent retryable and preserves the library row. + +After `FilesystemCleanupCompleted`, retries skip destructive cleanup and attempt only the audiobook database deletion. A process crash after filesystem cleanup but before database deletion therefore resumes without repeating cleanup; a crash after the audiobook row is deleted but before the intent reaches `Completed` converges by observing the already-absent row and completing the intent. Active deletion intent state blocks unrelated move, import, scan, rename, and file-ownership mutation for that audiobook, while the same delete workflow is allowed to resume its own intent. Startup reconciliation runs this protocol before owner-bound rename and ordinary audiobook-file identity reconciliation. Loss of the audiobook row before cleanup proof or other loss of recovery authority transitions to `NeedsAttention` and keeps the process-local filesystem mutation gate closed for diagnosis. + +### Durable completion and realtime publication + +The durable completion boundary atomically records terminal move state, the idempotent move-history event, and the unique `MoveScanHandoff`. Lease heartbeat and the per-audiobook mutation lock stop after that commit. Webhooks, toasts, scan dispatch, and SignalR publication are post-commit effects and must not make an already completed move appear to lose its lease or roll back filesystem state. + +Full `AudiobookUpdate` events are published through `IAudiobookUpdatePublisher`. The infrastructure publisher serializes updates per audiobook and reloads the current entity immediately before mapping and broadcasting, preventing an older move or scan snapshot from overwriting newer client state. Post-commit effects use host cancellation, not the completed move lease token. Root-folder relocation start, retry, and reconciliation likewise check request or host cancellation immediately before the transaction commit, complete the commit noncancelably, and treat SignalR publication as best effort so a committed saga is never reported as rolled back by a later disconnect. + +### Durable move-to-scan handoff + +Every completed move owns at most one database-unique `MoveScanHandoff`, including the authoritative target path identity. Handoffs transition through `Pending`, `Claimed`, and terminal `Succeeded`, `Failed`, or `Superseded` states. Claim leases and attempt generations fence dispatch, heartbeat renewal, completion, and manual retry. Manual retry must match the exact terminal scan-job ID and attempt generation it is reopening. + +`ScanQueueService` keeps move-handoff dispatch reservations private until `MarkDispatchedAsync` succeeds, so ordinary callers never receive an unpublished scan-job ID. `MoveScanHandoffRecoveryService` and immediate post-move dispatch use the same claim path. Before discovery, `ScanJobProcessor` verifies that the handoff target still matches the audiobook's current path identity; stale attempts terminate as `Superseded` without reading files or mutating metadata. Every production terminal path goes through `CommitTerminalJobStatusAsync`: cancellation is checked before terminal persistence, the durable history or handoff decision is then committed noncancelably, and the in-memory queue transition follows with that authoritative outcome before client publication. Lease-loss-only transitions do not create a second terminal record and instead mirror the newer durable owner directly in memory. + +## Audiobook File Ownership and Rename Coordination + +Audiobook file ownership is a database-enforced filesystem identity contract. `AudiobookFile.Path` remains a storage representation and may be absolute or relative to the owning audiobook's authoritative `BasePath`, but it cannot be mutated independently of its persisted canonical path, syntax, resolved case sensitivity, requested case-sensitivity mode, identity boundary, lookup key, ownership key, version, and state. All production creation flows use `IAudiobookFileService` and `IAudiobookFileRepository.ClaimAsync`; raw check-then-insert path equality is not an ownership decision. A filtered unique database index on valid ownership keys is the final concurrency authority, including simultaneous claims made under different audiobook operation locks. + +Legacy rows are reconciled in restart-safe batches after migrations. Resolvable unique rows become `Valid`; equivalent duplicate groups become `Conflict`; unavailable paths remain `Unavailable`. Conflict and unavailable rows are retained rather than reassigned or deleted. A new claim whose conservative lookup identity overlaps unresolved legacy ownership fails closed and requires operator resolution. Every rename, move, destination rewrite, relocation, or other path mutation updates the stored path and complete identity together. + +`Audiobook.BasePath` and legacy `FilePath` are metadata and expected-state tokens, not recursive filesystem ownership. A physical move must derive its source root from valid tracked `AudiobookFile` identities and publish an immutable manifest containing the exact files, required directories, lengths, timestamps, and hashes. Queue deduplication binds audiobook, source endpoint, target endpoint, and manifest digest. Workers may load and advance that persisted manifest but cannot create or append ownership evidence during execution. Broad author folders, shared flat folders, and stale metadata paths therefore cannot authorize moving or deleting sibling content. Missing, unresolved, changed, linked, or ambiguous tracked evidence fails closed with an explicit repair or `NeedsAttention` outcome. + +Path-scoped scans follow the same boundary rule. The API, manual import workflow, and move handoff must authorize the requested scan path against a configured root or output boundary and persist the complete `PathIdentitySnapshot` before queue publication. `ScanQueueService` never resolves a caller path into authority. Focused scans are explicitly non-authoritative for absence reconciliation; only a complete authoritative scan may delete missing tracked rows, and incomplete enumeration or ambiguous attribution preserves existing records. + +When discovery selects exactly one stable-identifier directory, that directory is authoritative for every new ownership claim, including exact-title matching, directory expansion, and embedded-metadata enrichment. Conflicting identifier directories fail closed. Existing tracked files outside the selected directory are preserved for compatibility, but they cannot authorize new outside claims, broaden the selected boundary, or widen `Audiobook.BasePath`. Containment always uses the persisted filesystem syntax and case semantics, and linked files or directories never extend the boundary. + +Move manifest identity version 1 hashes a deterministic binary encoding with a fixed domain header, explicit big-endian field widths, an entry count, length-prefixed normalized UTF-8 paths and hash bytes, and an explicit hash-presence marker. The active deduplication key binds the audiobook, both persisted endpoint identities, the normalized manifest digest, and the persisted target-boundary physical-generation authorization. Released pre-durable jobs are reconciled or fenced from mutation at startup; unsupported or malformed durable state requires attention rather than being upgraded from intermediate feature-branch protocol versions. + +Metadata-only destination rewrites are the explicit repair surface for stale or invalid stored paths. They update metadata and complete path identities under the global filesystem mutation coordinator followed by the audiobook operation lock without claiming source filesystem ownership or enqueuing a physical move. New metadata repairs and retries begin only after startup filesystem reconciliation reaches `Ready`; being non-physical does not permit them to race deletion, rename, root-relocation, directory-ownership, or audiobook-file identity recovery. Root-folder metadata repair is isolated at the audiobook boundary: each audiobook attributable to the source root is preflighted against the target semantics and either rebased completely or, only for a currently repairable target-identity collision/conflict, left completely unchanged as a durable skipped item with a machine-readable reason. A syntactically unresolvable stored path that cannot be attributed safely to the source root is preserved without being claimed by that relocation; an attributable rewrite failure that has no supported operator repair rejects before the partial root repair is published. Case-sensitive source identities that collapse on an insensitive target and unresolved target ownership can therefore leave that audiobook in `NeedsAttention` without preventing safe audiobooks or the configured root itself from moving to the repaired path. Skipped-audiobook directory ownership is conservatively retired rather than transferred, and metadata-only rewrites clear unproven file physical-generation evidence. While skipped items remain, the old source boundary stays protected and another root path change is blocked. The common audiobook filesystem-mutation gate fences any audiobook still on the source side of an active root relocation (or explicitly persisted as skipped), including the crash window where a physical relocation has durably reserved its target but has not yet published child MoveJobs; successfully rebased or moved target-side audiobooks remain available once their own move recovery permits it. A physical relocation interrupted before any MoveJob was durably published exposes an explicit abandon capability only when the root still points at the persisted source and no move or ownership journal exists. Abandonment reconciles provably empty Listenarr-created target reservations, retains every non-empty or unprovable target directory without deleting it, and then releases the relocation without changing audiobook metadata or source files. Metadata-only retry depends on persisted path semantics rather than physical target-directory authority, so a missing target can remain `Unavailable` while skipped metadata is repaired; physical relocation continues to require target-generation authorization. Retry and startup recovery reconstruct persisted source syntax/case semantics without requiring the old source to be mounted on the current host, including contextual `//` syntax used by explicit metadata repair, and use durable relocation status transitions (`Pending`/`Failed` for incomplete metadata commit, `NeedsAttention` for committed skipped items) rather than human error-message text to choose recovery behavior. Target-identity collision repair is relocation-scoped metadata surgery: it may remove only a currently colliding tracked file row owned by the skipped audiobook, never deletes or renames a filesystem object, and remains blocked while move, organize/rename, or deletion recovery owns that audiobook. Failed physical jobs can be manually requeued only when their persisted source, target, identities, and tracked-file manifest remain valid; otherwise they stay `NeedsAttention` for operator resolution. + +Filesystem-mutating and file-ownership-claiming flows, including move, scan, import, file registration, and rename execution, acquire locks in this order: + +```text +global filesystem mutation coordinator +→ ordered audiobook operation coordinator +``` + +Settings, configured roots, audiobook state, file paths, and filesystem identities are loaded after both boundaries are acquired. Rename requests carry the preview's expected current folder and each file's expected current path. Execution validates the complete operation plan, current root authorization, destination ownership, links and traversal defenses, and duplicate destinations before touching the filesystem. A stale folder, root, path, semantics, or ownership snapshot returns a conflict instead of regenerating the request against newer state. Every physical organize file move, including a compensation move during rollback, is bound in `FileMutationJournals` to its `AudiobookId` and exact `AudiobookFileId` (`0` for the legacy single-file path). Filesystem work reaches `Completed` before owner metadata can commit. The audiobook path metadata and every proven forward or rollback journal are then committed through the same scoped SQLite unit of work, advancing those journals to the terminal `OwnerMetadataReconciled` state. If persistence fails, rollback uses the same owner-bound protocol; any rollback whose generation cannot be proven remains unresolved and blocks later mutation. Startup reconciles those owner-bound journals before ordinary audiobook-file identity reconciliation, resumes incomplete filesystem work when generation evidence permits, recognizes a safely restored source as compensation, and fails the mutation gate on ambiguous or `NeedsAttention` state. `OwnerMetadataReconciled` and `NeedsAttention` are terminal; replay of an already reconciled low-level operation may verify its durable destination generation but cannot regain authority merely because the historical source pathname is reused later. + +`IFileMover` has no generic directory-copy or directory-move surface. Directory relocation belongs to `AudiobookContentMoveService`, where every source entry, target directory, cleanup authorization, and restart checkpoint is bound to the persisted move job and manifest. + +Generic single-file `Move`, `Copy`, and `HardlinkCopy` operations use `FileMutationJournals` keyed by a non-empty stable operation ID. Import/publication workflows may derive that ID deterministically when their retry contract requires the same operation identity. Organize/rename attempts instead use a fresh durable operation ID because their journals are owner-bound and startup-discoverable; this prevents completed or compensated history for an earlier attempt from colliding with a later legitimate retry of the same paths. File mutations publish directly to the final destination name under pinned parent handles; no staging file, hidden library-side scratch entry, or robocopy recovery tree is created. The journal records source physical identity and content proof, persists the target physical identity before verification, and advances monotonically through verification and, for supported moves, explicit source-deletion authorization before the source generation can be deleted. Organize/rename moves additionally bind the journal to the owning audiobook and file so process restart can reconcile filesystem completion with owner metadata. Native same-volume rename preserves physical identity when available. Copy and copy-based fallbacks hash and verify content. On Windows, a supported cross-volume move may copy/verify before deleting the proven source generation; on Unix-like systems, a move whose source and destination are on different filesystem volumes is rejected before journal creation or file publication because exact-generation source deletion cannot be guaranteed without introducing a library-side namespace claim. A replaced target, changed/recreated source, alias conflict, missing durable proof, or path-semantics mismatch fails closed into durable attention rather than adopting an uncertain file. ## Background Worker Ownership Hosted workers must have one clear owner for each state transition. Queue services can dedupe, persist, or expose job status, but they should not perform the durable state transition that belongs to a worker. +Audiobook path-bearing mutations are serialized per audiobook through `IAudiobookOperationCoordinator`. Move, scan, import, rename, file registration, and metadata-only destination rewrites must acquire the keyed operation boundary before loading mutable audiobook path state. The coordinator is reentrant for nested same-audiobook helpers and follows the documented single Listenarr process per database deployment model. + Background workers expose DI-facing processor contracts for deterministic cycle/job testing. Periodic workers should prefer `IWorkerCycleRunner` and `TimeProvider` for cancellation-safe loops and testable delays. Queue-backed workers should keep hosted services as channel adapters and put per-job orchestration in processors such as `ScanJobProcessor` and `MoveJobProcessor`. Exception filters should use `WorkerExceptionClassifier.IsNonFatal` when adding or refactoring catch blocks so fatal runtime exceptions are not swallowed. Worker processors should emit lightweight `worker.*` metrics for started, completed, failed, skipped, and retry-scheduled outcomes where the state applies. | Worker | Processor | Owned durable transitions | Forbidden transitions | Retry/backoff | Idempotency | Handoff | | --- | --- | --- | --- | --- | --- | --- | | `DownloadMonitorService` | `DownloadMonitorProcessor` | Polls enabled clients, updates active download progress, transitions client-reported failures to `Failed`, removes unreconcilable active client-backed downloads after a trusted live client snapshot proves either that their external ID is missing or that a non-DDL record has no stored external client ID, and enqueues an import job only when a download transitions to `Completed`. | Must not import, move, scan, or clean up files. | Per-client exponential polling backoff, capped at 15 minutes; success resets the client failure counter. | Download submission reserves a database-unique active audiobook key before contacting a client; duplicate import enqueue is delegated to `DownloadProcessingJobService`. Orphan cleanup is skipped for cached, unavailable, or suspiciously empty snapshots. DDL downloads are internally tracked with `DownloadClientId == "DDL"` and are excluded from external-ID cleanup. | `DownloadProcessingJobService` receives completed downloads for import. | | `DirectDownloadService` | `DirectDownloadProcessor` | Owns internal DDL transfer state: `Queued -> Downloading -> Completed/Failed`, writes one trusted artifact or an atomic artifact batch to local staging, updates aggregate progress, and enqueues one import job only after every artifact is durable. | Must not import or extract files, mark imports final, poll external download clients, or clean up moved downloads. | Periodic polling; failed HTTP/file writes remove the whole staging batch and transition the DDL record to `Failed` so active deduplication is released and the UI stops showing a stuck queued item. | Only rows with `DownloadClientId == "DDL"` and a supported direct-download source policy are fetched; the selected policy validates the complete plan, every original URL, and every redirect target. Partial files are written under app config storage with a `.partial` suffix and replaced atomically on success. Rows without a persisted artifact plan retain legacy one-file behavior. | `DownloadProcessingJobService` receives completed local DDL files or directories for import. | -| `DownloadProcessingJobProcessor` | `DownloadProcessingJobProcessor` | Owns import execution and checkpointed finalization: `Completed -> ImportPending -> Moved` on success and `Completed/ImportPending -> ImportBlocked` after retries are exhausted. `Moved`, processing-job completion, and the terminal import history event are committed together only after files are registered, the client item is marked imported, and a scan is queued. DDL imports resolve source files directly from Listenarr's local staging path and skip external-client mark-import calls. | Must not poll clients, download DDL payloads, or perform deferred client cleanup. | Job-level retry via `DownloadProcessingJob.ScheduleRetry`; persisted checkpoints prevent completed file imports from being repeated during finalization retries. | Active jobs use a database-unique normalized download key; recent completed jobs retain the cooldown guard. A stale job for an already `Moved` download completes as a no-op. | `ScanQueueService` receives the post-import library scan request. | +| `DownloadProcessingJobProcessor` | `DownloadProcessingJobProcessor` | Owns import execution and checkpointed finalization: `Completed -> ImportPending -> Moved` on success and `Completed/ImportPending -> ImportBlocked` after retries are exhausted. `Moved`, processing-job completion, and the terminal import history event are committed together only after files are registered, the client item is marked imported, and a scan is queued. DDL imports resolve source files directly from Listenarr's local staging path and skip external-client mark-import calls. | Must not poll clients, download DDL payloads, or perform deferred client cleanup. | Job-level retry via `DownloadProcessingJob.ScheduleRetry`; persisted checkpoints prevent completed file imports from being repeated during finalization retries. | Active jobs use a database-unique normalized download key; recent completed jobs retain the cooldown guard. A stale job for an already `Moved` download completes as a no-op. Multi-file destination planning reuses any existing base or numeric-suffix destination whose bytes already match the source before allocating a new suffix, so replay cannot manufacture an unbounded `(n)` sequence for the same completed files. | `ScanQueueService` receives the post-import library scan request. | | `DownloadProcessingJobCleanupService` | `DownloadProcessingJobCleanupProcessor` | Deletes old terminal `DownloadProcessingJob` rows after the retention window so the processing table does not grow unbounded. | Must not import downloads, move files, poll clients, remove client items, or change download state. | Daily cadence after startup delay; non-fatal failures are logged by the shared worker cycle runner and retried on the next cycle. | Only terminal `Completed`/`Failed` jobs older than retention are removed; active `Pending`, `Processing`, and `Retry` jobs remain untouched even when old. | `IDownloadProcessingJobService.CleanupOldJobsAsync` performs the cleanup. | -| `ScanBackgroundService` | `ScanJobProcessor` | Consumes scan jobs and reconciles audiobook files/metadata for the audiobook library path. | Must not move audiobook roots or import download payloads. | In-memory scan jobs can be requeued from failed/completed/queued status. | `ScanQueueService` dedupes queued/processing jobs by audiobook and path; explicit rescans are allowed after completion/failure. | Broadcasts library updates after reconciliation. | -| `MoveBackgroundService` | `MoveJobProcessor` | Owns audiobook filesystem relocation and move-job status transitions `Queued -> Processing -> Completed/Failed`. | Must not import downloads or rewrite scan ownership. | Failed jobs keep `AttemptCount` and can be requeued through `MoveQueueService`. | `MoveQueueService` uses async persistence plus a database-unique active deduplication key for audiobook and requested path; terminal transitions release the key. | Broadcasts library updates after a completed move. | +| `ScanBackgroundService` | `ScanJobProcessor` | Consumes preauthorized scan jobs, reconciles only files attributable to the audiobook within the persisted path boundary, and commits the authoritative move-scan handoff attempt before changing the in-memory scan state. | Must not move audiobook roots, import download payloads, manufacture path authority, claim ambiguous files, or delete missing rows from a focused or incomplete scan. | Ordinary in-memory scans can be requeued explicitly with their original identity and authority scope. Move-owned scans use a durable `MoveScanHandoff` lease and attempt generation; failed handoffs can be explicitly reopened and later attempts supersede stale workers. | `ScanQueueService` keeps database work outside its short in-memory queue gate and dedupes by audiobook, endpoint identity, correlation, and reconciliation authority. `MoveScanHandoffRecoveryService` atomically claims pending or expired handoffs, and terminal handoff updates are fenced by attempt generation and database idempotency keys. | Broadcasts library updates only after durable terminal completion. Move completion creates one database-unique `MoveScanHandoff`; immediate dispatch and periodic recovery use the same claim path and persisted target identity. | +| `MoveBackgroundService` | `MoveJobProcessor` | Owns audiobook filesystem relocation and move-job transitions `Queued/RetryScheduled -> Running -> Completed/Failed/NeedsAttention/Superseded`, including immutable tracked-file manifest checkpoints, target scaffolding, filesystem ownership evidence, metadata rebasing, artifact cleanup, and completion handoffs. | Must not import downloads, infer ownership from `BasePath`, create manifest entries during execution, move foreign sibling content, or claim scan execution ownership. It may request a post-move scan only through the durable scan handoff store. | Transient filesystem and completion-handoff failures use persisted exponential backoff with jitter and a bounded automatic retry count. Exhaustion transitions to `NeedsAttention`; explicit manual requeue validates the persisted source, target, complete identities, and manifest before resetting the retry budget while preserving lease generation fencing. Legacy or manifestless jobs remain `NeedsAttention` unless reconciliation can prove a safe terminal outcome. | `MoveQueueService` uses async persistence plus a database-unique versioned key over audiobook, source endpoint, target endpoint, and manifest digest. `IMoveExecutionStore` translates provider failures and fences every mutation/checkpoint by lease generation. Immutable manifests, database-backed physical-generation proofs, markerless file-mutation journals, and persisted target-scaffolding identities make replay idempotent. Ordinary foreign content is preserved; only manifest-owned paths and Listenarr-owned scaffolding are eligible for cleanup. Move history, one unique `MoveScanHandoff`, and terminal move state are committed atomically for genuine completions. | After terminal commit, performs durable scan-handoff dispatch and best-effort webhooks, toasts, and audiobook broadcasts outside the per-audiobook lock. | | `MovedDownloadCleanupService` | `MovedDownloadCleanupProcessor` | Owns deferred download-client cleanup only for `Moved` downloads whose client policy requests cleanup and whose import is proven durable by a completed processing job, `LastImportedAt`, imported unified history, legacy imported download history, or old legacy `Moved` state. It removes the operational DB record only after configured client cleanup succeeds; the `none` policy retains the imported record. Legacy `Moved`-state proof may remove stale client/DB state but must not authorize external file deletion. | Must never import files, clean up an uncommitted import, delete history, or change a download back out of `Moved`. | Polls on the configured interval and retains failed cleanup records for future retries. | Cleanup attempts share the import correlation ID and remain in append-only history after operational records are removed. | Download-client gateway removes eligible client items. | | `QueueMonitorService` | `QueueMonitorProcessor` | None; it observes external queue snapshots and emits SignalR updates. | Must not persist download/import/scan state. | Adaptive polling interval based on queue activity. | Snapshot comparison suppresses duplicate broadcasts. | `DownloadHub` receives `QueueUpdate` messages. | | `AutomaticSearchService` | `AutomaticSearchProcessor` | Owns periodic wanted-item search decisions and download submission requests. | Must not import downloads, move files, or mark scan state. | Runs every 6 hours after startup delay; one failed audiobook does not stop the cycle. | Active-download and cutoff-quality checks prevent duplicate active work on replay. | `IDownloadService.StartDownloadAsync` creates the download handoff. | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 724ebc8bd..3fa13c78b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -151,6 +151,15 @@ This project follows a layered pattern: domain models in `listenarr.domain`, EF - Run frontend type checks: `cd fe && npm run type-check` - Ensure all tests pass before submitting PR +### Compatibility and migration development policy + +The compatibility boundary for a pull request is the branch it targets. Databases, filesystem artifacts, API states, and recovery formats that were produced only by an unmerged feature branch or intermediate PR image are development artifacts, not supported upgrade inputs. + +- Do not keep production compatibility paths solely for intermediate versions of an unmerged PR. +- Preserve and regression-test compatibility with schemas and persisted data that actually exist on the target branch. +- Once an EF migration is merged into a supported branch it is immutable history. Before merge, superseded branch-only migrations should be removed and the final migration set regenerated with EF from the target branch model snapshot. +- Do not hand-edit EF scaffolding to emulate intermediate branch schemas. Put unavoidable data repair for real target-branch upgrades in explicit, tested startup/reconciliation primitives. + ### Branching Model Listenarr follows a **canary → beta → main** release flow: @@ -414,5 +423,6 @@ If you have any questions about contributing, please: 4. In `listenarr.api/Program.cs` call the infrastructure registration extension instead of registering types inline. 5. Delete the old API placeholder files and run `dotnet test` to verify no regressions. - Add a small DI/registration unit test (DependencyInjectionTests) that asserts required services are resolvable; run it early in CI to catch layering regressions. +- Create EF Core migrations with `dotnet ef migrations add` only. Do not hand-author migration `.cs`, `.Designer.cs`, or model snapshot files; generated migrations may be reviewed, but the scaffold is the source of truth so EF discovery metadata and accumulated snapshots stay complete. Thank you for contributing to Listenarr! 🎵📚 diff --git a/fe/package-lock.json b/fe/package-lock.json index c3fcc0f51..448fac84f 100644 --- a/fe/package-lock.json +++ b/fe/package-lock.json @@ -49,7 +49,7 @@ "vue-tsc": "^3.3.4" }, "engines": { - "node": ">=24.15.0" + "node": "^24.15.0" } }, "node_modules/@asamuzakjp/css-color": { @@ -419,7 +419,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -431,7 +430,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD", "optional": true }, @@ -439,7 +437,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -450,7 +447,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD", "optional": true }, @@ -458,7 +454,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -469,7 +464,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD", "optional": true }, @@ -816,7 +810,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -929,7 +922,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -946,7 +938,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -963,7 +954,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -980,7 +970,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -997,7 +986,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1014,7 +1002,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1031,7 +1018,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1048,7 +1034,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1065,7 +1050,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1082,7 +1066,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1099,7 +1082,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1116,7 +1098,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1133,7 +1114,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1152,7 +1132,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1169,7 +1148,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1204,7 +1182,6 @@ "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1215,7 +1192,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD", "optional": true }, @@ -1281,7 +1257,7 @@ "version": "24.13.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.1.tgz", "integrity": "sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -1291,7 +1267,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/sinonjs__fake-timers": { @@ -4089,7 +4065,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4746,7 +4721,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -5100,7 +5075,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5121,7 +5095,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5142,7 +5115,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5163,7 +5135,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5184,7 +5155,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5205,7 +5175,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5226,7 +5195,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5247,7 +5215,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5268,7 +5235,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5289,7 +5255,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5310,7 +5275,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ diff --git a/fe/src/App.vue b/fe/src/App.vue index 1571c998b..10a2ad840 100644 --- a/fe/src/App.vue +++ b/fe/src/App.vue @@ -39,6 +39,16 @@ +
+ {{ filesystemInitializationMessage }} +
+
@@ -558,11 +574,15 @@ import { useConfirmService } from '@/composables/confirmService' import { useNotification } from '@/composables/useNotification' import { useDownloadsStore } from '@/stores/downloads' import { useLibraryStore } from '@/stores/library' +import { useMoveJobsStore } from '@/stores/moveJobs' +import { useLibraryDeleteOperationsStore } from '@/stores/libraryDeleteOperations' +import { useScanNotificationsStore } from '@/stores/scanNotifications' +import { useFilesystemReadinessStore } from '@/stores/filesystemReadiness' import { useAuthStore } from '@/stores/auth' import { apiService } from '@/services/api' import { getStartupConfigCached } from '@/services/startupConfigCache' import { handleImageError } from '@/utils/imageFallback' -import { Pill } from '@/components/base' +import { Pill, ProgressBar } from '@/components/base' import { getPlaceholderUrl } from '@/utils/placeholder' import { useProtectedImages } from '@/composables/useProtectedImages' import { logSessionState, clearAllAuthData } from '@/utils/sessionDebug' @@ -586,6 +606,10 @@ const { notification, close: closeNotification } = useNotification() const { getProtectedImageSrc } = useProtectedImages() const downloadsStore = useDownloadsStore() const libraryStore = useLibraryStore() +const moveJobsStore = useMoveJobsStore() +const deleteOperationsStore = useLibraryDeleteOperationsStore() +const scanNotificationsStore = useScanNotificationsStore() +const filesystemReadinessStore = useFilesystemReadinessStore() const auth = useAuthStore() const authEnabled = ref(false) const startupConfigLoaded = ref(false) @@ -793,7 +817,6 @@ const closeMobileMenu = () => { } // Reactive state for badges and counters -const notificationCount = computed(() => recentNotifications.filter((n) => !n.dismissed).length) const queueItems = ref([]) const wantedCount = computed( () => libraryStore.audiobooks.filter((book) => book.wanted === true).length, @@ -872,13 +895,136 @@ type HistoryNotification = { title: string message: string icon?: string - timestamp: string + timestamp?: string dismissed?: boolean + progress?: number + phase?: string + active?: boolean + showProgressPercentage?: boolean + indeterminate?: boolean } const recentNotifications = reactive([]) const recentDownloadTitles = ref>(new Set()) // Track recent download titles to avoid spam +const activeMoveNotifications = computed(() => + moveJobsStore.trackedJobs.map((job) => { + const audiobookTitle = job.audiobookId + ? libraryStore.audiobooks.find((book) => book.id === job.audiobookId)?.title + : undefined + const target = job.target ? ` to ${job.target}` : '' + return { + id: `move-${job.jobId}`, + title: audiobookTitle ? `Moving ${audiobookTitle}` : 'Moving audiobook', + message: `${job.phase || 'Preparing move'}${target}`, + icon: 'ph ph-folder-open', + progress: job.progress, + phase: job.phase, + active: true, + } + }), +) + +const scanNotifications = computed(() => + scanNotificationsStore.jobs + .filter((job) => job.visible && !job.dismissed) + .sort((left, right) => right.timestamp.localeCompare(left.timestamp)) + .map((job) => { + const normalizedStatus = job.status.toLowerCase() + const active = normalizedStatus === 'queued' || normalizedStatus === 'processing' + const audiobookTitle = job.audiobookId + ? libraryStore.audiobooks.find((book) => book.id === job.audiobookId)?.title + : undefined + const subject = audiobookTitle || 'audiobook folder' + const title = + normalizedStatus === 'queued' + ? `Scan queued: ${subject}` + : normalizedStatus === 'processing' + ? `Scanning ${subject}` + : normalizedStatus === 'completed' + ? `Scan complete: ${subject}` + : normalizedStatus === 'superseded' + ? `Scan stopped: ${subject}` + : `Scan failed: ${subject}` + const message = + normalizedStatus === 'queued' + ? 'Waiting to scan folder' + : normalizedStatus === 'processing' + ? 'Scanning folder' + : normalizedStatus === 'completed' + ? job.found != null + ? `${job.found} file${job.found === 1 ? '' : 's'} found${job.created != null ? ` · ${job.created} added` : ''}` + : 'Folder scan completed' + : job.error || 'The folder scan did not complete' + + return { + id: `scan-${job.jobId}`, + title, + message, + icon: 'ph ph-folder-open', + timestamp: job.timestamp, + progress: active ? 0 : undefined, + active, + showProgressPercentage: false, + indeterminate: active, + } + }), +) + +const deleteNotifications = computed(() => + deleteOperationsStore.operations + .filter((operation) => !operation.dismissed) + .map((operation) => { + const active = operation.status === 'deleting' + const isBulk = operation.kind === 'bulk' + const title = active + ? isBulk + ? operation.title + : `Deleting ${operation.title}` + : operation.status === 'completed' + ? isBulk + ? `Deleted ${operation.deleted} audiobook${operation.deleted === 1 ? '' : 's'}` + : `Deleted ${operation.title}` + : isBulk + ? `Delete incomplete: ${operation.deleted}/${operation.total} audiobooks` + : `Delete failed: ${operation.title}` + const message = isBulk + ? active + ? operation.currentTitle + ? `${operation.processed}/${operation.total} · ${operation.currentTitle}` + : `${operation.processed}/${operation.total}` + : operation.status === 'completed' + ? `${operation.deleted}/${operation.total} deleted` + : `${operation.deleted}/${operation.total} deleted · ${operation.failed} failed${operation.error ? ` · ${operation.error}` : ''}` + : active + ? 'Removing audiobook from library' + : operation.status === 'completed' + ? 'Removed from library' + : operation.error || 'Could not remove audiobook from library' + + return { + id: operation.id, + title, + message, + icon: 'ph ph-file-remove', + timestamp: operation.startedAt, + progress: active ? operation.progress : undefined, + active, + showProgressPercentage: isBulk, + indeterminate: active && !isBulk, + } + }), +) + +const visibleNotifications = computed(() => [ + ...activeMoveNotifications.value, + ...scanNotifications.value, + ...deleteNotifications.value, + ...recentNotifications.filter((notification) => !notification.dismissed), +]) + +const notificationCount = computed(() => visibleNotifications.value.length) + function pushNotification(n: HistoryNotification) { // Ensure new notifications are not dismissed const notification = { ...n, dismissed: false } @@ -890,9 +1036,15 @@ function pushNotification(n: HistoryNotification) { function clearNotifications() { recentNotifications.length = 0 recentDownloadTitles.value.clear() + deleteOperationsStore.clearFinished() + scanNotificationsStore.clearFinished() } function dismissNotification(id: string) { + deleteOperationsStore.dismiss(id) + if (id.startsWith('scan-')) { + scanNotificationsStore.dismiss(id.slice('scan-'.length)) + } const notification = recentNotifications.find((n) => n.id === id) if (notification) { notification.dismissed = true @@ -930,7 +1082,99 @@ function notificationIconComponent(icon?: string) { let unsubscribeQueue: (() => void) | null = null let unsubscribeFilesRemoved: (() => void) | null = null +let unsubscribeScanJobs: (() => void) | null = null let unsubscribeSignalRConnected: (() => void) | null = null +let scanStatusReconcileTimer: ReturnType | null = null +let scanStatusReconcileInFlight = false + +const hasActiveVisibleScan = () => + auth.user.authenticated && + scanNotificationsStore.jobs.some((job) => { + const status = job.status.toLowerCase() + return job.visible && !job.dismissed && (status === 'queued' || status === 'processing') + }) + +const stopScanStatusReconciliation = () => { + if (scanStatusReconcileTimer != null) { + window.clearInterval(scanStatusReconcileTimer) + scanStatusReconcileTimer = null + } +} + +const reconcileActiveScanStatuses = async () => { + if (scanStatusReconcileInFlight) return + + const activeJobs = scanNotificationsStore.jobs.filter((job) => { + const status = job.status.toLowerCase() + return job.visible && !job.dismissed && (status === 'queued' || status === 'processing') + }) + if (activeJobs.length === 0) { + stopScanStatusReconciliation() + return + } + + scanStatusReconcileInFlight = true + try { + await Promise.all( + activeJobs.map(async (job) => { + try { + const status = await apiService.getScanJobStatus(job.jobId) + scanNotificationsStore.applyUpdate({ + jobId: job.jobId, + audiobookId: status.audiobookId, + status: status.status, + error: status.error, + }) + } catch (error) { + const status = + error && typeof error === 'object' && 'status' in error + ? Number((error as { status?: unknown }).status) + : undefined + if (status === 404) { + scanNotificationsStore.applyUpdate({ + jobId: job.jobId, + audiobookId: job.audiobookId, + status: 'Failed', + error: + 'Scan status is no longer available. Refresh the audiobook to verify the current files.', + }) + return + } + + logger.debug('Unable to reconcile scan job status', { jobId: job.jobId, error }) + } + }), + ) + } finally { + scanStatusReconcileInFlight = false + if (!hasActiveVisibleScan()) { + stopScanStatusReconciliation() + } + } +} + +const syncScanStatusReconciliation = () => { + if (!hasActiveVisibleScan()) { + stopScanStatusReconciliation() + return + } + + if (scanStatusReconcileTimer == null) { + void reconcileActiveScanStatuses() + scanStatusReconcileTimer = window.setInterval(() => { + void reconcileActiveScanStatuses() + }, 1500) + } +} + +watch( + () => + scanNotificationsStore.jobs + .map((job) => `${job.jobId}:${job.status}:${job.visible}:${job.dismissed === true}`) + .join('|'), + syncScanStatusReconciliation, + { flush: 'post' }, +) const syncLibrarySnapshot = async () => { try { @@ -1106,6 +1350,7 @@ watch( () => auth.user.authenticated, () => { void refreshAuthPresentationFromStartupConfig(true) + syncScanStatusReconciliation() }, ) @@ -1113,6 +1358,7 @@ watch( // Initialize: Subscribe to SignalR for real-time updates (NO POLLING!) onMounted(async () => { + filesystemReadinessStore.start() logger.debug('Initializing real-time updates via SignalR...') // Session debugging utilities @@ -1156,12 +1402,17 @@ onMounted(async () => { // If authenticated, load protected resources and enable real-time updates if (auth.user.authenticated) { + // Keep durable move jobs globally visible so the notification dropdown can + // show progress even when the Activity page is not mounted. + moveJobsStore.start() + // Hydrate the app once, then keep it current from SignalR updates. await Promise.all([downloadsStore.loadDownloads(), syncLibrarySnapshot()]) unsubscribeSignalRConnected = signalRService.onConnected(() => { if (auth.user.authenticated) { void syncLibrarySnapshot() + void moveJobsStore.loadActiveJobs() } }) @@ -1172,6 +1423,10 @@ onMounted(async () => { queueItems.value = queueSnapshot.items }) + unsubscribeScanJobs = signalRService.onScanJobUpdate((job) => { + scanNotificationsStore.applyUpdate(job) + }) + // Prepare toast helper for this mounted scope const toast = useToast() @@ -1340,9 +1595,15 @@ onUnmounted(() => { if (unsubscribeFilesRemoved) { unsubscribeFilesRemoved() } + if (unsubscribeScanJobs) { + unsubscribeScanJobs() + } + stopScanStatusReconciliation() if (unsubscribeSignalRConnected) { unsubscribeSignalRConnected() } + moveJobsStore.stop() + filesystemReadinessStore.stop() // Event listeners are automatically cleaned up by VueUse }) @@ -1389,14 +1650,35 @@ const dismissSecurityWarning = () => { securityWarningDismissed.value = true } +const showFilesystemInitializationBanner = computed( + () => + !hideLayout.value && + (filesystemReadinessStore.filesystemInitializing || filesystemReadinessStore.filesystemFailed), +) + +const filesystemInitializationMessage = computed(() => { + if (filesystemReadinessStore.filesystemFailed) { + return ( + filesystemReadinessStore.readiness?.filesystemErrorMessage || + 'Library filesystem initialization failed. Browsing remains available, but file operations are disabled.' + ) + } + + return 'Library filesystem is initializing. Browsing is available, but file operations are temporarily disabled.' +}) + const appShellCssVars = computed(() => { const topNavHeightPx = 60 - const bannerHeightPx = showSecurityWarningBanner.value ? 44 : 0 + const securityBannerHeightPx = showSecurityWarningBanner.value ? 44 : 0 + const filesystemBannerHeightPx = showFilesystemInitializationBanner.value ? 38 : 0 + const bannerHeightPx = securityBannerHeightPx + filesystemBannerHeightPx const topOffsetPx = hideLayout.value ? 0 : topNavHeightPx + bannerHeightPx return { '--top-nav-height': `${topNavHeightPx}px`, - '--security-banner-height': `${bannerHeightPx}px`, + '--security-banner-height': `${securityBannerHeightPx}px`, + '--filesystem-banner-height': `${filesystemBannerHeightPx}px`, + '--app-banner-height': `${bannerHeightPx}px`, '--app-top-offset': `${topOffsetPx}px`, } as Record }) @@ -1422,6 +1704,8 @@ these are not present, the Google Fonts import in `fe/index.html` will be used a #app { --top-nav-height: 60px; --security-banner-height: 0px; + --filesystem-banner-height: 0px; + --app-banner-height: 0px; --app-top-offset: var(--top-nav-height); font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 0; @@ -1444,14 +1728,14 @@ these are not present, the Google Fonts import in `fe/index.html` will be used a justify-content: space-between; align-items: center; position: fixed; - top: var(--security-banner-height); + top: var(--app-banner-height); left: 0; right: 0; z-index: 1000; } .top-nav.auth-warning-visible { - top: var(--security-banner-height); + top: var(--app-banner-height); } .security-warning-banner { @@ -1505,6 +1789,29 @@ these are not present, the Google Fonts import in `fe/index.html` will be used a outline-offset: 1px; } +.filesystem-initialization-banner { + position: fixed; + top: var(--security-banner-height); + left: 0; + right: 0; + z-index: 1001; + height: var(--filesystem-banner-height); + display: flex; + align-items: center; + padding: 0 1rem; + background: #263548; + border-bottom: 1px solid rgba(144, 202, 249, 0.28); + color: #d7ebff; + font-size: 0.875rem; + line-height: 1.3; +} + +.filesystem-initialization-banner.failed { + background: #4a2116; + border-bottom-color: rgba(255, 183, 77, 0.28); + color: #ffd8a8; +} + .nav-brand { display: flex; align-items: center; diff --git a/fe/src/__tests__/ActivityView.mobile.spec.ts b/fe/src/__tests__/ActivityView.mobile.spec.ts index eccced07c..1978d54be 100644 --- a/fe/src/__tests__/ActivityView.mobile.spec.ts +++ b/fe/src/__tests__/ActivityView.mobile.spec.ts @@ -89,6 +89,13 @@ describe('ActivityView mobile virtualization', () => { }), })) + vi.doMock('@/stores/moveJobs', () => ({ + useMoveJobsStore: () => ({ + trackedJobs: [], + start: vi.fn(), + }), + })) + vi.doMock('@/services/errorTracking', () => ({ errorTracking: { captureException: vi.fn(), diff --git a/fe/src/__tests__/ActivityView.spec.ts b/fe/src/__tests__/ActivityView.spec.ts index 8e76fe443..e13ae13c7 100644 --- a/fe/src/__tests__/ActivityView.spec.ts +++ b/fe/src/__tests__/ActivityView.spec.ts @@ -80,6 +80,18 @@ const mockLibraryStore = (audiobooks: Array<{ id: number; title: string }> = []) })) } +let currentMoveJobsStore: Record + +const mockMoveJobsStore = (overrides: Record = {}) => { + currentMoveJobsStore = { + trackedJobs: [], + start: vi.fn(), + ...overrides, + } + + return currentMoveJobsStore +} + const mockDownloadsStore = (overrides: Record = {}) => { const store = { activeDownloads: [], @@ -116,6 +128,10 @@ describe('ActivityView', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() + mockMoveJobsStore() + vi.doMock('@/stores/moveJobs', () => ({ + useMoveJobsStore: () => currentMoveJobsStore, + })) vi.spyOn(globalThis, 'setInterval').mockReturnValue( 1 as unknown as ReturnType, ) @@ -126,6 +142,34 @@ describe('ActivityView', () => { vi.restoreAllMocks() }) + it('shows active library move progress in the unified activity list', async () => { + mockSignalR() + mockApi() + mockConfigurationStore(false) + mockLibraryStore([{ id: 42, title: 'Book' }]) + mockDownloadsStore() + mockMoveJobsStore({ + trackedJobs: [ + { + jobId: 'job-1', + audiobookId: 42, + status: 'Running', + progress: 37.5, + phase: 'Copying', + target: '/library/book', + }, + ], + }) + + const wrapper = await mountActivityView() + const vm = wrapper.vm as unknown as ActivityViewVm + const move = vm.allActivityItems.find((item) => item.id === 'move:job-1') + + expect(move).toMatchObject({ status: 'moving', progress: 37.5 }) + expect(wrapper.text()).toContain('38%') + expect(wrapper.text()).toContain('Moving') + }) + it('includes completed external downloads from the downloads store in the unified list', async () => { mockSignalR() mockApi() diff --git a/fe/src/__tests__/AddLibraryModal.relativePath.spec.ts b/fe/src/__tests__/AddLibraryModal.relativePath.spec.ts index be2854918..9becdae18 100644 --- a/fe/src/__tests__/AddLibraryModal.relativePath.spec.ts +++ b/fe/src/__tests__/AddLibraryModal.relativePath.spec.ts @@ -28,6 +28,7 @@ vi.mock('@/services/api', () => ({ getApplicationSettings: vi.fn().mockResolvedValue({ outputPath: 'C:\\root' }), getQualityProfiles: vi.fn().mockResolvedValue([]), getRootFolders: vi.fn().mockResolvedValue([]), + addToLibrary: vi.fn().mockResolvedValue({ audiobook: { id: 1 } }), }, })) @@ -41,6 +42,156 @@ const fakeBook = { } describe('AddLibraryModal relative path derivation', () => { + it('shows and submits the same normalized effective destination', async () => { + const { apiService } = await import('@/services/api') + const wrapper = mount(AddLibraryModal, { + props: { + visible: false, + book: fakeBook, + }, + attachTo: document.body, + global: { + plugins: [(await import('pinia')).createPinia()], + }, + }) + + await wrapper.setProps({ visible: true }) + await new Promise((resolve) => setTimeout(resolve, 10)) + const input = wrapper.get('input.relative-input') + await input.setValue('Author/Title') + await wrapper.vm.$nextTick() + + const preview = wrapper.get('[data-testid="effective-destination"]').text() + expect(preview).toContain('C:\\root\\Author\\Title') + + await (wrapper.vm as unknown as { addToLibrary: () => Promise }).addToLibrary() + + expect(apiService.addToLibrary).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ destinationPath: 'C:\\root\\Author\\Title' }), + ) + }) + + it('submits a configured-root relative destination whose Unix trailing whitespace is significant', async () => { + const { apiService } = await import('@/services/api') + vi.mocked(apiService.addToLibrary).mockClear() + vi.mocked(apiService.getApplicationSettings).mockResolvedValueOnce({ outputPath: '/library' }) + vi.mocked(apiService.previewLibraryPath).mockResolvedValueOnce({ + fullPath: '/library/Author/Title', + relativePath: 'Author/Title', + }) + const wrapper = mount(AddLibraryModal, { + props: { + visible: false, + book: fakeBook, + }, + attachTo: document.body, + global: { + plugins: [(await import('pinia')).createPinia()], + }, + }) + + await wrapper.setProps({ visible: true }) + await new Promise((resolve) => setTimeout(resolve, 10)) + const vm = wrapper.vm as unknown as { + options: { relativePath: string } + addToLibrary: () => Promise + } + vm.options.relativePath = 'Author/Title ' + await wrapper.vm.$nextTick() + await vm.addToLibrary() + + expect(apiService.addToLibrary).toHaveBeenCalledTimes(1) + expect(apiService.addToLibrary).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ destinationPath: '/library/Author/Title ' }), + ) + wrapper.unmount() + }) + + it('preserves a literal backslash in a Unix relative destination segment', async () => { + const { apiService } = await import('@/services/api') + vi.mocked(apiService.addToLibrary).mockClear() + vi.mocked(apiService.getApplicationSettings).mockResolvedValueOnce({ outputPath: '/library' }) + vi.mocked(apiService.previewLibraryPath).mockResolvedValueOnce({ + fullPath: '/library/Author/Title', + relativePath: 'Author/Title', + }) + const wrapper = mount(AddLibraryModal, { + props: { + visible: false, + book: fakeBook, + }, + attachTo: document.body, + global: { + plugins: [(await import('pinia')).createPinia()], + }, + }) + + await wrapper.setProps({ visible: true }) + await new Promise((resolve) => setTimeout(resolve, 10)) + const vm = wrapper.vm as unknown as { + options: { relativePath: string } + addToLibrary: () => Promise + } + vm.options.relativePath = 'Author\\Title' + await wrapper.vm.$nextTick() + await vm.addToLibrary() + + expect(apiService.addToLibrary).toHaveBeenCalledTimes(1) + expect(apiService.addToLibrary).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ destinationPath: '/library/Author\\Title' }), + ) + wrapper.unmount() + }) + + it('does not offer an arbitrary custom-path destination', async () => { + const wrapper = mount(AddLibraryModal, { + props: { + visible: false, + book: fakeBook, + }, + attachTo: document.body, + global: { + plugins: [(await import('pinia')).createPinia()], + }, + }) + + await wrapper.setProps({ visible: true }) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(wrapper.text()).not.toContain('Custom path') + expect(wrapper.find('.custom-path-input').exists()).toBe(false) + }) + + it('rejects rooted input instead of treating it as a hidden custom destination', async () => { + const { apiService } = await import('@/services/api') + vi.mocked(apiService.addToLibrary).mockClear() + const wrapper = mount(AddLibraryModal, { + props: { + visible: false, + book: fakeBook, + }, + attachTo: document.body, + global: { + plugins: [(await import('pinia')).createPinia()], + }, + }) + + await wrapper.setProps({ visible: true }) + await new Promise((resolve) => setTimeout(resolve, 10)) + const input = wrapper.get('input.relative-input') + await input.setValue('C:\\root\\Author\\Title') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain( + 'Enter a path relative to the selected configured root folder.', + ) + await (wrapper.vm as unknown as { addToLibrary: () => Promise }).addToLibrary() + expect(apiService.addToLibrary).not.toHaveBeenCalled() + }) + it('shows relative path (full minus root) when preview returns fullPath and root configured', async () => { const wrapper = mount(AddLibraryModal, { props: { diff --git a/fe/src/__tests__/AppActivityBadge.spec.ts b/fe/src/__tests__/AppActivityBadge.spec.ts index bc81ee3de..93a3f6a0b 100644 --- a/fe/src/__tests__/AppActivityBadge.spec.ts +++ b/fe/src/__tests__/AppActivityBadge.spec.ts @@ -17,9 +17,75 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { mount, type VueWrapper } from '@vue/test-utils' -import { computed, ref } from 'vue' +import { computed, reactive, ref } from 'vue' import { createPinia, setActivePinia } from 'pinia' +const deleteOperationsMock = vi.hoisted(() => ({ + operations: [] as Array<{ + id: string + kind: 'single' | 'bulk' + title: string + audiobookId?: number + status: 'deleting' | 'completed' | 'failed' + progress: number + total: number + processed: number + deleted: number + failed: number + currentTitle?: string + startedAt: string + error?: string + dismissed?: boolean + }>, + dismiss: vi.fn(), + clearFinished: vi.fn(), +})) + +const scanSignalRMock = vi.hoisted(() => ({ + callback: null as + | ((job: { + jobId: string + audiobookId?: number | null + status: string + found?: number + created?: number + error?: string + }) => void) + | null, +})) + +const scanJobStatusMock = vi.hoisted(() => + vi.fn(async (jobId: string) => ({ + id: jobId, + audiobookId: 42, + status: 'Queued', + enqueuedAt: '2026-08-08T12:00:00Z', + canRequeue: true, + })), +) + +const moveJobsMock = vi.hoisted(() => ({ + trackedJobs: [] as Array<{ + jobId: string + audiobookId?: number + status: string + progress: number + phase?: string + target?: string + }>, + start: vi.fn(), + stop: vi.fn(), + loadActiveJobs: vi.fn(async () => undefined), +})) + +vi.mock('@/stores/moveJobs', () => ({ + useMoveJobsStore: () => moveJobsMock, +})) + +vi.mock('@/stores/libraryDeleteOperations', () => ({ + useLibraryDeleteOperationsStore: () => deleteOperationsMock, +})) + // Mock the downloads store so App.vue picks up the activeDownloads correctly vi.mock('@/stores/downloads', () => ({ useDownloadsStore: () => ({ @@ -45,6 +111,12 @@ vi.mock('@/services/signalr', () => ({ onConnected: vi.fn(() => () => undefined), onQueueUpdate: vi.fn(() => () => undefined), onFilesRemoved: vi.fn(() => () => undefined), + onScanJobUpdate: vi.fn((callback) => { + scanSignalRMock.callback = callback + return () => { + if (scanSignalRMock.callback === callback) scanSignalRMock.callback = null + } + }), onToast: vi.fn(() => () => undefined), onDownloadUpdate: vi.fn(() => () => undefined), onDownloadsList: vi.fn(() => () => undefined), @@ -60,6 +132,7 @@ vi.mock('@/services/api', () => ({ getBootstrapConfig: vi.fn(async () => ({ authenticationRequired: false })), getStartupConfig: vi.fn(async () => ({ authenticationRequired: false })), getLibrary: vi.fn(async () => []), + getScanJobStatus: scanJobStatusMock, }, })) @@ -75,6 +148,19 @@ describe('App.vue activity badge', () => { beforeEach(() => { // reset mocks between tests vi.resetModules() + moveJobsMock.trackedJobs.length = 0 + deleteOperationsMock.operations.length = 0 + scanSignalRMock.callback = null + scanJobStatusMock.mockReset() + scanJobStatusMock.mockImplementation(async (jobId: string) => ({ + id: jobId, + audiobookId: 42, + status: 'Queued', + enqueuedAt: '2026-08-08T12:00:00Z', + canRequeue: true, + })) + deleteOperationsMock.dismiss.mockReset() + deleteOperationsMock.clearFinished.mockReset() setActivePinia(createPinia()) }) @@ -103,6 +189,332 @@ describe('App.vue activity badge', () => { }) } + it('shows active move progress in the notification dropdown', async () => { + moveJobsMock.trackedJobs.push({ + jobId: 'move-1', + audiobookId: 98, + status: 'Running', + progress: 42.4, + phase: 'Verifying source', + target: 'D:\\Listenarr Test\\Book', + }) + + const { default: AppComponent } = await import('@/App.vue') + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', name: 'home', component: { template: '
' } }], + }) + await router.push('/') + await router.isReady().catch(() => {}) + + wrapper = mount(AppComponent, { + global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + await wrapper.find('.notification-wrapper .nav-btn').trigger('click') + + const dropdown = wrapper.find('.notification-dropdown') + expect(dropdown.exists()).toBe(true) + expect(dropdown.text()).toContain('Moving audiobook') + expect(dropdown.text()).toContain('Verifying source') + expect(dropdown.text()).toContain('42%') + expect(dropdown.find('.progress-fill').attributes('style')).toContain('width: 42.4%') + }) + + it('updates folder scan progress in one notification without a fake percentage', async () => { + const { default: AppComponent } = await import('@/App.vue') + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', name: 'home', component: { template: '
' } }], + }) + await router.push('/') + await router.isReady().catch(() => {}) + + wrapper = mount(AppComponent, { + global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(scanSignalRMock.callback).not.toBeNull() + + scanSignalRMock.callback?.({ + jobId: 'internal-scan', + audiobookId: 42, + status: 'Processing', + }) + await wrapper.vm.$nextTick() + await wrapper.find('.notification-wrapper .nav-btn').trigger('click') + expect(wrapper.find('.notification-dropdown').text()).not.toContain('Scanning audiobook folder') + + scanSignalRMock.callback?.({ + jobId: 'scan-1', + audiobookId: 42, + status: 'Queued', + }) + await wrapper.vm.$nextTick() + + let scanNotification = wrapper + .findAll('.notification-item') + .find((item) => item.text().includes('Scan queued: audiobook folder')) + expect(scanNotification?.text()).toContain('Waiting to scan folder') + expect(scanNotification?.find('.progress-fill').classes()).toContain('indeterminate') + expect(scanNotification?.text()).not.toMatch(/\d+%/) + expect(scanNotification?.find('.dismiss-btn').exists()).toBe(false) + + scanSignalRMock.callback?.({ + jobId: 'scan-1', + audiobookId: 42, + status: 'Processing', + }) + await wrapper.vm.$nextTick() + + scanNotification = wrapper + .findAll('.notification-item') + .find((item) => item.text().includes('Scanning audiobook folder')) + expect(scanNotification?.text()).toContain('Scanning folder') + expect(scanNotification?.find('.progress-fill').classes()).toContain('indeterminate') + expect(scanNotification?.text()).not.toMatch(/\d+%/) + expect(scanNotification?.find('.dismiss-btn').exists()).toBe(false) + + scanSignalRMock.callback?.({ + jobId: 'scan-1', + audiobookId: 42, + status: 'Completed', + found: 3, + created: 2, + }) + await wrapper.vm.$nextTick() + + scanNotification = wrapper + .findAll('.notification-item') + .find((item) => item.text().includes('Scan complete: audiobook folder')) + expect(scanNotification?.text()).toContain('3 files found · 2 added') + expect(scanNotification?.find('.progress-fill').exists()).toBe(false) + expect(scanNotification?.find('.dismiss-btn').exists()).toBe(true) + }) + + it('reconciles a missed terminal scan update from the authoritative status endpoint', async () => { + scanJobStatusMock.mockResolvedValue({ + id: 'scan-reconcile', + audiobookId: 42, + status: 'Completed', + enqueuedAt: '2026-08-08T12:00:00Z', + canRequeue: true, + }) + + const { default: AppComponent } = await import('@/App.vue') + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', name: 'home', component: { template: '
' } }], + }) + await router.push('/') + await router.isReady().catch(() => {}) + + wrapper = mount(AppComponent, { + global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + scanSignalRMock.callback?.({ + jobId: 'scan-reconcile', + audiobookId: 42, + status: 'Queued', + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(scanJobStatusMock).toHaveBeenCalledWith('scan-reconcile') + + await wrapper.find('.notification-wrapper .nav-btn').trigger('click') + const dropdown = wrapper.find('.notification-dropdown') + expect(dropdown.text()).toContain('Scan complete: audiobook folder') + expect(dropdown.text()).toContain('Folder scan completed') + expect(dropdown.text()).not.toContain('Waiting to scan folder') + expect(dropdown.text()).not.toContain('0 files found') + }) + + it('stops scan status reconciliation while logged out and resumes after re-authentication', async () => { + const authState = reactive({ + user: { authenticated: true }, + loadCurrentUser: vi.fn(async () => undefined), + logout: vi.fn(async () => undefined), + }) + vi.doMock('@/stores/auth', () => ({ + useAuthStore: () => authState, + })) + + const { default: AppComponent } = await import('@/App.vue') + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', name: 'home', component: { template: '
' } }], + }) + await router.push('/') + await router.isReady().catch(() => {}) + + wrapper = mount(AppComponent, { + global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + scanSignalRMock.callback?.({ + jobId: 'scan-auth-lifecycle', + audiobookId: 42, + status: 'Queued', + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(scanJobStatusMock).toHaveBeenCalled() + + const callsBeforeLogout = scanJobStatusMock.mock.calls.length + authState.user.authenticated = false + await wrapper.vm.$nextTick() + await new Promise((resolve) => setTimeout(resolve, 1600)) + expect(scanJobStatusMock).toHaveBeenCalledTimes(callsBeforeLogout) + + authState.user.authenticated = true + await wrapper.vm.$nextTick() + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(scanJobStatusMock.mock.calls.length).toBeGreaterThan(callsBeforeLogout) + }) + + it('fails closed when the authoritative scan job no longer exists', async () => { + scanJobStatusMock.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 })) + + const { default: AppComponent } = await import('@/App.vue') + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', name: 'home', component: { template: '
' } }], + }) + await router.push('/') + await router.isReady().catch(() => {}) + + wrapper = mount(AppComponent, { + global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + scanSignalRMock.callback?.({ + jobId: 'scan-lost', + audiobookId: 42, + status: 'Queued', + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + + await wrapper.find('.notification-wrapper .nav-btn').trigger('click') + const dropdown = wrapper.find('.notification-dropdown') + expect(dropdown.text()).toContain('Scan failed: audiobook folder') + expect(dropdown.text()).toContain('Scan status is no longer available') + expect(dropdown.text()).not.toContain('Waiting to scan folder') + }) + + it('does not regress a fast manual scan when completion arrives before queued', async () => { + const { default: AppComponent } = await import('@/App.vue') + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', name: 'home', component: { template: '
' } }], + }) + await router.push('/') + await router.isReady().catch(() => {}) + + wrapper = mount(AppComponent, { + global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + scanSignalRMock.callback?.({ jobId: 'scan-fast', audiobookId: 42, status: 'Processing' }) + scanSignalRMock.callback?.({ + jobId: 'scan-fast', + audiobookId: 42, + status: 'Completed', + found: 1, + created: 1, + }) + await wrapper.vm.$nextTick() + await wrapper.find('.notification-wrapper .nav-btn').trigger('click') + expect(wrapper.find('.notification-dropdown').text()).not.toContain('scan-fast') + expect(wrapper.find('.notification-dropdown').text()).not.toContain('Scan complete') + + scanSignalRMock.callback?.({ jobId: 'scan-fast', audiobookId: 42, status: 'Queued' }) + await wrapper.vm.$nextTick() + + const dropdown = wrapper.find('.notification-dropdown') + expect(dropdown.text()).toContain('Scan complete: audiobook folder') + expect(dropdown.text()).toContain('1 file found · 1 added') + expect(dropdown.text()).not.toContain('Scan queued') + }) + + it('shows library delete progress in notifications instead of Activity', async () => { + deleteOperationsMock.operations.push({ + id: 'delete-bulk-1', + kind: 'bulk', + title: 'Deleting 4 audiobooks', + status: 'deleting', + progress: 50, + total: 4, + processed: 2, + deleted: 2, + failed: 0, + currentTitle: 'Second Book', + startedAt: '2026-08-08T12:00:00Z', + }) + + const { default: AppComponent } = await import('@/App.vue') + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', name: 'home', component: { template: '
' } }], + }) + await router.push('/') + await router.isReady().catch(() => {}) + + wrapper = mount(AppComponent, { + global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + await wrapper.find('.notification-wrapper .nav-btn').trigger('click') + + const dropdown = wrapper.find('.notification-dropdown') + expect(dropdown.text()).toContain('Notifications') + expect(dropdown.text()).toContain('Deleting 4 audiobooks') + expect(dropdown.text()).toContain('2/4 · Second Book') + expect(dropdown.text()).toContain('50%') + }) + + it('shows a single delete as indeterminate progress without a fake percentage', async () => { + deleteOperationsMock.operations.push({ + id: 'delete-single-1', + kind: 'single', + title: 'Slow Delete', + audiobookId: 42, + status: 'deleting', + progress: 35, + total: 1, + processed: 0, + deleted: 0, + failed: 0, + startedAt: '2026-08-08T12:00:00Z', + }) + + const { default: AppComponent } = await import('@/App.vue') + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', name: 'home', component: { template: '
' } }], + }) + await router.push('/') + await router.isReady().catch(() => {}) + + wrapper = mount(AppComponent, { + global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + await wrapper.find('.notification-wrapper .nav-btn').trigger('click') + + const dropdown = wrapper.find('.notification-dropdown') + expect(dropdown.text()).toContain('Deleting Slow Delete') + expect(dropdown.text()).toContain('Removing audiobook from library') + expect(dropdown.text()).not.toContain('35%') + expect(dropdown.find('.progress-fill').classes()).toContain('indeterminate') + }) + it('counts active downloads correctly even when statuses are lowercase', async () => { // replace the downloads mock with one that returns a lowercased status const active = ref([ @@ -154,7 +566,7 @@ describe('App.vue activity badge', () => { const vm = wrapper.vm as unknown as { activityCount: number } // The badge should reflect the single active DDL download expect(vm.activityCount).toBe(1) - }, 20000) + }) it('counts DDL downloads regardless of downloadClientId casing', async () => { // downloads list contains a DDL downloadClientId in lowercase @@ -234,6 +646,7 @@ describe('App.vue activity badge', () => { return () => undefined }, onFilesRemoved: vi.fn(() => () => undefined), + onScanJobUpdate: vi.fn(() => () => undefined), onToast: vi.fn(() => () => undefined), onDownloadUpdate: vi.fn(() => () => undefined), onDownloadsList: vi.fn(() => () => undefined), @@ -267,7 +680,7 @@ describe('App.vue activity badge', () => { const vm = wrapper.vm as unknown as { activityCount: number } // With zero active downloads and two queue items, activityCount should reflect the queue expect(vm.activityCount).toBe(2) - }, 20000) + }) it('derives wantedCount from the hydrated library store without polling timers', async () => { const setIntervalSpy = vi.spyOn(window, 'setInterval') @@ -333,6 +746,7 @@ describe('App.vue activity badge', () => { }), onQueueUpdate: vi.fn(() => () => undefined), onFilesRemoved: vi.fn(() => () => undefined), + onScanJobUpdate: vi.fn(() => () => undefined), onToast: vi.fn(() => () => undefined), onDownloadUpdate: vi.fn(() => () => undefined), onDownloadsList: vi.fn(() => () => undefined), diff --git a/fe/src/__tests__/AudiobookDetailView.spec.ts b/fe/src/__tests__/AudiobookDetailView.spec.ts index 6f3830f04..edb2cc183 100644 --- a/fe/src/__tests__/AudiobookDetailView.spec.ts +++ b/fe/src/__tests__/AudiobookDetailView.spec.ts @@ -20,7 +20,9 @@ import { setActivePinia, createPinia } from 'pinia' import { describe, it, beforeEach, expect, vi } from 'vitest' import { API_BASE_PATH } from '@/services/apiBase' import { useLibraryStore } from '@/stores/library' -import { ensureImageCached } from '@/services/api' +import { useScanNotificationsStore } from '@/stores/scanNotifications' +import { useFilesystemReadinessStore } from '@/stores/filesystemReadiness' +import { apiService, ensureImageCached } from '@/services/api' import AudiobookDetailViewCmp from '@/views/library/AudiobookDetailView.vue' const routerPushMock = vi.fn() // Mock useRoute to provide params for the detail view @@ -35,6 +37,7 @@ vi.mock('@/services/api', () => ({ getImageUrl: vi.fn((url: string) => url || 'https://via.placeholder.com/300x450?text=No+Image'), getQualityProfiles: vi.fn(async () => []), getLibrary: vi.fn(async () => []), + scanAudiobook: vi.fn(), }, ensureImageCached: vi.fn(async () => true), })) @@ -183,4 +186,161 @@ describe('AudiobookDetailView image recache behavior', () => { expect(wrapper.find('.edit-audiobook-modal-stub').attributes('data-open')).toBe('true') }) + + it('updates the Files tab scan status from the shared scan state', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useLibraryStore() + const scanNotificationsStore = useScanNotificationsStore() + store.audiobooks = [{ id: 5, title: 'Detail Book', files: [] }] as unknown as ReturnType< + typeof useLibraryStore + >['audiobooks'] + store.fetchLibrary = vi.fn(async () => undefined) + + const wrapper = mount(AudiobookDetailViewCmp, { global: { plugins: [pinia] } }) + await new Promise((r) => setTimeout(r, 10)) + + const filesTab = wrapper.findAll('.tab').find((tab) => tab.text().includes('Files')) + expect(filesTab).toBeTruthy() + await filesTab!.trigger('click') + + scanNotificationsStore.applyUpdate({ + jobId: 'internal-scan-5', + audiobookId: 5, + status: 'Processing', + }) + await wrapper.vm.$nextTick() + expect(wrapper.find('.scan-job-status').exists()).toBe(false) + + scanNotificationsStore.registerManualScan('scan-job-5', 5) + await wrapper.vm.$nextTick() + + expect(wrapper.find('.scan-job-status').text()).toContain('scan-job-5') + expect(wrapper.find('.scan-job-status').text()).toContain('Queued') + + scanNotificationsStore.applyUpdate({ + jobId: 'scan-job-5', + audiobookId: 5, + status: 'Completed', + found: 2, + created: 1, + }) + await wrapper.vm.$nextTick() + + expect(wrapper.find('.scan-job-status').text()).toContain('Completed') + expect(wrapper.find('.scan-job-status').text()).not.toContain('Queued / Processing') + + scanNotificationsStore.clearFinished() + await wrapper.vm.$nextTick() + + expect(wrapper.find('.scan-job-status').exists()).toBe(false) + }) + + it('shows the newest visible manual scan for the audiobook', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useLibraryStore() + const scanNotificationsStore = useScanNotificationsStore() + store.audiobooks = [{ id: 5, title: 'Detail Book', files: [] }] as unknown as ReturnType< + typeof useLibraryStore + >['audiobooks'] + store.fetchLibrary = vi.fn(async () => undefined) + + const wrapper = mount(AudiobookDetailViewCmp, { global: { plugins: [pinia] } }) + await new Promise((r) => setTimeout(r, 10)) + + const filesTab = wrapper.findAll('.tab').find((tab) => tab.text().includes('Files')) + await filesTab!.trigger('click') + + scanNotificationsStore.registerManualScan('older-scan', 5) + scanNotificationsStore.applyUpdate({ + jobId: 'older-scan', + audiobookId: 5, + status: 'Completed', + }) + await new Promise((resolve) => setTimeout(resolve, 2)) + scanNotificationsStore.registerManualScan('newer-scan', 5) + await wrapper.vm.$nextTick() + + expect(wrapper.find('.scan-job-status').text()).toContain('newer-scan') + expect(wrapper.find('.scan-job-status').text()).toContain('Queued') + expect(wrapper.find('.scan-job-status').text()).not.toContain('older-scan') + }) + + it('registers an accepted Scan Folder job for global notification progress', async () => { + const pinia = createPinia() + setActivePinia(pinia) + useFilesystemReadinessStore().readiness = { + isReady: true, + status: 'ready', + databaseConnected: true, + migrationsCurrent: true, + errorCode: null, + filesystemReady: true, + filesystemStatus: 'Ready', + filesystemPhase: null, + filesystemErrorCode: null, + filesystemErrorMessage: null, + } + const store = useLibraryStore() + const scanNotificationsStore = useScanNotificationsStore() + store.audiobooks = [{ id: 5, title: 'Detail Book', files: [] }] as unknown as ReturnType< + typeof useLibraryStore + >['audiobooks'] + store.fetchLibrary = vi.fn(async () => undefined) + vi.mocked(apiService.scanAudiobook).mockResolvedValue({ + message: 'Scan enqueued', + found: 0, + created: 0, + jobId: 'scan-job-5', + }) + + const wrapper = mount(AudiobookDetailViewCmp, { global: { plugins: [pinia] } }) + await new Promise((r) => setTimeout(r, 10)) + + const scanButton = wrapper.find('button[aria-label="Scan Folder"]') + expect(scanButton.exists()).toBe(true) + await scanButton.trigger('click') + await new Promise((r) => setTimeout(r, 0)) + + expect(apiService.scanAudiobook).toHaveBeenCalledWith(5) + expect(scanNotificationsStore.jobs).toHaveLength(1) + expect(scanNotificationsStore.jobs[0]).toMatchObject({ + jobId: 'scan-job-5', + audiobookId: 5, + status: 'Queued', + visible: true, + }) + }) + + it('disables Scan Folder while library filesystem initialization is incomplete', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useLibraryStore() + store.audiobooks = [{ id: 5, title: 'Detail Book', files: [] }] as unknown as ReturnType< + typeof useLibraryStore + >['audiobooks'] + store.fetchLibrary = vi.fn(async () => undefined) + useFilesystemReadinessStore().readiness = { + isReady: true, + status: 'ready', + databaseConnected: true, + migrationsCurrent: true, + errorCode: null, + filesystemReady: false, + filesystemStatus: 'Running', + filesystemPhase: 'AudiobookFileIdentities', + filesystemErrorCode: null, + filesystemErrorMessage: null, + } + + const wrapper = mount(AudiobookDetailViewCmp, { global: { plugins: [pinia] } }) + await new Promise((resolve) => setTimeout(resolve, 10)) + + const scanButton = wrapper.get('button[aria-label="Scan Folder"]') + expect(scanButton.attributes('disabled')).toBeDefined() + expect(scanButton.attributes('title')).toContain('filesystem initialization') + await scanButton.trigger('click') + expect(apiService.scanAudiobook).not.toHaveBeenCalled() + }) }) diff --git a/fe/src/__tests__/BulkEditModal.results.spec.ts b/fe/src/__tests__/BulkEditModal.results.spec.ts new file mode 100644 index 000000000..9d584463d --- /dev/null +++ b/fe/src/__tests__/BulkEditModal.results.spec.ts @@ -0,0 +1,189 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import BulkEditModal from '@/components/domain/collection/BulkEditModal.vue' +import { executeBulkEdit } from '@/utils/bulkEditOrchestration' + +const success = vi.fn() +const error = vi.fn() +const info = vi.fn() + +vi.mock('@/services/toastService', () => ({ + useToast: () => ({ success, error, info }), +})) + +vi.mock('@/utils/bulkEditOrchestration', () => ({ + executeBulkEdit: vi.fn(), +})) + +const executeBulkEditMock = vi.mocked(executeBulkEdit) + +describe('BulkEditModal results', () => { + beforeEach(() => { + vi.clearAllMocks() + const pinia = createPinia() + setActivePinia(pinia) + }) + + it('keeps the modal open and does not emit saved when any item fails', async () => { + executeBulkEditMock.mockResolvedValue({ + results: [ + { id: 1, success: true, pathChangeOutcome: 'none', errors: [] }, + { + id: 2, + success: false, + metadataUpdated: false, + pathChangeOutcome: 'failed', + errors: ['queue unavailable'], + }, + ], + }) + const pinia = createPinia() + setActivePinia(pinia) + const wrapper = mount(BulkEditModal, { + props: { + isOpen: true, + selectedCount: 2, + selectedIds: new Set([1, 2]), + }, + global: { + plugins: [pinia], + stubs: { + Modal: { template: '
' }, + ModalBody: { template: '
' }, + ModalHeader: true, + MoveAudiobookModal: true, + RootFolderSelect: true, + Checkbox: true, + }, + }, + }) + const vm = wrapper.vm as unknown as { + formData: { monitored: boolean | null } + handleSave: () => Promise + showResults: boolean + results: Array<{ id: number; success: boolean; errors: string[] }> + } + vm.formData.monitored = true + + await vm.handleSave() + + expect(vm.showResults).toBe(true) + expect(vm.results).toEqual([ + { id: 1, success: true, pathChangeOutcome: 'none', errors: [] }, + { + id: 2, + success: false, + metadataUpdated: false, + pathChangeOutcome: 'failed', + errors: ['queue unavailable'], + }, + ]) + expect(error).toHaveBeenCalledWith( + 'Bulk update incomplete', + expect.stringContaining('1 succeeded, 0 partially succeeded, and 1 failed'), + ) + expect(wrapper.emitted('saved')).toBeUndefined() + expect(wrapper.emitted('close')).toBeUndefined() + }) + + it('renders a partial result distinctly and keeps the modal open', async () => { + executeBulkEditMock.mockResolvedValue({ + results: [ + { + id: 1, + success: false, + metadataUpdated: true, + pathChangeOutcome: 'not-enqueued', + errors: ['queue unavailable'], + }, + ], + }) + const pinia = createPinia() + setActivePinia(pinia) + const wrapper = mount(BulkEditModal, { + props: { + isOpen: true, + selectedCount: 1, + selectedIds: new Set([1]), + }, + global: { + plugins: [pinia], + stubs: { + Modal: { template: '
' }, + ModalBody: { template: '
' }, + ModalHeader: true, + MoveAudiobookModal: true, + RootFolderSelect: true, + Checkbox: true, + }, + }, + }) + const vm = wrapper.vm as unknown as { + formData: { monitored: boolean | null } + handleSave: () => Promise + } + vm.formData.monitored = true + + await vm.handleSave() + + expect(wrapper.text()).toContain('0 succeeded, 1 partially succeeded, 0 failed') + expect(wrapper.text()).toContain('Partial') + expect(wrapper.text()).toContain('Metadata saved; the requested move was not queued.') + expect(error).toHaveBeenCalledWith( + 'Bulk update incomplete', + expect.stringContaining('0 succeeded, 1 partially succeeded, and 0 failed'), + ) + expect(wrapper.emitted('saved')).toBeUndefined() + expect(wrapper.emitted('close')).toBeUndefined() + }) + + it('emits saved and closes only when every item succeeds', async () => { + executeBulkEditMock.mockResolvedValue({ + results: [ + { id: 1, success: true, pathChangeOutcome: 'none', errors: [] }, + { id: 2, success: true, pathChangeOutcome: 'none', errors: [] }, + ], + }) + const pinia = createPinia() + setActivePinia(pinia) + const wrapper = mount(BulkEditModal, { + props: { + isOpen: true, + selectedCount: 2, + selectedIds: new Set([1, 2]), + }, + global: { + plugins: [pinia], + stubs: { + Modal: { template: '
' }, + ModalBody: { template: '
' }, + ModalHeader: true, + MoveAudiobookModal: true, + RootFolderSelect: true, + Checkbox: true, + }, + }, + }) + const vm = wrapper.vm as unknown as { + formData: { monitored: boolean | null } + handleSave: () => Promise + } + vm.formData.monitored = true + + await vm.handleSave() + + expect(success).toHaveBeenCalledWith('Bulk update', 'Updated 2 audiobook(s)') + expect(wrapper.emitted('saved')).toHaveLength(1) + expect(wrapper.emitted('close')).toHaveLength(1) + }) +}) diff --git a/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts b/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts index 30f882c7b..3bbf77ca6 100644 --- a/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts +++ b/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts @@ -18,6 +18,33 @@ import { mount } from '@vue/test-utils' import { vi, describe, it, expect, beforeEach } from 'vitest' +type MoveJobUpdate = { jobId?: string; status?: string; target?: string; error?: string } + +const toastMocks = vi.hoisted(() => ({ + info: vi.fn(), + success: vi.fn(), + error: vi.fn(), +})) + +const filesystemReadinessMock = vi.hoisted(() => ({ + filesystemReady: true, + filesystemInitializing: false, + filesystemFailed: false, +})) + +const signalRMocks = vi.hoisted(() => { + const state = { + callback: null as ((job: MoveJobUpdate) => void) | null, + unsubscribe: vi.fn(), + onMoveJobUpdate: vi.fn(), + } + state.onMoveJobUpdate.mockImplementation((callback: (job: MoveJobUpdate) => void) => { + state.callback = callback + return state.unsubscribe + }) + return state +}) + vi.mock('@/services/api', () => ({ apiService: { getAudiobook: vi.fn().mockImplementation(async (id: number) => ({ id })), @@ -27,17 +54,45 @@ vi.mock('@/services/api', () => ({ checkVolume: vi.fn().mockResolvedValue({ sameVolume: true }), updateAudiobook: vi.fn().mockResolvedValue({ message: 'ok', audiobook: {} }), updateAudiobookIdentifiers: vi.fn().mockResolvedValue({ identifiers: [] }), - moveAudiobook: vi.fn().mockResolvedValue({ message: 'queued', jobId: 'job-1' }), + getMoveRecoveryState: vi.fn().mockResolvedValue({ + hasUnresolvedMove: false, + disposition: 'None', + jobId: null, + status: null, + phase: null, + requestedPath: null, + error: null, + canRetry: false, + blockingJobIds: [], + }), + requeueMoveJob: vi.fn().mockImplementation(async (jobId: string) => ({ + message: 'requeued', + jobId, + })), + getMoveJobStatus: vi.fn().mockImplementation(async (jobId: string) => ({ + jobId, + audiobookId: 1, + status: 'Queued', + })), + moveAudiobook: vi.fn().mockImplementation(async (_id: number, destination: string) => ({ + message: 'queued', + jobId: 'job-1', + target: destination, + })), }, })) vi.mock('@/services/toastService', () => ({ - useToast: () => ({ info: vi.fn(), success: vi.fn(), error: vi.fn() }), + useToast: () => toastMocks, +})) + +vi.mock('@/stores/filesystemReadiness', () => ({ + useFilesystemReadinessStore: () => filesystemReadinessMock, })) vi.mock('@/services/signalr', () => ({ signalRService: { - onMoveJobUpdate: vi.fn(() => () => {}), + onMoveJobUpdate: signalRMocks.onMoveJobUpdate, }, })) @@ -48,16 +103,198 @@ const audiobook = { title: 'Sample', authors: ['Author'], basePath: 'C:\\root\\Some Author\\Some Title', + imageUrl: 'C:\\root\\Some Author\\Some Title\\cover.jpg', monitored: true, tags: [], } describe('EditAudiobookModal move options', () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks() + filesystemReadinessMock.filesystemReady = true + filesystemReadinessMock.filesystemInitializing = false + filesystemReadinessMock.filesystemFailed = false + signalRMocks.callback = null + signalRMocks.onMoveJobUpdate.mockImplementation((callback: (job: MoveJobUpdate) => void) => { + signalRMocks.callback = callback + return signalRMocks.unsubscribe + }) + const { apiService } = await import('@/services/api') + vi.mocked(apiService.getMoveRecoveryState).mockResolvedValue({ + hasUnresolvedMove: false, + disposition: 'None', + jobId: null, + status: null, + phase: null, + requestedPath: null, + error: null, + canRetry: false, + blockingJobIds: [], + }) + vi.mocked(apiService.requeueMoveJob).mockImplementation(async (jobId: string) => ({ + message: 'requeued', + jobId, + })) + vi.mocked(apiService.getMoveJobStatus).mockImplementation(async (jobId: string) => ({ + jobId, + audiobookId: 1, + status: 'Queued', + })) + vi.mocked(apiService.moveAudiobook).mockImplementation( + async (_id: number, destination: string) => ({ + message: 'queued', + jobId: 'job-1', + target: destination, + }), + ) + }) + + it('disables destination edits and move resume while filesystem initialization is running', async () => { + const { apiService } = await import('@/services/api') + filesystemReadinessMock.filesystemReady = false + filesystemReadinessMock.filesystemInitializing = true + vi.mocked(apiService.getMoveRecoveryState).mockResolvedValue({ + hasUnresolvedMove: true, + disposition: 'RetryAvailable', + jobId: 'recover-job-initializing', + status: 'Failed', + phase: 'Published', + requestedPath: 'C:\\root\\Recovered Author\\Recovered Book', + error: 'Interrupted move detected.', + canRetry: true, + blockingJobIds: ['recover-job-initializing'], + }) + + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + await new Promise((resolve) => setTimeout(resolve, 200)) + + expect(wrapper.get('.btn-edit-destination').attributes('disabled')).toBeDefined() + const resume = wrapper.get('[data-testid="resume-move-button"]') + expect(resume.attributes('disabled')).toBeDefined() + await resume.trigger('click') + expect(apiService.requeueMoveJob).not.toHaveBeenCalled() + }) + + it('rehydrates an interrupted move after a fresh open and resumes the original job', async () => { + const { apiService } = await import('@/services/api') + const recoverable = { + hasUnresolvedMove: true, + disposition: 'RetryAvailable', + jobId: 'recover-job-1', + status: 'Failed', + phase: 'Published', + requestedPath: 'C:\\root\\Recovered Author\\Recovered Book', + error: 'The previous filesystem cleanup was interrupted.', + canRetry: true, + blockingJobIds: ['recover-job-1'], + } + vi.mocked(apiService.getMoveRecoveryState) + .mockResolvedValueOnce(recoverable) + .mockResolvedValueOnce({ + ...recoverable, + disposition: 'InProgress', + status: 'Queued', + canRetry: false, + }) + + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((resolve) => setTimeout(resolve, 200)) + + const notice = wrapper.get('[data-testid="move-recovery-notice"]') + expect(notice.text()).toContain('An interrupted move needs to be resumed.') + expect(notice.text()).toContain('C:\\root\\Recovered Author\\Recovered Book') + expect(wrapper.get('.btn-edit-destination').attributes('disabled')).toBeDefined() + + await wrapper.get('[data-testid="resume-move-button"]').trigger('click') + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(apiService.requeueMoveJob).toHaveBeenCalledWith('recover-job-1') + expect(toastMocks.info).toHaveBeenCalledWith( + 'Move resumed', + 'Move job recover-job-1 was queued to resume its interrupted work.', + ) + }) + + it('turns a race-time recovery conflict into resumable server state instead of retrying a fresh move', async () => { + const { apiService } = await import('@/services/api') + const noRecovery = { + hasUnresolvedMove: false, + disposition: 'None', + jobId: null, + status: null, + phase: null, + requestedPath: null, + error: null, + canRetry: false, + blockingJobIds: [] as string[], + } + const recoverable = { + hasUnresolvedMove: true, + disposition: 'RetryAvailable', + jobId: 'recover-job-race', + status: 'Failed', + phase: 'Published', + requestedPath: 'C:\\root\\New Author\\New Book', + error: 'Interrupted move detected.', + canRetry: true, + blockingJobIds: ['recover-job-race'], + } + vi.mocked(apiService.getMoveRecoveryState).mockImplementation(async () => + vi.mocked(apiService.moveAudiobook).mock.calls.length > 0 ? recoverable : noRecovery, + ) + vi.mocked(apiService.moveAudiobook).mockRejectedValueOnce( + Object.assign(new Error('API error'), { + status: 409, + body: JSON.stringify({ + code: 'move_recovery_required', + message: 'An interrupted move still owns this audiobook filesystem state.', + jobId: 'recover-job-race', + status: 'Failed', + requestedPath: recoverable.requestedPath, + recoveryDisposition: 'RetryAvailable', + canRetry: true, + }), + }), + ) + + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + await new Promise((resolve) => setTimeout(resolve, 200)) + ;(wrapper.vm as unknown).formData.relativePath = 'New Author\\New Book' + await wrapper.vm.$nextTick() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((resolve) => setTimeout(resolve, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: true, deleteEmptySource: true }) + await savePromise + await wrapper.vm.$nextTick() + + expect(apiService.moveAudiobook).toHaveBeenCalledTimes(1) + expect(apiService.requeueMoveJob).not.toHaveBeenCalled() + expect(wrapper.get('[data-testid="move-recovery-notice"]').text()).toContain( + 'An interrupted move needs to be resumed.', + ) + expect(wrapper.get('[data-testid="resume-move-button"]').exists()).toBe(true) + expect(toastMocks.error).toHaveBeenCalledWith( + 'Resume interrupted move', + 'An interrupted move still owns this audiobook filesystem state.', + ) }) - it('Change without moving should update audiobook and not call move API', async () => { + it('Change without moving should persist metadata and identifiers before the destination update', async () => { const wrapper = mount(EditAudiobookModal, { props: { isOpen: true, audiobook }, attachTo: document.body, @@ -67,10 +304,19 @@ describe('EditAudiobookModal move options', () => { // let init settle await new Promise((r) => setTimeout(r, 200)) - // Ensure there is a detectable change: set an explicit custom root and flip monitored - ;(wrapper.vm as unknown).selectedRootId = 0 - ;(wrapper.vm as unknown).customRootPath = 'C:\\root\\New Author\\New Book' - ;(wrapper.vm as unknown).formData.monitored = false + // Ensure there is a detectable destination change under the configured output root. + ;(wrapper.vm as unknown).formData.relativePath = 'New Author\\New Book' + ;(wrapper.vm as unknown).formData.title = 'Sample Updated' + ;(wrapper.vm as unknown).formData.identifiers = [ + { + localKey: 'new-asin', + type: 'Asin', + value: 'B0TEST1234', + region: 'us', + isPrimary: true, + source: 'Manual', + }, + ] await wrapper.vm.$nextTick() // Start save flow and resolve the in-component confirmation promise by @@ -85,8 +331,163 @@ describe('EditAudiobookModal move options', () => { await new Promise((r) => setTimeout(r, 50)) const { apiService } = await import('@/services/api') + expect(apiService.moveAudiobook).toHaveBeenCalledTimes(1) + expect(apiService.moveAudiobook).toHaveBeenCalledWith(1, 'C:\\root\\New Author\\New Book', { + sourcePath: 'C:\\root\\Some Author\\Some Title', + moveFiles: false, + deleteEmptySource: false, + }) expect(apiService.updateAudiobook).toHaveBeenCalledTimes(1) - expect(apiService.moveAudiobook).toHaveBeenCalledTimes(0) + const updatePayload = vi.mocked(apiService.updateAudiobook).mock.calls[0][1] as Record< + string, + unknown + > + expect(updatePayload.title).toBe('Sample Updated') + expect(Object.prototype.hasOwnProperty.call(updatePayload, 'basePath')).toBe(false) + expect(Object.prototype.hasOwnProperty.call(updatePayload, 'imageUrl')).toBe(false) + expect(apiService.updateAudiobookIdentifiers).toHaveBeenCalledTimes(1) + expect(vi.mocked(apiService.updateAudiobook).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(apiService.updateAudiobookIdentifiers).mock.invocationCallOrder[0], + ) + expect( + vi.mocked(apiService.updateAudiobookIdentifiers).mock.invocationCallOrder[0], + ).toBeLessThan(vi.mocked(apiService.moveAudiobook).mock.invocationCallOrder[0]) + }) + + it('reports partial success when metadata saves but the destination update fails', async () => { + const { apiService } = await import('@/services/api') + vi.mocked(apiService.moveAudiobook).mockRejectedValueOnce(new Error('queue unavailable')) + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + ;(wrapper.vm as unknown).formData.relativePath = 'New Author\\New Book' + ;(wrapper.vm as unknown).formData.title = 'Saved Before Move Failure' + await wrapper.vm.$nextTick() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((r) => setTimeout(r, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: true, deleteEmptySource: true }) + await savePromise + + expect(apiService.updateAudiobook).toHaveBeenCalledWith( + 1, + expect.objectContaining({ title: 'Saved Before Move Failure' }), + ) + expect(apiService.moveAudiobook).toHaveBeenCalledTimes(1) + expect(toastMocks.error).toHaveBeenCalledWith( + 'Move failed', + 'Your metadata changes were saved, but the destination update could not be confirmed.', + ) + expect(wrapper.emitted('saved')).toBeUndefined() + }) + + it('shows a structured destination rejection inline with the effective path', async () => { + const { apiService } = await import('@/services/api') + const rejectedPath = 'C:\\root\\New Author\\New Book' + vi.mocked(apiService.moveAudiobook).mockRejectedValueOnce( + Object.assign(new Error('API error'), { + status: 400, + body: JSON.stringify({ + code: 'destination_path_outside_roots', + field: 'destinationPath', + message: 'DestinationPath must be inside a configured root folder or output path', + resolvedDestination: rejectedPath, + }), + }), + ) + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((resolve) => setTimeout(resolve, 200)) + ;(wrapper.vm as unknown).formData.relativePath = 'New Author\\New Book' + await wrapper.vm.$nextTick() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((resolve) => setTimeout(resolve, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: true, deleteEmptySource: true }) + await savePromise + await wrapper.vm.$nextTick() + + expect(wrapper.get('[data-testid="effective-destination"]').text()).toContain(rejectedPath) + expect(wrapper.text()).toContain( + 'DestinationPath must be inside a configured root folder or output path', + ) + expect(toastMocks.error).toHaveBeenCalledWith( + 'Invalid destination', + 'DestinationPath must be inside a configured root folder or output path', + ) + expect(wrapper.emitted('saved')).toBeUndefined() + }) + + it('shows the server source-manifest failure instead of directing the user to a nonexistent queue job', async () => { + const { apiService } = await import('@/services/api') + const manifestMessage = + 'The audiobook has no validated tracked files. Rescan or repair it before moving files.' + vi.mocked(apiService.moveAudiobook).mockRejectedValueOnce( + Object.assign(new Error('API error'), { + status: 400, + body: JSON.stringify({ + code: 'move_source_unverified', + field: 'sourcePath', + message: manifestMessage, + }), + }), + ) + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((resolve) => setTimeout(resolve, 200)) + ;(wrapper.vm as unknown).formData.relativePath = 'New Author\\New Book' + await wrapper.vm.$nextTick() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((resolve) => setTimeout(resolve, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: true, deleteEmptySource: true }) + await savePromise + + expect(toastMocks.error).toHaveBeenCalledWith('Move failed', manifestMessage) + expect(wrapper.emitted('saved')).toBeUndefined() + }) + + it('Destination-only change without moving should call move API and skip metadata update', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + ;(wrapper.vm as unknown).formData.relativePath = 'New Author\\New Book' + await wrapper.vm.$nextTick() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((r) => setTimeout(r, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: false, deleteEmptySource: true }) + await savePromise + await new Promise((r) => setTimeout(r, 50)) + + const { apiService } = await import('@/services/api') + expect(apiService.updateAudiobook).toHaveBeenCalledTimes(0) + expect(apiService.moveAudiobook).toHaveBeenCalledTimes(1) + expect(apiService.moveAudiobook).toHaveBeenCalledWith(1, 'C:\\root\\New Author\\New Book', { + sourcePath: 'C:\\root\\Some Author\\Some Title', + moveFiles: false, + deleteEmptySource: false, + }) }) it('Move should call move API with deleteEmptySource true by default', async () => { @@ -98,9 +499,8 @@ describe('EditAudiobookModal move options', () => { await new Promise((r) => setTimeout(r, 200)) - // Ensure there is a detectable change: set an explicit custom root and flip monitored - ;(wrapper.vm as unknown).selectedRootId = 0 - ;(wrapper.vm as unknown).customRootPath = 'C:\\root\\New Author\\New Book' + // Ensure there is a detectable destination change and flip monitored. + ;(wrapper.vm as unknown).formData.relativePath = 'New Author\\New Book' ;(wrapper.vm as unknown).formData.monitored = false await wrapper.vm.$nextTick() @@ -117,14 +517,350 @@ describe('EditAudiobookModal move options', () => { const { apiService } = await import('@/services/api') expect(apiService.updateAudiobook).toHaveBeenCalledTimes(1) + expect(apiService.updateAudiobook).toHaveBeenCalledWith( + 1, + expect.not.objectContaining({ basePath: expect.anything() }), + ) expect(apiService.moveAudiobook).toHaveBeenCalledTimes(1) + expect(apiService.moveAudiobook).toHaveBeenCalledWith(1, 'C:\\root\\New Author\\New Book', { + sourcePath: 'C:\\root\\Some Author\\Some Title', + moveFiles: true, + deleteEmptySource: true, + }) + }) + + it('rooted destination input cannot bypass the relative-only action boundary', async () => { + const { apiService } = await import('@/services/api') + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((resolve) => setTimeout(resolve, 200)) + ;(wrapper.vm as unknown).formData.relativePath = 'C:\\root\\New Author\\New Book' + ;(wrapper.vm as unknown).formData.title = 'Should Not Save' + await wrapper.vm.$nextTick() + + await (wrapper.vm as unknown).handleSave() + + expect(apiService.updateAudiobook).not.toHaveBeenCalled() + expect(apiService.moveAudiobook).not.toHaveBeenCalled() + expect(toastMocks.error).toHaveBeenCalledWith( + 'Invalid destination', + 'Enter a path relative to the selected configured root folder.', + ) + }) + + it('Destination with parent traversal should be invalid and not call save APIs', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + ;(wrapper.vm as unknown).formData.relativePath = 'Some Author\\Some Title\\..' + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Path traversal is not allowed in the destination folder') + expect( + wrapper.find('button[aria-label="Save destination"]').attributes('disabled'), + ).toBeDefined() + + await (wrapper.vm as unknown).handleSave() + await new Promise((r) => setTimeout(r, 50)) + + const { apiService } = await import('@/services/api') + expect(apiService.updateAudiobook).toHaveBeenCalledTimes(0) + expect(apiService.moveAudiobook).toHaveBeenCalledTimes(0) + }) + + it('Destination segment with trailing whitespace should be invalid and not call save APIs', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + ;(wrapper.vm as unknown).formData.relativePath = 'Some Author\\Some Title\\test ' + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain( + 'Windows destination folder segments cannot end with a space or period', + ) + expect( + wrapper.find('button[aria-label="Save destination"]').attributes('disabled'), + ).toBeDefined() + + await (wrapper.vm as unknown).handleSave() + await new Promise((r) => setTimeout(r, 50)) + + const { apiService } = await import('@/services/api') + expect(apiService.updateAudiobook).toHaveBeenCalledTimes(0) + expect(apiService.moveAudiobook).toHaveBeenCalledTimes(0) + }) + + it('Destination inside current source should be allowed as a content move', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + ;(wrapper.vm as unknown).formData.relativePath = 'Some Author\\Some Title\\ test' + await wrapper.vm.$nextTick() + + expect(wrapper.text()).not.toContain('Source and destination folders cannot overlap') + expect( + wrapper.find('button[aria-label="Save destination"]').attributes('disabled'), + ).toBeUndefined() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((r) => setTimeout(r, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: true, deleteEmptySource: true }) + await savePromise + await new Promise((r) => setTimeout(r, 50)) + + const { apiService } = await import('@/services/api') + expect(apiService.updateAudiobook).toHaveBeenCalledTimes(0) + expect(apiService.moveAudiobook).toHaveBeenCalledWith( + 1, + 'C:\\root\\Some Author\\Some Title\\ test', + { + sourcePath: 'C:\\root\\Some Author\\Some Title', + moveFiles: true, + deleteEmptySource: true, + }, + ) + }) + + it('Windows destination segment with leading whitespace outside source should be allowed', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + ;(wrapper.vm as unknown).formData.relativePath = 'Some Author\\Other Title\\ test' + await wrapper.vm.$nextTick() + + expect(wrapper.text()).not.toContain('Windows destination folder segments cannot end') + expect( + wrapper.find('button[aria-label="Save destination"]').attributes('disabled'), + ).toBeUndefined() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((r) => setTimeout(r, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: true, deleteEmptySource: true }) + await savePromise + await new Promise((r) => setTimeout(r, 50)) + + const { apiService } = await import('@/services/api') expect(apiService.moveAudiobook).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ moveFiles: true, deleteEmptySource: true }), + 1, + 'C:\\root\\Some Author\\Other Title\\ test', + { + sourcePath: 'C:\\root\\Some Author\\Some Title', + moveFiles: true, + deleteEmptySource: true, + }, + ) + }) + + it('blocks a duplicate destination change while this client is already tracking an active move', async () => { + const pinia = (await import('pinia')).createPinia() + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [pinia] }, + }) + + await new Promise((resolve) => setTimeout(resolve, 200)) + const { useMoveJobsStore } = await import('@/stores/moveJobs') + const moveJobsStore = useMoveJobsStore(pinia) + moveJobsStore.trackQueuedJob({ + jobId: 'job-active', + audiobookId: audiobook.id, + status: 'Running', + target: 'C:\\root\\First Destination', + }) + ;(wrapper.vm as unknown).formData.relativePath = 'Second Destination' + await wrapper.vm.$nextTick() + await (wrapper.vm as unknown).handleSave() + + const { apiService } = await import('@/services/api') + expect(apiService.moveAudiobook).not.toHaveBeenCalled() + expect(toastMocks.info).toHaveBeenCalledWith( + 'Move already in progress', + 'Move job job-active is still running. Wait for it to finish before changing the destination again.', ) }) + it('Move-only destination changes should enqueue move without pre-saving BasePath', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + ;(wrapper.vm as unknown).formData.relativePath = 'New Author\\New Book' + await wrapper.vm.$nextTick() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((r) => setTimeout(r, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: true, deleteEmptySource: true }) + await savePromise + await new Promise((r) => setTimeout(r, 50)) + + const { apiService } = await import('@/services/api') + const { useMoveJobsStore } = await import('@/stores/moveJobs') + const moveJobsStore = useMoveJobsStore() + expect(apiService.updateAudiobook).toHaveBeenCalledTimes(0) + expect(apiService.moveAudiobook).toHaveBeenCalledWith(1, 'C:\\root\\New Author\\New Book', { + sourcePath: 'C:\\root\\Some Author\\Some Title', + moveFiles: true, + deleteEmptySource: true, + }) + expect(moveJobsStore.trackedById['job-1']).toEqual({ + jobId: 'job-1', + audiobookId: 1, + status: 'Queued', + progress: 0, + phase: undefined, + target: 'C:\\root\\New Author\\New Book', + error: undefined, + recoveryDisposition: undefined, + canRetry: undefined, + }) + expect(signalRMocks.onMoveJobUpdate).toHaveBeenCalledTimes(1) + expect(wrapper.emitted('saved')).toHaveLength(1) + expect(wrapper.emitted('close')).toHaveLength(1) + }) + + it('tracks the server-authoritative resolved move destination', async () => { + const { apiService } = await import('@/services/api') + vi.mocked(apiService.moveAudiobook).mockResolvedValueOnce({ + message: 'queued', + jobId: 'job-canonical', + target: 'C:/root/Canonical Author/Canonical Book', + }) + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((resolve) => setTimeout(resolve, 200)) + ;(wrapper.vm as unknown).formData.relativePath = 'New Author\\New Book' + await wrapper.vm.$nextTick() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((resolve) => setTimeout(resolve, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: true, deleteEmptySource: true }) + await savePromise + + const { useMoveJobsStore } = await import('@/stores/moveJobs') + const moveJobsStore = useMoveJobsStore() + expect(moveJobsStore.trackedById['job-canonical']?.target).toBe( + 'C:/root/Canonical Author/Canonical Book', + ) + }) + + it('preserves legal whitespace in the server-authoritative move destination', async () => { + const { apiService } = await import('@/services/api') + const serverTarget = '/library/Author/Book ' + vi.mocked(apiService.moveAudiobook).mockResolvedValueOnce({ + message: 'queued', + jobId: 'job-whitespace', + target: serverTarget, + }) + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((resolve) => setTimeout(resolve, 200)) + ;(wrapper.vm as unknown).formData.relativePath = 'New Author\\New Book' + await wrapper.vm.$nextTick() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((resolve) => setTimeout(resolve, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: true, deleteEmptySource: true }) + await savePromise + + const { useMoveJobsStore } = await import('@/stores/moveJobs') + const moveJobsStore = useMoveJobsStore() + expect(moveJobsStore.trackedById['job-whitespace']?.target).toBe(serverTarget) + }) + + it('rejects an untrackable physical move response', async () => { + const { apiService } = await import('@/services/api') + vi.mocked(apiService.moveAudiobook).mockResolvedValueOnce({ message: 'queued' }) + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((resolve) => setTimeout(resolve, 200)) + ;(wrapper.vm as unknown).formData.relativePath = 'New Author\\New Book' + await wrapper.vm.$nextTick() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((resolve) => setTimeout(resolve, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: true, deleteEmptySource: true }) + await savePromise + + const { useMoveJobsStore } = await import('@/stores/moveJobs') + const moveJobsStore = useMoveJobsStore() + expect(Object.keys(moveJobsStore.trackedById)).not.toContain('undefined') + expect(wrapper.emitted('saved')).toBeUndefined() + expect(toastMocks.error).toHaveBeenCalledWith( + 'Move failed', + 'The destination update could not be confirmed. No move job was created.', + ) + }) + + it('legacy out-of-root audiobooks allow metadata-only saves until relocation is explicitly chosen', async () => { + const { apiService } = await import('@/services/api') + const wrapper = mount(EditAudiobookModal, { + props: { + isOpen: true, + audiobook: { + ...audiobook, + basePath: 'D:\\legacy\\Some Author\\Some Title', + }, + }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((resolve) => setTimeout(resolve, 200)) + ;(wrapper.vm as unknown).formData.title = 'Legacy Metadata Updated' + await wrapper.vm.$nextTick() + + await (wrapper.vm as unknown).handleSave() + + expect(apiService.updateAudiobook).toHaveBeenCalledWith( + 1, + expect.objectContaining({ title: 'Legacy Metadata Updated' }), + ) + expect(apiService.moveAudiobook).not.toHaveBeenCalled() + expect(wrapper.emitted('saved')).toHaveLength(1) + }) + it('Edition-only changes should persist through updateAudiobook', async () => { const wrapper = mount(EditAudiobookModal, { props: { isOpen: true, audiobook }, @@ -147,6 +883,41 @@ describe('EditAudiobookModal move options', () => { ) }) + it('metadata edit with separator-only configured-root path does not enqueue a move', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + + const vm = wrapper.vm as unknown as { + formData: { title: string; relativePath: string } + handleSave: () => Promise + } + vm.formData.relativePath = 'Some Author/Some Title' + vm.formData.title = 'Updated Sample' + await wrapper.vm.$nextTick() + + expect(wrapper.text()).not.toContain('Destination folder must be different') + + await vm.handleSave() + await new Promise((r) => setTimeout(r, 50)) + + const { apiService } = await import('@/services/api') + expect(apiService.updateAudiobook).toHaveBeenCalledTimes(1) + expect(apiService.updateAudiobook).toHaveBeenCalledWith( + 1, + expect.objectContaining({ title: 'Updated Sample' }), + ) + expect(apiService.updateAudiobook).toHaveBeenCalledWith( + 1, + expect.not.objectContaining({ basePath: expect.anything() }), + ) + expect(apiService.moveAudiobook).toHaveBeenCalledTimes(0) + }) + it('metadata changes should persist through updateAudiobook', async () => { const wrapper = mount(EditAudiobookModal, { props: { diff --git a/fe/src/__tests__/EditAudiobookModal.relativePath.spec.ts b/fe/src/__tests__/EditAudiobookModal.relativePath.spec.ts index aa36c94e2..54738ec5b 100644 --- a/fe/src/__tests__/EditAudiobookModal.relativePath.spec.ts +++ b/fe/src/__tests__/EditAudiobookModal.relativePath.spec.ts @@ -6,18 +6,20 @@ * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . */ -import { mount } from '@vue/test-utils' -import { vi, describe, it, expect } from 'vitest' -import { nextTick } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount, type VueWrapper } from '@vue/test-utils' +import { createPinia } from 'pinia' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), +})) + +vi.mock('@/services/toastService', () => ({ + useToast: () => toastMocks, +})) vi.mock('@/services/api', () => ({ apiService: { @@ -25,14 +27,21 @@ vi.mock('@/services/api', () => ({ getQualityProfiles: vi.fn().mockResolvedValue([]), getApplicationSettings: vi.fn().mockResolvedValue({ outputPath: 'C:\\root' }), getAudiobookIdentifiers: vi.fn().mockResolvedValue({ identifiers: [] }), - getRootFolders: vi - .fn() - .mockResolvedValue([{ id: 1, name: 'Default', path: 'C:\\root', isDefault: true }]), + getRootFolders: vi.fn(), }, })) +import { apiService } from '@/services/api' import EditAudiobookModal from '@/components/domain/audiobook/EditAudiobookModal.vue' +const defaultRoot = { + id: 1, + name: 'Default', + path: 'C:\\root', + isDefault: true, + resolvedCaseSensitivity: 'Insensitive' as const, +} + const audiobook = { id: 1, title: 'Sample', @@ -42,197 +51,200 @@ const audiobook = { tags: [], } -describe('EditAudiobookModal relative path calculation', () => { - it('shows full path in readonly input by default', async () => { - const wrapper = mount(EditAudiobookModal, { - props: { - isOpen: true, - audiobook, - }, - attachTo: document.body, - global: { - plugins: [(await import('pinia')).createPinia()], - }, - }) +type EditDestinationVm = { + selectedRootId: number | null + unmanagedExistingDestination: boolean + editingDestination: boolean + formData: { relativePath: string | null } + combinedBasePath: () => string | null + startEditingDestination: () => void + finishEditingDestination: () => void +} - // allow async init - await new Promise((r) => setTimeout(r, 10)) +async function mountModal( + candidate = audiobook, +): Promise<{ wrapper: VueWrapper; vm: EditDestinationVm }> { + const wrapper = mount(EditAudiobookModal, { + props: { + isOpen: true, + audiobook: candidate, + }, + attachTo: document.body, + global: { + plugins: [createPinia()], + }, + }) - // Primary assertion: combined path should match expected (normalize slashes) - expect(((wrapper.vm as unknown).combinedBasePath() || '').replace(/\\/g, '/')).toBe( - 'C:/root/Some Author/Some Title', - ) + await new Promise((resolve) => setTimeout(resolve, 25)) + return { + wrapper, + vm: wrapper.vm as unknown as EditDestinationVm, + } +} - // If the readonly input exists in this environment, also assert its value - const readonlyInput = wrapper.find('.readonly-input') - const readonlyValue = ( - readonlyInput.exists() - ? (readonlyInput.element as HTMLInputElement).value || '' - : 'C:\\root\\Some Author\\Some Title' - ).replace(/\\/g, '/') - expect(readonlyValue).toBe('C:/root/Some Author/Some Title') +describe('EditAudiobookModal configured-root destination editing', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(apiService.getRootFolders).mockResolvedValue([defaultRoot]) }) - it('derives relative path from stored basePath when root configured', async () => { - const wrapper = mount(EditAudiobookModal, { - props: { - isOpen: true, - audiobook, - }, - attachTo: document.body, - global: { - plugins: [(await import('pinia')).createPinia()], - }, - }) + it('shows the full stored path while deriving a configured-root relative path', async () => { + const { wrapper, vm } = await mountModal() - // allow async init - await new Promise((r) => setTimeout(r, 10)) - - // Expect the internal relativePath to be derived from stored basePath - expect((wrapper.vm as unknown).formData.relativePath).toBe('Some Author\\Some Title') + expect((vm.combinedBasePath() || '').replace(/\\/g, '/')).toBe('C:/root/Some Author/Some Title') + expect(vm.formData.relativePath).toBe('Some Author\\Some Title') + expect( + (wrapper.get('.readonly-input').element as HTMLInputElement).value.replace(/\\/g, '/'), + ).toBe('C:/root/Some Author/Some Title') }) - it('treats an exact root-folder basePath as that configured root instead of custom path', async () => { - const wrapper = mount(EditAudiobookModal, { - props: { - isOpen: true, - audiobook: { - ...audiobook, - basePath: 'C:\\root', - }, - }, - attachTo: document.body, - global: { - plugins: [(await import('pinia')).createPinia()], - }, + it('treats an exact configured root as the selected root with an empty relative path', async () => { + const { vm } = await mountModal({ + ...audiobook, + basePath: 'C:\\root', }) - await new Promise((r) => setTimeout(r, 10)) - - expect((wrapper.vm as unknown).selectedRootId).toBe(1) - expect((wrapper.vm as unknown).customRootPath).toBeUndefined() - expect((wrapper.vm as unknown).formData.relativePath).toBe('') + expect(vm.selectedRootId).toBe(1) + expect(vm.formData.relativePath).toBe('') }) - it('normalizes absolute path to relative when Done is clicked', async () => { - const wrapper = mount(EditAudiobookModal, { - props: { - isOpen: true, - audiobook, - }, - attachTo: document.body, - global: { - plugins: [(await import('pinia')).createPinia()], - }, + it('selects the most specific configured root for nested roots', async () => { + vi.mocked(apiService.getRootFolders).mockResolvedValueOnce([ + defaultRoot, + { + id: 2, + name: 'Nested sensitive root', + path: 'C:\\root\\Sensitive', + isDefault: false, + resolvedCaseSensitivity: 'Sensitive', + }, + ]) + + const { vm } = await mountModal({ + ...audiobook, + basePath: 'C:\\root\\Sensitive\\Book', }) - // allow async init - await new Promise((r) => setTimeout(r, 10)) + expect(vm.selectedRootId).toBe(2) + expect(vm.formData.relativePath).toBe('Book') + }) + + it('rejects an absolute destination even when it is inside the selected root', async () => { + const { vm } = await mountModal() - // Set absolute value and call finishEditingDestination directly - ;(wrapper.vm as unknown).formData.relativePath = 'C:\\root\\New Author\\New Title' - await (wrapper.vm as unknown).finishEditingDestination() + vm.startEditingDestination() + vm.formData.relativePath = 'C:\\root\\New Author\\New Title' + vm.finishEditingDestination() - // After normalization the internal relativePath should be the short relative - expect((wrapper.vm as unknown).formData.relativePath).toBe('New Author\\New Title') + expect(vm.formData.relativePath).toBe('C:\\root\\New Author\\New Title') + expect(vm.editingDestination).toBe(true) + expect(toastMocks.error).toHaveBeenCalledWith( + 'Invalid destination', + 'Enter a path relative to the selected configured root folder.', + ) }) - it('preserves a user-typed relative path after Done and reopen', async () => { - const wrapper = mount(EditAudiobookModal, { - props: { - isOpen: true, - audiobook, - }, - attachTo: document.body, - global: { - plugins: [(await import('pinia')).createPinia()], - }, - }) + it('rejects a Windows root-relative destination', async () => { + const { vm } = await mountModal() + + vm.startEditingDestination() + vm.formData.relativePath = '\\Redirected Title' + vm.finishEditingDestination() + + expect(vm.formData.relativePath).toBe('\\Redirected Title') + expect(vm.editingDestination).toBe(true) + expect(toastMocks.error).toHaveBeenCalledWith( + 'Invalid destination', + 'Enter a path relative to the selected configured root folder.', + ) + }) - // allow async init - await new Promise((r) => setTimeout(r, 10)) + it('does not expose an arbitrary custom-path destination mode', async () => { + const { wrapper } = await mountModal() - // Type a relative path and call Done directly - ;(wrapper.vm as unknown).formData.relativePath = 'My Author\\My Title' - await (wrapper.vm as unknown).finishEditingDestination() + await wrapper.get('button[aria-label="Edit destination"]').trigger('click') + await wrapper.vm.$nextTick() - // The internal relativePath should remain what the user typed - expect((wrapper.vm as unknown).formData.relativePath).toBe('My Author\\My Title') + expect(wrapper.text()).not.toContain('Custom path') + expect(wrapper.find('.custom-input').exists()).toBe(false) + expect(wrapper.find('button[aria-label="Browse for folder"]').exists()).toBe(false) }) - it('prefills absolute path when switching to Custom path', async () => { - const wrapper = mount(EditAudiobookModal, { - props: { - isOpen: true, - audiobook, - }, - attachTo: document.body, - global: { - plugins: [(await import('pinia')).createPinia()], - }, + it('keeps a legacy out-of-root path visible until a configured-root relative path is chosen', async () => { + const legacyPath = 'D:\\legacy\\Author\\Title' + const { wrapper, vm } = await mountModal({ + ...audiobook, + basePath: legacyPath, }) - // allow async init - await new Promise((r) => setTimeout(r, 10)) - - // Simulate switching to Custom path by setting selectedRootId - ;(wrapper.vm as unknown).selectedRootId = 0 - await nextTick() + expect(vm.unmanagedExistingDestination).toBe(true) + expect((wrapper.get('.readonly-input').element as HTMLInputElement).value).toBe(legacyPath) - // customRootPath should be prefilled to the full base path (normalize slashes) - expect(((wrapper.vm as unknown).customRootPath || '').replace(/\\/g, '/')).toBe( - 'C:/root/Some Author/Some Title', + vm.startEditingDestination() + await wrapper.vm.$nextTick() + expect(wrapper.text()).toContain( + 'Enter a path relative to the selected configured root folder.', ) + + vm.formData.relativePath = 'Author\\Title' + vm.finishEditingDestination() + + expect(vm.unmanagedExistingDestination).toBe(false) + expect((vm.combinedBasePath() || '').replace(/\\/g, '/')).toBe('C:/root/Author/Title') + expect(vm.editingDestination).toBe(false) }) - it('does not duplicate relative part when saving a Custom path', async () => { - const wrapper = mount(EditAudiobookModal, { - props: { - isOpen: true, - audiobook, - }, - attachTo: document.body, - global: { - plugins: [(await import('pinia')).createPinia()], - }, + it('uses Unix separators when the configured Unix root contains a literal backslash', async () => { + vi.mocked(apiService.getRootFolders).mockResolvedValueOnce([ + { + id: 9, + name: 'Unix root with backslash', + path: '/library/Books\\Archive', + pathSyntax: 'Unix', + isDefault: true, + resolvedCaseSensitivity: 'Sensitive', + }, + ]) + vi.mocked(apiService.getApplicationSettings).mockResolvedValueOnce({ + outputPath: '/library/Books\\Archive', }) - // allow async init - await new Promise((r) => setTimeout(r, 10)) + const { vm } = await mountModal({ + ...audiobook, + basePath: '/library/Books\\Archive/Author/Title', + }) - // Simulate selecting Custom path directly - ;(wrapper.vm as unknown).selectedRootId = 0 - ;(wrapper.vm as unknown).customRootPath = (wrapper.vm as unknown).combinedBasePath() - await nextTick() + vm.startEditingDestination() + vm.formData.relativePath = 'Other/Book' + vm.finishEditingDestination() - // combinedBasePath should equal the custom path exactly (no duplication) - const cb = (wrapper.vm as unknown).combinedBasePath() - const cr = (wrapper.vm as unknown).customRootPath - expect((cb || '').replace(/\\/g, '/')).toBe((cr || '').replace(/\\/g, '/')) + expect(vm.combinedBasePath()).toBe('/library/Books\\Archive/Other/Book') + expect(vm.editingDestination).toBe(false) }) - it('selects custom path via folder browser and saves exact custom path (no duplication)', async () => { - const wrapper = mount(EditAudiobookModal, { - props: { - isOpen: true, - audiobook, - }, - attachTo: document.body, - global: { - plugins: [(await import('pinia')).createPinia()], - }, + it('treats a leading backslash as relative under an explicit Unix root', async () => { + vi.mocked(apiService.getRootFolders).mockResolvedValueOnce([ + { + id: 8, + name: 'Unix root', + path: '/library', + pathSyntax: 'Unix', + isDefault: true, + resolvedCaseSensitivity: 'Sensitive', + }, + ]) + vi.mocked(apiService.getApplicationSettings).mockResolvedValueOnce({ outputPath: '/library' }) + + const { vm } = await mountModal({ + ...audiobook, + basePath: '/library/Author/Title', }) - // allow async init - await new Promise((r) => setTimeout(r, 10)) - - // Simulate folder browser selection by setting custom root directly - ;(wrapper.vm as unknown).selectedRootId = 0 - ;(wrapper.vm as unknown).customRootPath = 'C:\\temp\\Isaac Asimov\\Foundation' - await nextTick() + vm.startEditingDestination() + vm.formData.relativePath = '\\Chapter' + vm.finishEditingDestination() - // combinedBasePath should equal the selected custom root exactly - const cb = (wrapper.vm as unknown).combinedBasePath() - expect(cb.replace(/\\/g, '/')).toBe('C:/temp/Isaac Asimov/Foundation') + expect(vm.formData.relativePath).toBe('\\Chapter') + expect(vm.editingDestination).toBe(false) }) }) diff --git a/fe/src/__tests__/LibraryImportFooter.spec.ts b/fe/src/__tests__/LibraryImportFooter.spec.ts index def1ee4f3..ca97b90bb 100644 --- a/fe/src/__tests__/LibraryImportFooter.spec.ts +++ b/fe/src/__tests__/LibraryImportFooter.spec.ts @@ -20,6 +20,7 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import LibraryImportFooter from '@/components/domain/audiobook/LibraryImportFooter.vue' import { useLibraryImportStore } from '@/stores/libraryImport' +import { useFilesystemReadinessStore } from '@/stores/filesystemReadiness' import type { SearchResult, RootFolder } from '@/types' const success = vi.fn() @@ -38,6 +39,18 @@ describe('LibraryImportFooter', () => { const pinia = createPinia() setActivePinia(pinia) const store = useLibraryImportStore() + useFilesystemReadinessStore().readiness = { + isReady: true, + status: 'ready', + databaseConnected: true, + migrationsCurrent: true, + errorCode: null, + filesystemReady: true, + filesystemStatus: 'Ready', + filesystemPhase: null, + filesystemErrorCode: null, + filesystemErrorMessage: null, + } let resolveImport: ((value: { imported: number; errors: string[] }) => void) | null = null @@ -101,4 +114,51 @@ describe('LibraryImportFooter', () => { expect(success).toHaveBeenCalledWith('Import complete', '2 books imported') }) + + it('disables cached-result imports while filesystem initialization is incomplete', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useLibraryImportStore() + useFilesystemReadinessStore().readiness = { + isReady: true, + status: 'ready', + databaseConnected: true, + migrationsCurrent: true, + errorCode: null, + filesystemReady: false, + filesystemStatus: 'Running', + filesystemPhase: 'AudiobookFileIdentities', + filesystemErrorCode: null, + filesystemErrorMessage: null, + } + store.items = { + 'C:\\incoming\\Book.mp3': { + id: 'C:\\incoming\\Book.mp3', + fullPath: 'C:\\incoming\\Book.mp3', + sourceFiles: ['C:\\incoming\\Book.mp3'], + folderPath: 'C:\\incoming', + relativePath: 'Book', + folderName: 'Book', + format: 'MP3', + fileCount: 1, + selectedMatch: { title: 'Book', authors: [] } as unknown as SearchResult, + hasSearched: true, + isSearching: false, + selected: true, + }, + } + const importSelected = vi.spyOn(store, 'importSelected') + const wrapper = mount(LibraryImportFooter, { + props: { + folders: [{ id: 1, path: 'D:\\library' }] as unknown as RootFolder[], + }, + global: { plugins: [pinia] }, + }) + + const importButton = wrapper.get('button.btn.btn-primary') + expect(importButton.attributes('disabled')).toBeDefined() + expect(importButton.attributes('title')).toContain('filesystem initialization') + await importButton.trigger('click') + expect(importSelected).not.toHaveBeenCalled() + }) }) diff --git a/fe/src/__tests__/MoveAudiobookModal.spec.ts b/fe/src/__tests__/MoveAudiobookModal.spec.ts new file mode 100644 index 000000000..afef80a30 --- /dev/null +++ b/fe/src/__tests__/MoveAudiobookModal.spec.ts @@ -0,0 +1,73 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { describe, expect, it } from 'vitest' +import MoveAudiobookModal from '@/components/feedback/MoveAudiobookModal.vue' +import { useFilesystemReadinessStore } from '@/stores/filesystemReadiness' + +function setFilesystemReadiness(ready: boolean) { + useFilesystemReadinessStore().readiness = { + isReady: true, + status: 'ready', + databaseConnected: true, + migrationsCurrent: true, + errorCode: null, + filesystemReady: ready, + filesystemStatus: ready ? 'Ready' : 'Running', + filesystemPhase: ready ? null : 'AudiobookFileIdentities', + filesystemErrorCode: null, + filesystemErrorMessage: null, + } +} + +describe('MoveAudiobookModal filesystem readiness', () => { + it('keeps path-only updates available but disables physical moves while initializing', async () => { + const pinia = createPinia() + setActivePinia(pinia) + setFilesystemReadiness(false) + const wrapper = mount(MoveAudiobookModal, { + props: { + visible: true, + pendingRootPath: 'D:\\Audiobooks', + moveFiles: true, + }, + global: { plugins: [pinia] }, + }) + + const moveFiles = wrapper.get('input[aria-label="Move files now"]') + expect(moveFiles.attributes('disabled')).toBeDefined() + expect(wrapper.text()).toContain('filesystem initialization completes') + expect(wrapper.get('.btn.btn-primary').text()).toBe('Update Path') + + await wrapper.get('.btn.btn-primary').trigger('click') + + expect(wrapper.emitted('confirm')?.[0]?.[0]).toMatchObject({ + moveFiles: false, + }) + }) + + it('allows physical moves after filesystem initialization completes', () => { + const pinia = createPinia() + setActivePinia(pinia) + setFilesystemReadiness(true) + const wrapper = mount(MoveAudiobookModal, { + props: { + visible: true, + pendingRootPath: 'D:\\Audiobooks', + moveFiles: true, + }, + global: { plugins: [pinia] }, + }) + + expect(wrapper.get('input[aria-label="Move files now"]').attributes('disabled')).toBeUndefined() + expect(wrapper.get('.btn.btn-primary').text()).toBe('Move Files') + }) +}) diff --git a/fe/src/__tests__/NotificationsTab.spec.ts b/fe/src/__tests__/NotificationsTab.spec.ts index f9a832deb..d422c232e 100644 --- a/fe/src/__tests__/NotificationsTab.spec.ts +++ b/fe/src/__tests__/NotificationsTab.spec.ts @@ -15,7 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { useConfigurationStore } from '@/stores/configuration' @@ -40,6 +40,54 @@ describe('NotificationsTab', () => { expect(wrapper.find('.section-header .small-inline-spinner').exists()).toBe(true) }) + it('uses the latest committed settings version across consecutive webhook saves', async () => { + const pinia = createPinia() + setActivePinia(pinia) + + const cfg = useConfigurationStore() + cfg.isLoading = false + const initialSettings = { version: 3, webhookUrl: '', webhooks: [] } + cfg.applicationSettings = initialSettings as never + const submittedVersions: number[] = [] + cfg.saveApplicationSettings = vi.fn(async (payload) => { + submittedVersions.push(payload.version) + const saved = { ...payload, version: payload.version + 1 } + cfg.applicationSettings = saved + return saved + }) + + const NotificationsTab = (await import('@/views/settings/NotificationsTab.vue')).default + const wrapper = mount(NotificationsTab, { + props: { settings: initialSettings as never }, + global: { plugins: [pinia] }, + }) + const vm = wrapper.vm as unknown as { openWebhookForm: () => void } + + async function addWebhook(name: string, url: string, expectedSaveCount: number) { + vm.openWebhookForm() + await wrapper.vm.$nextTick() + await wrapper.find('#webhook-name').setValue(name) + await wrapper.find('#webhook-type').setValue('NTFY') + await wrapper.vm.$nextTick() + await wrapper.find('#webhook-url').setValue(url) + const trigger = wrapper.find('.webhook-triggers input[type="checkbox"]') + expect(trigger.exists()).toBe(true) + await trigger.setValue(true) + await wrapper.find('.webhook-modal form').trigger('submit') + await vi.waitFor(() => { + expect(submittedVersions).toHaveLength(expectedSaveCount) + }) + } + + await addWebhook('First webhook', 'https://ntfy.example/first', 1) + await addWebhook('Second webhook', 'https://ntfy.example/second', 2) + + expect(submittedVersions).toEqual([3, 4]) + const updates = wrapper.emitted('update:settings') ?? [] + expect(updates).toHaveLength(2) + expect((updates[1]?.[0] as { version: number }).version).toBe(5) + }) + describe('webhook URL validation', () => { async function mountAndOpenForm() { const pinia = createPinia() diff --git a/fe/src/__tests__/ProgressBar.spec.ts b/fe/src/__tests__/ProgressBar.spec.ts index d818cfad2..0edca37cc 100644 --- a/fe/src/__tests__/ProgressBar.spec.ts +++ b/fe/src/__tests__/ProgressBar.spec.ts @@ -43,4 +43,18 @@ describe('ProgressBar', () => { expect(text).toContain('8.0 GB') expect(text).not.toContain('TB') }) + + it('renders indeterminate activity without percentage text', () => { + const wrapper = mount(ProgressBar, { + props: { + value: 0, + variant: 'activity', + indeterminate: true, + showPercentage: true, + }, + }) + + expect(wrapper.find('.progress-fill').classes()).toContain('indeterminate') + expect(wrapper.find('.percentage').exists()).toBe(false) + }) }) diff --git a/fe/src/__tests__/RenamePreviewModal.spec.ts b/fe/src/__tests__/RenamePreviewModal.spec.ts index 4a4801138..7d024103e 100644 --- a/fe/src/__tests__/RenamePreviewModal.spec.ts +++ b/fe/src/__tests__/RenamePreviewModal.spec.ts @@ -17,9 +17,33 @@ */ import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' import RenamePreviewModal from '@/components/domain/organize/RenamePreviewModal.vue' import { apiService } from '@/services/api' -import type { RenamePreview } from '@/types' +import { useFilesystemReadinessStore } from '@/stores/filesystemReadiness' +import type { RenamePathSemanticsSnapshot, RenamePreview } from '@/types' + +const currentFolderSemantics: RenamePathSemanticsSnapshot = { + syntax: 'Windows', + caseSensitivity: 'Insensitive', + requestedMode: 'Auto', + boundaryPath: 'D:\\test\\Author\\Alchemised', +} + +function createFilesystemPinia(filesystemStatus: 'Running' | 'Ready') { + const pinia = createPinia() + setActivePinia(pinia) + useFilesystemReadinessStore().readiness = { + isReady: true, + status: 'ready', + databaseConnected: true, + migrationsCurrent: true, + filesystemReady: filesystemStatus === 'Ready', + filesystemStatus, + filesystemPhase: filesystemStatus === 'Running' ? 'AudiobookFileIdentities' : null, + } + return pinia +} describe('RenamePreviewModal', () => { beforeEach(() => { @@ -32,6 +56,7 @@ describe('RenamePreviewModal', () => { audiobookId: 7, audiobookTitle: 'Alchemised', currentFolderPath: 'D:\\test\\Author\\Alchemised', + currentFolderSemantics, newFolderPath: 'D:\\test\\Author\\Alchemised test', folderChanged: true, hasChanges: true, @@ -48,11 +73,13 @@ describe('RenamePreviewModal', () => { }, ] satisfies RenamePreview[]) + const pinia = createFilesystemPinia('Ready') const wrapper = mount(RenamePreviewModal, { props: { visible: true, audiobookIds: [7], }, + global: { plugins: [pinia] }, }) await flushPromises() @@ -69,4 +96,100 @@ describe('RenamePreviewModal', () => { expect(wrapper.text()).toContain('New') expect(wrapper.find('.btn.btn-primary').text()).toContain('Organize 1') }) + + it('keeps preview available but disables organize while filesystem initialization is running', async () => { + vi.mocked(apiService.previewRename).mockResolvedValue([ + { + audiobookId: 7, + audiobookTitle: 'Alchemised', + currentFolderPath: 'D:\\test\\Author\\Alchemised', + currentFolderSemantics, + newFolderPath: 'D:\\test\\Author\\Alchemised test', + folderChanged: true, + hasChanges: true, + fileRenames: [], + }, + ] satisfies RenamePreview[]) + const pinia = createFilesystemPinia('Running') + const wrapper = mount(RenamePreviewModal, { + props: { + visible: true, + audiobookIds: [7], + }, + global: { plugins: [pinia] }, + }) + + await flushPromises() + + expect(apiService.previewRename).toHaveBeenCalledWith([7]) + const organize = wrapper.get('.btn.btn-primary') + expect(organize.attributes('disabled')).toBeDefined() + expect(organize.attributes('title')).toContain('filesystem initialization') + await organize.trigger('click') + expect(apiService.executeRename).not.toHaveBeenCalled() + }) + + it('sends expected current state and displays stale-preview conflicts as failures', async () => { + vi.mocked(apiService.previewRename).mockResolvedValue([ + { + audiobookId: 7, + audiobookTitle: 'Alchemised', + currentFolderPath: 'D:\\test\\Author\\Alchemised', + currentFolderSemantics, + newFolderPath: 'D:\\test\\Author\\Alchemised test', + folderChanged: true, + hasChanges: true, + fileRenames: [ + { + fileId: 71, + currentPath: 'D:\\test\\Author\\Alchemised\\Alchemised.m4b', + newPath: 'D:\\test\\Author\\Alchemised test\\Alchemised test.m4b', + currentFilename: 'Alchemised.m4b', + newFilename: 'Alchemised test.m4b', + changed: true, + }, + ], + }, + ] satisfies RenamePreview[]) + vi.mocked(apiService.executeRename).mockResolvedValue([ + { + audiobookId: 7, + success: false, + conflict: true, + error: 'The audiobook folder changed after the organize preview was generated.', + renamedFiles: [], + }, + ]) + + const pinia = createFilesystemPinia('Ready') + const wrapper = mount(RenamePreviewModal, { + props: { + visible: true, + audiobookIds: [7], + }, + global: { plugins: [pinia] }, + }) + await flushPromises() + + await wrapper.find('.btn.btn-primary').trigger('click') + await flushPromises() + + expect(apiService.executeRename).toHaveBeenCalledWith([ + { + audiobookId: 7, + currentFolderPath: 'D:\\test\\Author\\Alchemised', + currentFolderSemantics, + newFolderPath: 'D:\\test\\Author\\Alchemised test', + fileRenames: [ + { + fileId: 71, + currentPath: 'D:\\test\\Author\\Alchemised\\Alchemised.m4b', + newPath: 'D:\\test\\Author\\Alchemised test\\Alchemised test.m4b', + }, + ], + }, + ]) + expect(wrapper.find('.result-row.error').exists()).toBe(true) + expect(wrapper.text()).toContain('folder changed after the organize preview') + }) }) diff --git a/fe/src/__tests__/RootFolderFormModal.spec.ts b/fe/src/__tests__/RootFolderFormModal.spec.ts new file mode 100644 index 000000000..882f81250 --- /dev/null +++ b/fe/src/__tests__/RootFolderFormModal.spec.ts @@ -0,0 +1,790 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import RootFolderFormModal from '@/components/settings/RootFolderFormModal.vue' +import MoveAudiobookModal from '@/components/feedback/MoveAudiobookModal.vue' +import { useRootFoldersStore } from '@/stores/rootFolders' +import { apiService } from '@/services/api' + +const success = vi.fn() +const warning = vi.fn() +const error = vi.fn() +const filesystemReadinessMock = vi.hoisted(() => ({ filesystemReady: true })) + +vi.mock('@/stores/filesystemReadiness', () => ({ + useFilesystemReadinessStore: () => filesystemReadinessMock, +})) + +vi.mock('@/services/toastService', () => ({ + useToast: () => ({ success, warning, error }), +})) + +describe('RootFolderFormModal', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + filesystemReadinessMock.filesystemReady = true + }) + + it('rejects a Windows drive-relative root before submission', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const create = vi.spyOn(store, 'create') + const wrapper = mount(RootFolderFormModal, { + global: { + plugins: [pinia], + stubs: { + FolderBrowserModal: true, + }, + }, + }) + await wrapper.get('input[placeholder="Enter a name for this root folder"]').setValue('Library') + await wrapper.get('#root-path').setValue('C:') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect(create).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith( + 'Validation Error', + expect.stringContaining('separator after the drive letter'), + ) + }) + + it('rejects a relative root before submission', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const create = vi.spyOn(store, 'create') + const wrapper = mount(RootFolderFormModal, { + global: { + plugins: [pinia], + stubs: { + FolderBrowserModal: true, + }, + }, + }) + await wrapper.get('input[placeholder="Enter a name for this root folder"]').setValue('Library') + await wrapper.get('#root-path').setValue('relative/library') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect(create).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith( + 'Validation Error', + expect.stringContaining('absolute directory path'), + ) + }) + + it('uses the edited unambiguous path syntax instead of stale root metadata', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const wrapper = mount(RootFolderFormModal, { + props: { + root: { + id: 8, + name: 'Migrated Library', + path: '//server/share/Books', + pathSyntax: 'Windows', + isDefault: false, + }, + }, + global: { + plugins: [pinia], + stubs: { + FolderBrowserModal: true, + }, + }, + }) + await wrapper.get('#root-path').setValue('/srv/CON') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect(error).not.toHaveBeenCalled() + }) + + it('locks root path repair while filesystem startup reconciliation is unavailable', async () => { + filesystemReadinessMock.filesystemReady = false + const pinia = createPinia() + setActivePinia(pinia) + const root = { + id: 12, + name: 'Unavailable Library', + path: '/server/mnt/drive/Audiobooks', + pathSyntax: 'Unix' as const, + isDefault: false, + caseSensitivityMode: 'Auto' as const, + resolvedCaseSensitivity: 'Unknown' as const, + pathIdentityState: 'Unavailable' as const, + canChangePath: true, + canMutateFilesystem: false, + } + const wrapper = mount(RootFolderFormModal, { + props: { root }, + global: { + plugins: [pinia], + stubs: { FolderBrowserModal: true }, + }, + }) + + expect(wrapper.get('#root-path').attributes('disabled')).toBeDefined() + expect(wrapper.get('#root-case-sensitivity').attributes('disabled')).toBeDefined() + expect(wrapper.get('.btn-inline-browse').attributes('disabled')).toBeDefined() + }) + + it('keeps metadata editing available while filesystem path controls are locked', async () => { + filesystemReadinessMock.filesystemReady = false + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const root = { + id: 11, + name: 'Library', + path: 'C:\\Library', + pathSyntax: 'Windows' as const, + isDefault: false, + caseSensitivityMode: 'Auto' as const, + resolvedCaseSensitivity: 'Insensitive' as const, + pathIdentityState: 'Valid' as const, + } + store.folders = [root] + const update = vi.spyOn(store, 'update').mockResolvedValue({ + ...root, + name: 'Renamed Library', + }) + const wrapper = mount(RootFolderFormModal, { + props: { root }, + global: { + plugins: [pinia], + stubs: { FolderBrowserModal: true }, + }, + }) + + expect(wrapper.get('#root-path').attributes('disabled')).toBeDefined() + expect(wrapper.get('#root-case-sensitivity').attributes('disabled')).toBeDefined() + expect(wrapper.get('.btn-inline-browse').attributes('disabled')).toBeDefined() + const name = wrapper.get('input[placeholder="Enter a name for this root folder"]') + expect(name.attributes('disabled')).toBeUndefined() + await name.setValue('Renamed Library') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + await vi.waitFor(() => expect(update).toHaveBeenCalledTimes(1)) + expect(update).toHaveBeenCalledWith( + root.id, + expect.objectContaining({ + name: 'Renamed Library', + path: root.path, + caseSensitivityMode: root.caseSensitivityMode, + }), + { expectedCurrentPath: root.path }, + ) + }) + + it('updates metadata directly for an equivalent Windows path', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const root = { + id: 12, + name: 'Library', + path: 'C:\\Library', + pathSyntax: 'Windows' as const, + isDefault: false, + caseSensitivityMode: 'Auto' as const, + resolvedCaseSensitivity: 'Insensitive' as const, + pathIdentityState: 'Valid' as const, + } + store.folders = [root] + const update = vi.spyOn(store, 'update') + const updateMetadata = vi.spyOn(apiService, 'updateRootFolder').mockResolvedValue(root) + const relocate = vi.spyOn(apiService, 'changeRootFolderPath') + vi.spyOn(apiService, 'getRootFolders').mockResolvedValue([root]) + const wrapper = mount(RootFolderFormModal, { + props: { + root, + }, + global: { + plugins: [pinia], + stubs: { + FolderBrowserModal: true, + }, + }, + }) + await wrapper.get('#root-path').setValue('c:/library/') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + await vi.waitFor(() => expect(update).toHaveBeenCalledTimes(1)) + expect(update).toHaveBeenCalledWith(12, expect.objectContaining({ path: 'c:/library/' }), { + expectedCurrentPath: 'C:\\Library', + }) + expect(updateMetadata).toHaveBeenCalledWith( + 12, + expect.objectContaining({ path: 'C:\\Library' }), + ) + expect(relocate).not.toHaveBeenCalled() + expect(success).toHaveBeenCalledWith('Success', 'Root folder updated') + }) + + it('ignores stale resolved sensitivity when explicit persisted mode is sensitive', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const root = { + id: 20, + name: 'Library', + path: 'C:\\Library', + pathSyntax: 'Windows' as const, + isDefault: false, + caseSensitivityMode: 'Sensitive' as const, + resolvedCaseSensitivity: 'Insensitive' as const, + pathIdentityState: 'Valid' as const, + } + store.folders = [root] + const update = vi.spyOn(store, 'update') + const wrapper = mount(RootFolderFormModal, { + props: { root }, + global: { + plugins: [pinia], + stubs: { FolderBrowserModal: true }, + }, + }) + await wrapper.get('#root-path').setValue('C:\\library') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect(update).not.toHaveBeenCalled() + expect((wrapper.vm as unknown as { showConfirm: boolean }).showConfirm).toBe(true) + }) + + it('fails closed when auto identity is unavailable despite stale insensitive resolution', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const root = { + id: 21, + name: 'Library', + path: 'C:\\Library', + pathSyntax: 'Windows' as const, + isDefault: false, + caseSensitivityMode: 'Auto' as const, + resolvedCaseSensitivity: 'Insensitive' as const, + pathIdentityState: 'Unavailable' as const, + } + store.folders = [root] + const update = vi.spyOn(store, 'update') + const wrapper = mount(RootFolderFormModal, { + props: { root }, + global: { + plugins: [pinia], + stubs: { FolderBrowserModal: true }, + }, + }) + await wrapper.get('#root-path').setValue('C:\\library') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect(update).not.toHaveBeenCalled() + expect((wrapper.vm as unknown as { showConfirm: boolean }).showConfirm).toBe(true) + }) + + it('same-path filesystem-semantics repair requires confirmation without offering file movement', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const root = { + id: 23, + name: 'Library', + path: 'D:\\Listenarr Test', + pathSyntax: 'Windows' as const, + isDefault: true, + caseSensitivityMode: 'Auto' as const, + resolvedCaseSensitivity: 'Sensitive' as const, + pathIdentityState: 'Valid' as const, + storageState: 'Unavailable' as const, + storageReason: 'FilesystemSemanticsChanged' as const, + canMutateFilesystem: false, + } + store.folders = [root] + const update = vi.spyOn(store, 'update').mockResolvedValue({ + ...root, + storageState: 'Healthy', + storageReason: 'None', + canMutateFilesystem: true, + }) + const wrapper = mount(RootFolderFormModal, { + props: { root }, + global: { + plugins: [pinia], + stubs: { FolderBrowserModal: true }, + }, + }) + + await (wrapper.vm as unknown as { save: () => Promise }).save() + await wrapper.vm.$nextTick() + + const moveModal = wrapper.findComponent(MoveAudiobookModal) + expect(moveModal.props('rootFolderRepair')).toBe(true) + expect(moveModal.props('showMoveOption')).toBe(false) + expect(moveModal.props('allowMoveFiles')).toBe(false) + moveModal.vm.$emit('confirm', { moveFiles: false, deleteEmpty: false }) + + await vi.waitFor(() => expect(update).toHaveBeenCalledTimes(1)) + expect(update).toHaveBeenCalledWith( + root.id, + expect.objectContaining({ path: root.path }), + expect.objectContaining({ + expectedCurrentPath: root.path, + pathChangeConfirmed: true, + moveFiles: false, + }), + ) + }) + + it('foreign source path change disables physical move and confirms metadata-only repair', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const root = { + id: 22, + name: 'Copied Linux Library', + path: '/server/mnt/drive/Audiobooks', + pathSyntax: 'Unix' as const, + isDefault: true, + caseSensitivityMode: 'Auto' as const, + resolvedCaseSensitivity: 'Sensitive' as const, + pathIdentityState: 'Unavailable' as const, + storageState: 'Unavailable' as const, + storageReason: 'ForeignPathSyntax' as const, + canMutateFilesystem: false, + } + store.folders = [root] + const update = vi.spyOn(store, 'update').mockResolvedValue({ + ...root, + path: 'D:\\Listenarr Test', + pathSyntax: 'Windows', + storageState: 'Healthy', + storageReason: 'None', + canMutateFilesystem: true, + }) + const wrapper = mount(RootFolderFormModal, { + props: { root }, + attachTo: document.body, + global: { + plugins: [pinia], + stubs: { FolderBrowserModal: true }, + }, + }) + await wrapper.get('#root-path').setValue('D:\\Listenarr Test') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + await wrapper.vm.$nextTick() + + const moveFiles = document.body.querySelector( + 'input[aria-label="Move files now"]', + ) + expect(moveFiles).not.toBeNull() + expect(moveFiles!.disabled).toBe(true) + expect(moveFiles!.checked).toBe(false) + expect(document.body.textContent).toContain( + 'Files cannot be moved from the current root on this system', + ) + const moveModal = wrapper.findComponent(MoveAudiobookModal) + expect(moveModal.props('allowMoveFiles')).toBe(false) + expect(moveModal.props('moveFiles')).toBe(false) + moveModal.vm.$emit('confirm', { moveFiles: false, deleteEmpty: false }) + + await vi.waitFor(() => expect(update).toHaveBeenCalledTimes(1)) + expect(update).toHaveBeenCalledWith( + root.id, + expect.objectContaining({ path: 'D:\\Listenarr Test' }), + expect.objectContaining({ + expectedCurrentPath: root.path, + pathChangeConfirmed: true, + moveFiles: false, + }), + ) + wrapper.unmount() + }) + + it('requires relocation confirmation when a sensitive persisted root changes only by case', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const root = { + id: 14, + name: 'Library', + path: 'C:\\Library', + pathSyntax: 'Windows' as const, + isDefault: false, + caseSensitivityMode: 'Sensitive' as const, + resolvedCaseSensitivity: 'Sensitive' as const, + } + store.folders = [root] + const update = vi.spyOn(store, 'update') + const updateMetadata = vi.spyOn(apiService, 'updateRootFolder') + const relocate = vi.spyOn(apiService, 'changeRootFolderPath') + const wrapper = mount(RootFolderFormModal, { + props: { root }, + global: { + plugins: [pinia], + stubs: { FolderBrowserModal: true }, + }, + }) + await wrapper.get('#root-path').setValue('C:\\library') + await wrapper.get('#root-case-sensitivity').setValue('Insensitive') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect((wrapper.vm as unknown as { showConfirm: boolean }).showConfirm).toBe(true) + expect(update).not.toHaveBeenCalled() + expect(updateMetadata).not.toHaveBeenCalled() + expect(relocate).not.toHaveBeenCalled() + }) + + it('migrates semantics without path confirmation when an insensitive root changes only by case', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const root = { + id: 18, + name: 'Library', + path: 'C:\\Library', + pathSyntax: 'Windows' as const, + isDefault: false, + caseSensitivityMode: 'Insensitive' as const, + resolvedCaseSensitivity: 'Insensitive' as const, + } + const updated = { + ...root, + caseSensitivityMode: 'Sensitive' as const, + resolvedCaseSensitivity: 'Sensitive' as const, + } + store.folders = [root] + const updateMetadata = vi.spyOn(apiService, 'updateRootFolder') + const relocate = vi.spyOn(apiService, 'changeRootFolderPath').mockResolvedValue({ + relocationId: null, + rootFolderId: 18, + currentPath: root.path, + targetPath: root.path, + status: 'Completed', + totalJobs: 0, + completedJobs: 0, + targetIdentityEnrollmentState: 'Authorized', + }) + vi.spyOn(apiService, 'getRootFolders').mockResolvedValue([updated]) + const wrapper = mount(RootFolderFormModal, { + props: { root }, + global: { + plugins: [pinia], + stubs: { FolderBrowserModal: true }, + }, + }) + await wrapper.get('#root-path').setValue('C:\\library') + await wrapper.get('#root-case-sensitivity').setValue('Sensitive') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect((wrapper.vm as unknown as { showConfirm: boolean }).showConfirm).toBe(false) + expect(updateMetadata).not.toHaveBeenCalled() + expect(relocate).toHaveBeenCalledWith(18, { + targetPath: root.path, + mode: 'metadataOnly', + deleteEmptySource: false, + desiredName: root.name, + desiredIsDefault: false, + targetCaseSensitivityMode: 'Sensitive', + expectedCurrentPath: root.path, + }) + }) + + it('fails closed when the current root is missing after reload', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const root = { + id: 16, + name: 'Removed Library', + path: '/removed-library', + pathSyntax: 'Unix' as const, + isDefault: false, + } + vi.spyOn(apiService, 'getRootFolders').mockResolvedValue([]) + const updateMetadata = vi.spyOn(apiService, 'updateRootFolder') + const relocate = vi.spyOn(apiService, 'changeRootFolderPath') + const wrapper = mount(RootFolderFormModal, { + props: { root }, + global: { + plugins: [pinia], + stubs: { FolderBrowserModal: true }, + }, + }) + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect(updateMetadata).not.toHaveBeenCalled() + expect(relocate).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith('Error', expect.stringContaining('removed')) + }) + + it('requires confirmation for a store-computed path change', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + store.folders = [ + { + id: 17, + name: 'Library', + path: '/old-library', + pathSyntax: 'Unix', + isDefault: false, + resolvedCaseSensitivity: 'Sensitive', + }, + ] + + await expect( + store.update( + 17, + { + id: 17, + name: 'Library', + path: '/new-library', + isDefault: false, + caseSensitivityMode: 'Auto', + }, + { expectedCurrentPath: '/old-library' }, + ), + ).rejects.toThrow('requires confirmation') + }) + + it('fails closed when the stored root changed while the modal was open', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const root = { + id: 15, + name: 'Library', + path: '/old-library', + pathSyntax: 'Unix' as const, + isDefault: false, + } + store.folders = [{ ...root, path: '/newer-library' }] + const updateMetadata = vi.spyOn(apiService, 'updateRootFolder') + const relocate = vi.spyOn(apiService, 'changeRootFolderPath') + const wrapper = mount(RootFolderFormModal, { + props: { root }, + global: { + plugins: [pinia], + stubs: { FolderBrowserModal: true }, + }, + }) + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect(updateMetadata).not.toHaveBeenCalled() + expect(relocate).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith('Error', expect.stringContaining('changed while editing')) + }) + + it('fails closed for a case-only edit when persisted auto semantics are unknown', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const update = vi.spyOn(store, 'update') + const wrapper = mount(RootFolderFormModal, { + props: { + root: { + id: 19, + name: 'Library', + path: 'C:\\Library', + pathSyntax: 'Windows', + isDefault: false, + caseSensitivityMode: 'Auto', + resolvedCaseSensitivity: 'Unknown', + }, + }, + global: { + plugins: [pinia], + stubs: { FolderBrowserModal: true }, + }, + }) + await wrapper.get('#root-path').setValue('C:\\library') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect(update).not.toHaveBeenCalled() + expect((wrapper.vm as unknown as { showConfirm: boolean }).showConfirm).toBe(true) + }) + + it.each(['Sensitive', 'Unknown'] as const)( + 'treats a case-only edit as a path change when sensitive mode resolves as %s', + async (resolvedCaseSensitivity) => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const update = vi.spyOn(store, 'update') + const wrapper = mount(RootFolderFormModal, { + props: { + root: { + id: 13, + name: 'Library', + path: 'C:\\Library', + pathSyntax: 'Windows', + isDefault: false, + caseSensitivityMode: 'Sensitive', + resolvedCaseSensitivity, + }, + }, + global: { + plugins: [pinia], + stubs: { + FolderBrowserModal: true, + }, + }, + }) + await wrapper.get('#root-path').setValue('C:\\library') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect(update).not.toHaveBeenCalled() + expect((wrapper.vm as unknown as { showConfirm: boolean }).showConfirm).toBe(true) + }, + ) + + it('shows the structured root-folder conflict message without the raw API wrapper', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const publicMessage = + 'This root folder already has a path change in progress. Wait for it to finish, or resolve and retry the existing relocation before changing the path again.' + vi.spyOn(store, 'update').mockRejectedValue( + Object.assign(new Error(`API error: 409 {"message":"${publicMessage}"}`), { + status: 409, + body: JSON.stringify({ + message: publicMessage, + code: 'root_folder_relocation_active', + }), + }), + ) + const wrapper = mount(RootFolderFormModal, { + props: { + root: { + id: 7, + name: 'Library', + path: '/old-library', + isDefault: true, + }, + }, + global: { + plugins: [pinia], + stubs: { + FolderBrowserModal: true, + }, + }, + }) + await wrapper.get('#root-path').setValue('/new-library') + + await ( + wrapper.vm as unknown as { confirmChange: (moveFiles: boolean) => Promise } + ).confirmChange(false) + + expect(error).toHaveBeenCalledWith('Error', publicMessage) + expect(error).not.toHaveBeenCalledWith('Error', expect.stringContaining('API error: 409')) + }) + + it('reports metadata-only partial success as a warning instead of a failed save', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + vi.spyOn(store, 'update').mockResolvedValue({ + id: 7, + name: 'Library', + path: '/new-library', + isDefault: true, + activeRelocation: { + relocationId: 'repair-1', + rootFolderId: 7, + currentPath: '/new-library', + targetPath: '/new-library', + status: 'NeedsAttention', + totalJobs: 2, + completedJobs: 1, + error: 'The relocation requires attention.', + targetIdentityEnrollmentState: 'Authorized', + }, + }) + const wrapper = mount(RootFolderFormModal, { + props: { + root: { + id: 7, + name: 'Library', + path: '/old-library', + isDefault: true, + }, + }, + global: { + plugins: [pinia], + stubs: { + FolderBrowserModal: true, + }, + }, + }) + await wrapper.get('#root-path').setValue('/new-library') + + await ( + wrapper.vm as unknown as { confirmChange: (moveFiles: boolean) => Promise } + ).confirmChange(false) + + expect(warning).toHaveBeenCalledWith( + 'Root folder changed', + expect.stringContaining('audiobooks still need path repair'), + ) + expect(error).not.toHaveBeenCalled() + expect(success).not.toHaveBeenCalled() + }) + + it.each([ + [true, 'Root relocation started'], + [false, 'Root folder changed'], + ])('reports the path change accurately when moveFiles is %s', async (moveFiles, message) => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + vi.spyOn(store, 'update').mockResolvedValue({ + id: 7, + name: 'Library', + path: '/new-library', + isDefault: true, + }) + const wrapper = mount(RootFolderFormModal, { + props: { + root: { + id: 7, + name: 'Library', + path: '/old-library', + isDefault: true, + }, + }, + global: { + plugins: [pinia], + stubs: { + FolderBrowserModal: true, + }, + }, + }) + await wrapper.get('#root-path').setValue('/new-library') + await ( + wrapper.vm as unknown as { confirmChange: (moveFiles: boolean) => Promise } + ).confirmChange(moveFiles) + await vi.waitFor(() => expect(success).toHaveBeenCalledWith('Success', message)) + expect(store.update).toHaveBeenCalledWith( + 7, + expect.objectContaining({ path: '/new-library' }), + expect.objectContaining({ + expectedCurrentPath: '/old-library', + pathChangeConfirmed: true, + moveFiles, + }), + ) + }) +}) diff --git a/fe/src/__tests__/RootFolderSelect.spec.ts b/fe/src/__tests__/RootFolderSelect.spec.ts new file mode 100644 index 000000000..28190bf68 --- /dev/null +++ b/fe/src/__tests__/RootFolderSelect.spec.ts @@ -0,0 +1,64 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia } from 'pinia' + +vi.mock('@/services/api', () => ({ + apiService: { + getRootFolders: vi.fn(), + }, +})) + +import { apiService } from '@/services/api' +import RootFolderSelect from '@/components/form/RootFolderSelect.vue' + +describe('RootFolderSelect', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(apiService.getRootFolders).mockResolvedValue([ + { id: 1, name: 'Primary', path: '/library', isDefault: true }, + { id: 2, name: 'Archive', path: '/archive', isDefault: false }, + ]) + }) + + it('offers only the default and configured roots', async () => { + const wrapper = mount(RootFolderSelect, { + props: { rootId: null }, + global: { plugins: [createPinia()] }, + }) + + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(wrapper.findAll('option').map((option) => option.text())).toEqual([ + 'Use default', + 'Primary — /library', + 'Archive — /archive', + ]) + expect(wrapper.text()).not.toContain('Custom path') + }) + + it('emits only configured root IDs or the default selection', async () => { + const wrapper = mount(RootFolderSelect, { + props: { rootId: null }, + global: { plugins: [createPinia()] }, + }) + + await new Promise((resolve) => setTimeout(resolve, 20)) + const select = wrapper.get('select') + + await select.setValue('2') + expect(wrapper.emitted('update:rootId')?.at(-1)).toEqual([2]) + + await select.setValue('__null__') + expect(wrapper.emitted('update:rootId')?.at(-1)).toEqual([null]) + expect(wrapper.emitted('update:customPath')).toBeUndefined() + }) +}) diff --git a/fe/src/__tests__/RootFoldersSettings.spec.ts b/fe/src/__tests__/RootFoldersSettings.spec.ts index 65a3e2fce..70cc403b1 100644 --- a/fe/src/__tests__/RootFoldersSettings.spec.ts +++ b/fe/src/__tests__/RootFoldersSettings.spec.ts @@ -15,16 +15,81 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import { describe, it, expect, vi } from 'vitest' -import { mount } from '@vue/test-utils' +import { beforeEach, describe, it, expect, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import RootFoldersSettings from '@/components/settings/RootFoldersSettings.vue' import { useRootFoldersStore } from '@/stores/rootFolders' +import { useFilesystemReadinessStore } from '@/stores/filesystemReadiness' +import { apiService } from '@/services/api' +import { signalRService } from '@/services/signalr' +import { useToast } from '@/services/toastService' +import type { RootFolder, RootFolderPathChangeResult } from '@/types' + +const targetPath = '/srv/Audiobooks ' + +function relocation( + targetIdentityEnrollmentState: RootFolderPathChangeResult['targetIdentityEnrollmentState'], +): RootFolderPathChangeResult { + return { + relocationId: 'relocation-1', + rootFolderId: 3, + currentPath: '/srv/Old', + targetPath, + status: 'NeedsAttention', + totalJobs: 1, + completedJobs: 0, + error: 'Authorization required', + targetIdentityEnrollmentState, + mode: 'Relocate', + } +} + +function rootFolder(activeRelocation: RootFolderPathChangeResult | null): RootFolder { + return { + id: 3, + name: 'Audiobooks', + path: '/srv/Old', + isDefault: true, + pathIdentityState: 'Valid', + resolvedCaseSensitivity: 'Sensitive', + storageState: 'Healthy', + storageReason: 'None', + canConfirmCurrentFolder: false, + canChangePath: true, + canMutateFilesystem: true, + activeRelocation, + } +} + +function createReadyPinia() { + const pinia = createPinia() + setActivePinia(pinia) + useFilesystemReadinessStore().readiness = { + isReady: true, + status: 'ready', + databaseConnected: true, + migrationsCurrent: true, + filesystemReady: true, + filesystemStatus: 'Ready', + } + return pinia +} describe('RootFoldersSettings', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + vi.mocked(apiService.getRootFolders).mockReset().mockResolvedValue([]) + vi.mocked(apiService.getRootFolderMetadataRepairDetails).mockReset() + vi.mocked(apiService.removeRootFolderMetadataRepairFile).mockReset() + vi.mocked(apiService.abandonUnpublishedRootFolderRelocation).mockReset() + vi.mocked(apiService.retryRootFolderRelocation).mockReset() + useToast().toasts.splice(0) + }) + it('shows header spinner and loading state when store.loading is true', async () => { - const pinia = createPinia() - setActivePinia(pinia) + const pinia = createReadyPinia() useRootFoldersStore() @@ -51,4 +116,469 @@ describe('RootFoldersSettings', () => { await new Promise((r) => setTimeout(r, 0)) await wrapper.vm.$nextTick() }) + + it('presents partial metadata repair as an actionable progress panel', async () => { + const skippedAudiobookIds = [8, 10, 11, 12, 13, 14, 15, 18, 19, 20, 21, 22, 23, 32] + const active = { + ...relocation('Authorized'), + mode: 'MetadataOnly' as const, + currentPath: targetPath, + targetPath, + totalJobs: 81, + completedJobs: 67, + skippedAudiobookIds, + skippedItems: skippedAudiobookIds.map((audiobookId) => ({ + audiobookId, + reasonCode: + audiobookId === 32 + ? ('TargetIdentityCollision' as const) + : ('InvalidStoredPath' as const), + })), + } + vi.mocked(apiService.getRootFolders).mockResolvedValue([ + { + ...rootFolder(active), + path: targetPath, + }, + ]) + const pinia = createReadyPinia() + const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) + await flushPromises() + + expect(wrapper.text()).toContain('Path repair needs attention') + expect(wrapper.text()).toContain('67 of 81 audiobooks updated') + expect(wrapper.text()).toContain('14 audiobooks still need manual review') + expect(wrapper.text()).not.toContain('Pending path:') + expect(wrapper.text()).not.toContain('Authorization required') + expect(wrapper.get('[role="progressbar"]').attributes('aria-valuenow')).toBe('83') + expect(wrapper.get('.relocation-affected summary').text()).toContain( + '14 audiobooks need attention', + ) + expect(wrapper.get('a[href="/audiobooks/32"]').text()).toBe('Audiobook #32') + expect(wrapper.text()).toContain('Tracked file paths collide at this destination.') + expect(wrapper.text()).not.toContain('Case-sensitive file paths collide') + expect( + wrapper.findAll('button').some((button) => button.text().trim() === 'Retry remaining'), + ).toBe(true) + }) + + it('loads collision repair details and removes only the selected tracked record', async () => { + const active = { + ...relocation('Unavailable'), + mode: 'MetadataOnly' as const, + currentPath: targetPath, + targetPath, + totalJobs: 1, + completedJobs: 0, + skippedAudiobookIds: [32], + skippedItems: [ + { + audiobookId: 32, + reasonCode: 'TargetIdentityCollision' as const, + }, + ], + } + vi.mocked(apiService.getRootFolders).mockResolvedValue([ + { + ...rootFolder(active), + path: targetPath, + }, + ]) + vi.mocked(apiService.getRootFolderMetadataRepairDetails).mockResolvedValue({ + relocationId: 'relocation-1', + audiobookId: 32, + audiobookTitle: 'Powerless', + reasonCode: 'TargetIdentityCollision', + collisionGroups: [ + { + targetRelativePath: 'Author/Powerless/book.mp3', + files: [ + { audiobookFileId: 100, audiobookId: 32, relativePath: 'book.mp3', canRemove: true }, + { audiobookFileId: 101, audiobookId: 32, relativePath: 'book.MP3', canRemove: true }, + ], + }, + ], + }) + vi.mocked(apiService.removeRootFolderMetadataRepairFile).mockResolvedValue({ + relocationId: 'relocation-1', + audiobookId: 32, + audiobookTitle: 'Powerless', + reasonCode: 'TargetIdentityCollision', + collisionGroups: [], + }) + const pinia = createReadyPinia() + const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) + await flushPromises() + + const review = wrapper.findAll('button').find((button) => button.text().trim() === 'Review') + expect(review).toBeDefined() + await review!.trigger('click') + await flushPromises() + + expect(apiService.getRootFolderMetadataRepairDetails).toHaveBeenCalledWith('relocation-1', 32) + expect(wrapper.text()).toContain('Powerless') + expect(wrapper.text()).toContain('book.mp3') + expect(wrapper.text()).toContain('book.MP3') + + const remove = wrapper + .findAll('button') + .find((button) => button.text().trim() === 'Remove tracked record') + expect(remove).toBeDefined() + await remove!.trigger('click') + await wrapper.vm.$nextTick() + const confirm = wrapper + .findAll('button') + .find((button) => button.text().includes('Remove record')) + expect(confirm).toBeDefined() + await confirm!.trigger('click') + await flushPromises() + + expect(apiService.removeRootFolderMetadataRepairFile).toHaveBeenCalledWith( + 'relocation-1', + 32, + 100, + ) + expect(wrapper.text()).toContain('No remaining conflicting tracked file records were found') + }) + + it('reloads root relocation state after SignalR reconnect', async () => { + const active = relocation('Authorized') + vi.mocked(apiService.getRootFolders) + .mockResolvedValueOnce([rootFolder(active)]) + .mockResolvedValueOnce([rootFolder(null)]) + let connected: (() => void) | undefined + const unsubscribe = vi.fn() + vi.spyOn(signalRService, 'onConnected').mockImplementation((callback) => { + connected = callback + return unsubscribe + }) + const pinia = createReadyPinia() + const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) + await flushPromises() + + expect(wrapper.text()).toContain('Library move needs attention') + connected?.() + await flushPromises() + + expect(apiService.getRootFolders).toHaveBeenCalledTimes(2) + expect(wrapper.text()).not.toContain('Library move needs attention') + wrapper.unmount() + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['Healthy', 'Healthy', true, false], + ['Missing', 'Missing', false, false], + ['Unavailable', 'Unavailable', false, false], + ['Unconfirmed', 'Needs confirmation', false, true], + ] as const)( + 'renders %s storage state with the correct actions', + async (storageState, label, canMutateFilesystem, canConfirmCurrentFolder) => { + const folder = { + ...rootFolder(null), + storageState, + storageReason: + storageState === 'Healthy' + ? ('None' as const) + : storageState === 'Missing' + ? ('PathMissing' as const) + : storageState === 'Unconfirmed' + ? ('NoAuthorizedIdentity' as const) + : ('AccessDenied' as const), + storageMessage: + storageState === 'Healthy' ? null : `Storage is ${storageState.toLowerCase()}.`, + canMutateFilesystem, + canConfirmCurrentFolder, + confirmationToken: canConfirmCurrentFolder ? 'observation-token' : null, + } + vi.mocked(apiService.getRootFolders).mockResolvedValue([folder]) + const pinia = createReadyPinia() + const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) + await flushPromises() + + expect(wrapper.text()).toContain(label) + expect(wrapper.get('[data-cy="scan-unmatched"]').attributes('disabled') !== undefined).toBe( + !canMutateFilesystem, + ) + expect(wrapper.find('[data-cy="confirm-root-folder"]').exists()).toBe(canConfirmCurrentFolder) + wrapper.unmount() + }, + ) + + it('shows initializing, blocks filesystem actions, and keeps metadata editing available', async () => { + const folder = { + ...rootFolder(null), + storageState: 'Initializing' as const, + storageReason: 'Initializing' as const, + storageMessage: 'Library filesystem initialization is in progress.', + canMutateFilesystem: false, + canChangePath: false, + canConfirmCurrentFolder: false, + confirmationToken: null, + } + vi.mocked(apiService.getRootFolders).mockResolvedValue([folder]) + const pinia = createPinia() + setActivePinia(pinia) + useFilesystemReadinessStore().readiness = { + isReady: true, + status: 'ready', + databaseConnected: true, + migrationsCurrent: true, + filesystemReady: false, + filesystemStatus: 'Running', + filesystemPhase: 'AudiobookFileIdentities', + } + const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) + await flushPromises() + + expect(wrapper.text()).toContain('Initializing') + expect(wrapper.text()).not.toContain('Needs confirmation') + expect(wrapper.get('[data-cy="scan-unmatched"]').attributes('disabled')).toBeDefined() + expect(wrapper.get('[data-cy="edit-root-folder"]').attributes('disabled')).toBeUndefined() + expect(wrapper.find('[data-cy="confirm-root-folder"]').exists()).toBe(false) + }) + + it('confirms the exact observed folder generation only when confirmation is available', async () => { + const folder = { + ...rootFolder(null), + storageState: 'Changed' as const, + storageReason: 'IdentityMismatch' as const, + storageMessage: 'The folder at this location changed.', + canConfirmCurrentFolder: true, + canMutateFilesystem: false, + confirmationToken: 'observation-token', + } + vi.mocked(apiService.getRootFolders).mockResolvedValue([folder]) + vi.mocked(apiService.confirmRootFolder).mockResolvedValue(folder) + const pinia = createReadyPinia() + const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) + await flushPromises() + + expect(wrapper.text()).toContain('Folder changed') + const action = wrapper.get('[data-cy="confirm-root-folder"]') + await action.trigger('click') + + const displayedPath = wrapper.get('[data-testid="root-folder-confirmation-path"]') + expect(displayedPath.element.textContent).toBe(folder.path) + const confirm = wrapper.get('.modal-delete-button') + expect(confirm.text()).toContain('Confirm folder') + await confirm.trigger('click') + await flushPromises() + + expect(apiService.confirmRootFolder).toHaveBeenCalledWith( + folder.id, + folder.path, + folder.confirmationToken, + ) + }) + + it('blocks metadata-only retry while startup filesystem reconciliation is unavailable', async () => { + const active = { + ...relocation('Authorized'), + mode: 'MetadataOnly' as const, + skippedAudiobookIds: [32], + } + vi.mocked(apiService.getRootFolders).mockResolvedValue([rootFolder(active)]) + const pinia = createPinia() + setActivePinia(pinia) + useFilesystemReadinessStore().readiness = { + isReady: true, + status: 'ready', + databaseConnected: true, + migrationsCurrent: true, + filesystemReady: false, + filesystemStatus: 'Failed', + } + const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) + await flushPromises() + + const retry = wrapper + .findAll('button') + .find((button) => button.text().trim() === 'Retry remaining') + expect(retry).toBeDefined() + expect(retry!.attributes('disabled')).toBeDefined() + }) + + it('keeps failed metadata repair retry available without physical target identity once startup is ready', async () => { + const active = { + ...relocation('Unavailable'), + mode: 'MetadataOnly' as const, + status: 'Failed' as const, + error: 'Metadata recovery failed.', + skippedAudiobookIds: [], + } + vi.mocked(apiService.getRootFolders).mockResolvedValue([rootFolder(active)]) + const pinia = createReadyPinia() + const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) + await flushPromises() + + expect(wrapper.text()).toContain('Path repair failed') + const retry = wrapper + .findAll('button') + .find((button) => button.text().trim() === 'Retry repair') + expect(retry).toBeDefined() + expect(retry!.attributes('disabled')).toBeUndefined() + }) + + it('offers cancel only when the backend marks an unpublished physical relocation abandonable', async () => { + const abandonable = { + ...relocation('Authorized'), + status: 'NeedsAttention' as const, + totalJobs: 1, + completedJobs: 0, + canAbandon: true, + } + vi.mocked(apiService.getRootFolders).mockResolvedValue([rootFolder(abandonable)]) + vi.mocked(apiService.abandonUnpublishedRootFolderRelocation).mockResolvedValue({ + ...abandonable, + status: 'Failed', + canAbandon: false, + }) + const pinia = createReadyPinia() + const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) + await flushPromises() + + const buttons = wrapper.findAll('button') + const cancel = buttons.find((button) => button.text().trim() === 'Cancel unfinished') + expect(cancel).toBeDefined() + expect(buttons.some((button) => button.text().trim() === 'Retry')).toBe(false) + await cancel!.trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('No audiobook move jobs were published') + + const confirm = wrapper + .findAll('button') + .find((button) => button.text().trim() === 'Cancel relocation') + expect(confirm).toBeDefined() + await confirm!.trigger('click') + await flushPromises() + + expect(apiService.abandonUnpublishedRootFolderRelocation).toHaveBeenCalledWith( + abandonable.relocationId, + ) + expect(useToast().toasts[0]?.title).toBe('Root relocation canceled') + }) + + it('does not infer abandon authority from a physical NeedsAttention status alone', async () => { + const active = { + ...relocation('Authorized'), + status: 'NeedsAttention' as const, + totalJobs: 1, + completedJobs: 0, + canAbandon: false, + } + vi.mocked(apiService.getRootFolders).mockResolvedValue([rootFolder(active)]) + const pinia = createReadyPinia() + const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) + await flushPromises() + + expect(wrapper.text()).not.toContain('Cancel unfinished') + }) + + it('reports a durable failed retry result as a failure, not partial attention', async () => { + const active = { + ...relocation('Unavailable'), + mode: 'MetadataOnly' as const, + status: 'Failed' as const, + error: 'Metadata recovery failed.', + skippedAudiobookIds: [], + } + vi.mocked(apiService.getRootFolders).mockResolvedValue([rootFolder(active)]) + vi.mocked(apiService.retryRootFolderRelocation).mockResolvedValue({ + ...active, + error: + 'The relocation failed. Review the server logs and retry after resolving the underlying issue.', + }) + const pinia = createReadyPinia() + const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) + await flushPromises() + + const retry = wrapper + .findAll('button') + .find((button) => button.text().trim() === 'Retry repair') + expect(retry).toBeDefined() + await retry!.trigger('click') + await flushPromises() + + const retryToast = useToast().toasts[0] + expect(retryToast?.level).toBe('error') + expect(retryToast?.title).toBe('Path repair failed') + expect(retryToast?.message).toContain('The relocation failed') + expect(retryToast?.message).not.toContain('still needs attention') + }) + + it('shows the structured API message when retry is rejected', async () => { + const active = { + ...relocation('Unavailable'), + mode: 'MetadataOnly' as const, + status: 'Failed' as const, + error: 'Metadata recovery failed.', + skippedAudiobookIds: [], + } + vi.mocked(apiService.getRootFolders).mockResolvedValue([rootFolder(active)]) + const apiError = Object.assign( + new Error('API error: 409 {"message":"Resolve the active recovery state before retrying."}'), + { + status: 409, + body: JSON.stringify({ + message: 'Resolve the active recovery state before retrying.', + code: 'root_folder_path_change_blocked', + }), + }, + ) + vi.mocked(apiService.retryRootFolderRelocation).mockRejectedValue(apiError) + const pinia = createReadyPinia() + const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) + await flushPromises() + + const retry = wrapper + .findAll('button') + .find((button) => button.text().trim() === 'Retry repair') + expect(retry).toBeDefined() + await retry!.trigger('click') + await flushPromises() + + const retryToast = useToast().toasts[0] + expect(retryToast?.title).toBe('Retry failed') + expect(retryToast?.message).toBe('Resolve the active recovery state before retrying.') + expect(retryToast?.message).not.toContain('API error') + expect(retryToast?.message).not.toContain('409') + }) + + it('disables set-default while a relocation owns the root metadata state', async () => { + vi.mocked(apiService.getRootFolders).mockResolvedValue([ + { + ...rootFolder(relocation('Authorized')), + isDefault: false, + }, + ]) + const pinia = createReadyPinia() + const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) + await flushPromises() + + const setDefault = wrapper.get('button[title="Set as Default"]') + expect(setDefault.attributes('disabled')).toBeDefined() + await setDefault.trigger('click') + expect(apiService.updateRootFolder).not.toHaveBeenCalled() + }) + + it('keeps ordinary retry separate for an authorized relocation', async () => { + vi.mocked(apiService.getRootFolders).mockResolvedValue([rootFolder(relocation('Authorized'))]) + const pinia = createReadyPinia() + const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) + await flushPromises() + + expect(wrapper.find('[data-cy="confirm-root-folder"]').exists()).toBe(false) + expect(wrapper.findAll('button').some((button) => button.text().trim() === 'Retry')).toBe(true) + }) + + it('fails closed when the target identity is unavailable', async () => { + vi.mocked(apiService.getRootFolders).mockResolvedValue([rootFolder(relocation('Unavailable'))]) + const pinia = createReadyPinia() + const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) + await flushPromises() + + expect(wrapper.find('[data-cy="confirm-root-folder"]').exists()).toBe(false) + expect(wrapper.findAll('button').some((button) => button.text().trim() === 'Retry')).toBe(false) + }) }) diff --git a/fe/src/__tests__/SettingsView.spec.ts b/fe/src/__tests__/SettingsView.spec.ts index aa7b3e360..a700d9ba7 100644 --- a/fe/src/__tests__/SettingsView.spec.ts +++ b/fe/src/__tests__/SettingsView.spec.ts @@ -61,6 +61,7 @@ vi.mock('@/services/api', () => ({ describe('SettingsView', () => { type SetupState = { showPassword?: { value: boolean } | boolean } type Settings = { + version?: number adminPassword?: string useUsProxy?: boolean usProxyHost?: string @@ -150,7 +151,12 @@ describe('SettingsView', () => { // Note: legacy "Prefer US domain" setting was removed from the UI; // related tests removed to reflect current application state. - it('applies child updates (via events) to settings and includes them when saving', async () => { + it('preserves the loaded concurrency version and saves child updates exactly once', async () => { + ;(apiService.getApplicationSettings as Mock).mockResolvedValue({ + version: 7, + folderNamingPattern: '{Author}/{Series}/{Title}', + fileNamingPattern: '{Title}', + }) const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', name: 'home', component: { template: '
' } }], @@ -173,13 +179,9 @@ describe('SettingsView', () => { await generalTab!.trigger('click') const vm = wrapper.vm as unknown as { settings?: Settings } - vm.settings = { - folderNamingPattern: '{Author}/{Series}/{Title}', - fileNamingPattern: '{Title}', - } as unknown as Settings - - await wrapper.vm.$nextTick() - await new Promise((r) => setTimeout(r, 0)) + await vi.waitFor(() => { + expect(vm.settings?.version).toBe(7) + }) // Find the File Naming Pattern input inside the child and change it const fileNamingInput = wrapper.find('input[placeholder="{Title}"]') @@ -190,7 +192,10 @@ describe('SettingsView', () => { // Spy on the configuration store save method const { useConfigurationStore } = await import('@/stores/configuration') const cfgStore = useConfigurationStore() - cfgStore.saveApplicationSettings = vi.fn().mockResolvedValue(undefined) + cfgStore.saveApplicationSettings = vi.fn().mockImplementation(async (payload) => ({ + ...payload, + version: 8, + })) // Save settings and assert that the updated value from the child is included const saveBtn = wrapper @@ -199,9 +204,70 @@ describe('SettingsView', () => { expect(saveBtn).toBeTruthy() await saveBtn!.trigger('click') - expect(cfgStore.saveApplicationSettings).toHaveBeenCalled() + expect(cfgStore.saveApplicationSettings).toHaveBeenCalledTimes(1) const calledWith = (cfgStore.saveApplicationSettings as Mock).mock.calls[0][0] expect(calledWith.fileNamingPattern).toBe('{Title}-{DiskNumber}') + expect(calledWith.version).toBe(7) + await vi.waitFor(() => { + expect(vm.settings?.version).toBe(8) + }) + }) + + it('reloads the authoritative version after a failed settings save', async () => { + ;(apiService.getApplicationSettings as Mock).mockResolvedValue({ + version: 7, + folderNamingPattern: '{Author}/{Series}/{Title}', + fileNamingPattern: '{Title}', + }) + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', name: 'home', component: { template: '
' } }], + }) + await router.push('/') + await router.isReady().catch(() => {}) + + const pinia = createPinia() + setActivePinia(pinia) + const wrapper = mount(SettingsView, { + global: { plugins: [pinia, router], stubs: ['FolderBrowser'] }, + }) + + const generalTab = wrapper + .findAll('button.tab-button') + .find((b) => b.text().includes('General Settings')) + expect(generalTab).toBeTruthy() + await generalTab!.trigger('click') + + const vm = wrapper.vm as unknown as { settings?: Settings } + await vi.waitFor(() => { + expect(vm.settings?.version).toBe(7) + }) + + const { useConfigurationStore } = await import('@/stores/configuration') + const cfgStore = useConfigurationStore() + cfgStore.saveApplicationSettings = vi + .fn() + .mockRejectedValue(new Error('admin provisioning failed')) + cfgStore.loadApplicationSettings = vi.fn(async () => { + const reloaded = { + version: 8, + folderNamingPattern: '{Author}/{Series}/{Title}', + fileNamingPattern: '{Title}', + } as never + cfgStore.applicationSettings = reloaded + return reloaded + }) + + const saveBtn = wrapper + .findAll('button.btn.btn-primary') + .find((b) => b.text().includes('Save Settings')) + expect(saveBtn).toBeTruthy() + await saveBtn!.trigger('click') + + await vi.waitFor(() => { + expect(cfgStore.loadApplicationSettings).toHaveBeenCalledTimes(1) + expect(vm.settings?.version).toBe(8) + }) }) it('toggles download client enabled state', async () => { diff --git a/fe/src/__tests__/UnmatchedFilesModal.spec.ts b/fe/src/__tests__/UnmatchedFilesModal.spec.ts new file mode 100644 index 000000000..2d9c1498a --- /dev/null +++ b/fe/src/__tests__/UnmatchedFilesModal.spec.ts @@ -0,0 +1,114 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { describe, expect, it, vi } from 'vitest' +import UnmatchedFilesModal from '@/components/feedback/UnmatchedFilesModal.vue' +import { useFilesystemReadinessStore } from '@/stores/filesystemReadiness' +import { apiService } from '@/services/api' +import type { RootFolder } from '@/types' + +vi.mock('@/services/api', () => ({ + apiService: { + getSavedUnmatchedFiles: vi.fn().mockResolvedValue({ + items: [ + { + fullPath: 'C:\\library\\Book\\01.m4b', + bookFolder: 'C:\\library\\Book', + relativePath: 'Book', + title: 'Book', + author: 'Author', + asin: 'B000000001', + fileCount: 1, + format: 'M4B', + }, + ], + lastScannedAt: null, + }), + getApplicationSettings: vi.fn().mockResolvedValue({ completedFileAction: 'copy' }), + getRootFolders: vi.fn().mockResolvedValue([]), + scanUnmatchedFiles: vi.fn(), + getUnmatchedResults: vi.fn(), + getAudibleMetadata: vi.fn(), + addToLibrary: vi.fn(), + startManualImport: vi.fn(), + }, +})) + +vi.mock('@/services/signalr', () => ({ + signalRService: { + onUnmatchedScanComplete: vi.fn(() => () => undefined), + }, +})) + +vi.mock('@/services/toastService', () => ({ + useToast: () => ({ + success: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }), +})) + +describe('UnmatchedFilesModal filesystem readiness', () => { + it('keeps cached results visible but disables scan and import actions while initializing', async () => { + const pinia = createPinia() + setActivePinia(pinia) + useFilesystemReadinessStore().readiness = { + isReady: true, + status: 'ready', + databaseConnected: true, + migrationsCurrent: true, + errorCode: null, + filesystemReady: false, + filesystemStatus: 'Running', + filesystemPhase: 'AudiobookFileIdentities', + filesystemErrorCode: null, + filesystemErrorMessage: null, + } + const rootFolder = { + id: 7, + name: 'Library', + path: 'C:\\library', + isDefault: true, + storageState: 'Initializing', + canMutateFilesystem: false, + } as unknown as RootFolder + + const wrapper = mount(UnmatchedFilesModal, { + props: { isOpen: false, rootFolder }, + attachTo: document.body, + global: { + plugins: [pinia], + stubs: { + AddLibraryModal: true, + }, + }, + }) + await wrapper.setProps({ isOpen: true }) + await flushPromises() + + expect(document.body.textContent).toContain('Book') + const buttons = Array.from(document.body.querySelectorAll('button')) + const add = buttons.find((button) => button.textContent?.trim() === 'Add') + const addAll = buttons.find((button) => button.textContent?.includes('Add All')) + const scan = buttons.find((button) => button.textContent?.trim() === 'Scan') + expect(add).toBeTruthy() + expect(addAll).toBeTruthy() + expect(scan).toBeTruthy() + expect(add!.disabled).toBe(true) + expect(addAll!.disabled).toBe(true) + expect(scan!.disabled).toBe(true) + + scan!.click() + expect(apiService.scanUnmatchedFiles).not.toHaveBeenCalled() + expect(document.body.querySelector('add-library-modal-stub')).toBeNull() + wrapper.unmount() + }) +}) diff --git a/fe/src/__tests__/bulkEditOrchestration.spec.ts b/fe/src/__tests__/bulkEditOrchestration.spec.ts new file mode 100644 index 000000000..b5d0edbb2 --- /dev/null +++ b/fe/src/__tests__/bulkEditOrchestration.spec.ts @@ -0,0 +1,306 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { executeBulkEdit } from '@/utils/bulkEditOrchestration' + +function createDependencies() { + return { + bulkUpdateAudiobooks: vi.fn(async (ids: number[]) => ({ + message: 'updated', + results: ids.map((id) => ({ + id, + success: true, + metadataUpdated: true, + pathChangeOutcome: 'enqueued', + moveJobId: `job-${id}`, + resolvedDestination: `/library-new/Book ${id}`, + errors: [] as string[], + })), + })), + trackQueuedJob: vi.fn(), + } +} + +describe('bulk edit orchestration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('sends one backend-owned physical path-change request and registers returned jobs', async () => { + const dependencies = createDependencies() + const destinationRoot = '/library-new ' + dependencies.bulkUpdateAudiobooks.mockResolvedValueOnce({ + message: 'updated', + results: [ + { + id: 1, + success: true, + metadataUpdated: true, + pathChangeOutcome: 'enqueued', + moveJobId: 'job-1', + resolvedDestination: '/library-new /Book 1 ', + errors: [], + }, + { + id: 2, + success: true, + metadataUpdated: true, + pathChangeOutcome: 'enqueued', + moveJobId: 'job-2', + resolvedDestination: '/library-new /Book 2 ', + errors: [], + }, + ], + }) + + const outcome = await executeBulkEdit( + { + ids: [1, 2], + updates: { + monitored: true, + rootFolder: destinationRoot, + moveFiles: true, + deleteEmptySource: true, + }, + destinationRoot, + moveFiles: true, + deleteEmptySource: true, + }, + dependencies, + ) + + expect(dependencies.bulkUpdateAudiobooks).toHaveBeenCalledTimes(1) + expect(dependencies.bulkUpdateAudiobooks).toHaveBeenCalledWith( + [1, 2], + { monitored: true }, + { + mode: 'Physical', + destinationRootOrPath: destinationRoot, + deleteEmptySource: true, + }, + ) + expect(dependencies.trackQueuedJob).toHaveBeenNthCalledWith(1, { + jobId: 'job-1', + audiobookId: 1, + target: '/library-new /Book 1 ', + }) + expect(dependencies.trackQueuedJob).toHaveBeenNthCalledWith(2, { + jobId: 'job-2', + audiobookId: 2, + target: '/library-new /Book 2 ', + }) + expect(outcome.results.every((result) => result.success)).toBe(true) + }) + + it.each([ + ['missing', undefined, 'The server did not return a path-change outcome.'], + ['unknown', 'queued-sometime', 'The server returned an unrecognized path-change outcome.'], + ])('fails closed for a %s physical path-change outcome', async (_, pathChangeOutcome, error) => { + const dependencies = createDependencies() + dependencies.bulkUpdateAudiobooks.mockResolvedValueOnce({ + message: 'updated', + results: [ + { + id: 1, + success: true, + metadataUpdated: true, + pathChangeOutcome, + moveJobId: 'job-1', + resolvedDestination: '/library-new/Book 1', + errors: [], + }, + ], + }) + + const outcome = await executeBulkEdit( + { + ids: [1], + updates: {}, + destinationRoot: '/library-new', + moveFiles: true, + deleteEmptySource: false, + }, + dependencies, + ) + + expect(outcome.results[0]).toEqual( + expect.objectContaining({ + id: 1, + success: false, + errors: expect.arrayContaining([error]), + }), + ) + expect(dependencies.trackQueuedJob).not.toHaveBeenCalled() + }) + + it('fails closed when a valid outcome does not match the requested operation', async () => { + const dependencies = createDependencies() + dependencies.bulkUpdateAudiobooks.mockResolvedValueOnce({ + message: 'updated', + results: [ + { + id: 1, + success: true, + metadataUpdated: true, + pathChangeOutcome: 'metadata-updated', + moveJobId: 'job-1', + resolvedDestination: '/library-new/Book 1', + errors: [], + }, + ], + }) + + const outcome = await executeBulkEdit( + { + ids: [1], + updates: {}, + destinationRoot: '/library-new', + moveFiles: true, + deleteEmptySource: false, + }, + dependencies, + ) + + expect(outcome.results[0]).toEqual( + expect.objectContaining({ + id: 1, + success: false, + errors: expect.arrayContaining([ + 'The server returned a path-change outcome that does not match the request.', + ]), + }), + ) + expect(dependencies.trackQueuedJob).not.toHaveBeenCalled() + }) + + it('surfaces backend per-item move failures without registering failed jobs', async () => { + const dependencies = createDependencies() + dependencies.bulkUpdateAudiobooks.mockResolvedValueOnce({ + message: 'updated', + results: [ + { + id: 1, + success: true, + metadataUpdated: true, + pathChangeOutcome: 'enqueued', + moveJobId: 'job-1', + resolvedDestination: '/library-new/Book 1', + errors: [], + }, + { + id: 2, + success: false, + metadataUpdated: true, + pathChangeOutcome: 'failed', + moveJobId: null, + resolvedDestination: '/library-new/Book 2', + errors: ['queue unavailable'], + }, + ], + }) + + const outcome = await executeBulkEdit( + { + ids: [1, 2], + updates: { rootFolder: '/library-new', moveFiles: true }, + destinationRoot: '/library-new', + moveFiles: true, + deleteEmptySource: false, + }, + dependencies, + ) + + expect(outcome.results[1]).toEqual( + expect.objectContaining({ id: 2, success: false, errors: ['queue unavailable'] }), + ) + expect(dependencies.trackQueuedJob).toHaveBeenCalledTimes(1) + expect(dependencies.trackQueuedJob).not.toHaveBeenCalledWith( + expect.objectContaining({ audiobookId: 2 }), + ) + }) + + it('fails closed when a successful physical result omits its durable job id', async () => { + const dependencies = createDependencies() + dependencies.bulkUpdateAudiobooks.mockResolvedValueOnce({ + message: 'updated', + results: [ + { + id: 1, + success: true, + metadataUpdated: true, + pathChangeOutcome: 'enqueued', + moveJobId: null, + resolvedDestination: '/library-new/Book 1', + errors: [], + }, + ], + }) + + const outcome = await executeBulkEdit( + { + ids: [1], + updates: {}, + destinationRoot: '/library-new', + moveFiles: true, + deleteEmptySource: false, + }, + dependencies, + ) + + expect(outcome.results[0]).toEqual( + expect.objectContaining({ + id: 1, + success: false, + errors: ['The server did not return a durable move job ID.'], + }), + ) + expect(dependencies.trackQueuedJob).not.toHaveBeenCalled() + }) + + it('uses typed metadata-only path changes and does not register move jobs', async () => { + const dependencies = createDependencies() + dependencies.bulkUpdateAudiobooks.mockResolvedValueOnce({ + message: 'updated', + results: [ + { + id: 1, + success: true, + metadataUpdated: true, + pathChangeOutcome: 'metadata-updated', + moveJobId: null, + resolvedDestination: '/library-new/Book 1', + errors: [], + }, + ], + }) + + await executeBulkEdit( + { + ids: [1], + updates: { monitored: false, rootFolder: '/library-new' }, + destinationRoot: '/library-new', + moveFiles: false, + deleteEmptySource: false, + }, + dependencies, + ) + + expect(dependencies.bulkUpdateAudiobooks).toHaveBeenCalledWith( + [1], + { monitored: false }, + { + mode: 'MetadataOnly', + destinationRootOrPath: '/library-new', + deleteEmptySource: false, + }, + ) + expect(dependencies.trackQueuedJob).not.toHaveBeenCalled() + }) +}) diff --git a/fe/src/__tests__/debug_AddNew.spec.ts b/fe/src/__tests__/debug_AddNew.spec.ts deleted file mode 100644 index 932f46770..000000000 --- a/fe/src/__tests__/debug_AddNew.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Listenarr - Audiobook Management System - * Copyright (C) 2024-2026 Listenarr Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -import { describe } from 'vitest' - -describe.todo('debug AddNew placeholder') diff --git a/fe/src/__tests__/filesystemReadiness.store.spec.ts b/fe/src/__tests__/filesystemReadiness.store.spec.ts new file mode 100644 index 000000000..bbf7c32e7 --- /dev/null +++ b/fe/src/__tests__/filesystemReadiness.store.spec.ts @@ -0,0 +1,102 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +const apiMocks = vi.hoisted(() => ({ + getSystemReadiness: vi.fn(), +})) + +vi.mock('@/services/api', () => ({ + apiService: { + getSystemReadiness: apiMocks.getSystemReadiness, + }, +})) + +import { useFilesystemReadinessStore } from '@/stores/filesystemReadiness' + +function readiness(filesystemStatus: 'Pending' | 'Running' | 'Ready' | 'Failed') { + return { + isReady: true, + status: 'ready', + databaseConnected: true, + migrationsCurrent: true, + filesystemReady: filesystemStatus === 'Ready', + filesystemStatus, + filesystemPhase: filesystemStatus === 'Running' ? 'AudiobookFileIdentities' : null, + filesystemErrorCode: filesystemStatus === 'Failed' ? 'filesystem_initialization_failed' : null, + filesystemErrorMessage: + filesystemStatus === 'Failed' ? 'Injected filesystem initialization failure.' : null, + } +} + +describe('filesystem readiness store', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + setActivePinia(createPinia()) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('polls while reconciliation is running and stops after Ready', async () => { + apiMocks.getSystemReadiness + .mockResolvedValueOnce(readiness('Running')) + .mockResolvedValueOnce(readiness('Ready')) + const store = useFilesystemReadinessStore() + + store.start() + await vi.waitFor(() => expect(store.filesystemStatus).toBe('Running')) + + expect(store.filesystemReady).toBe(false) + expect(store.filesystemInitializing).toBe(true) + expect(apiMocks.getSystemReadiness).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1500) + await vi.waitFor(() => expect(store.filesystemStatus).toBe('Ready')) + + expect(store.filesystemReady).toBe(true) + expect(store.filesystemInitializing).toBe(false) + expect(apiMocks.getSystemReadiness).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(5000) + expect(apiMocks.getSystemReadiness).toHaveBeenCalledTimes(2) + }) + + it('stops polling and exposes failure details after Failed', async () => { + apiMocks.getSystemReadiness.mockResolvedValue(readiness('Failed')) + const store = useFilesystemReadinessStore() + + store.start() + await vi.waitFor(() => expect(store.filesystemStatus).toBe('Failed')) + + expect(store.filesystemReady).toBe(false) + expect(store.filesystemFailed).toBe(true) + expect(store.readiness?.filesystemErrorCode).toBe('filesystem_initialization_failed') + + await vi.advanceTimersByTimeAsync(5000) + expect(apiMocks.getSystemReadiness).toHaveBeenCalledTimes(1) + }) + + it('stop prevents a queued poll from running', async () => { + apiMocks.getSystemReadiness.mockResolvedValue(readiness('Running')) + const store = useFilesystemReadinessStore() + + store.start() + await vi.waitFor(() => expect(apiMocks.getSystemReadiness).toHaveBeenCalledTimes(1)) + store.stop() + + await vi.advanceTimersByTimeAsync(5000) + expect(apiMocks.getSystemReadiness).toHaveBeenCalledTimes(1) + }) +}) diff --git a/fe/src/__tests__/import-activity.spec.ts b/fe/src/__tests__/import-activity.spec.ts index 0f3697ea5..efda0b4c8 100644 --- a/fe/src/__tests__/import-activity.spec.ts +++ b/fe/src/__tests__/import-activity.spec.ts @@ -89,5 +89,5 @@ describe('import checks', () => { const mod = await import('@/views/ActivityView.vue') expect(mod).toBeTruthy() - }, 20000) + }) }) diff --git a/fe/src/__tests__/library.deleteOperations.spec.ts b/fe/src/__tests__/library.deleteOperations.spec.ts new file mode 100644 index 000000000..20bbd84a6 --- /dev/null +++ b/fe/src/__tests__/library.deleteOperations.spec.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import type { Audiobook } from '@/types' +import { apiService } from '@/services/api' +import { useLibraryStore } from '@/stores/library' +import { useLibraryDeleteOperationsStore } from '@/stores/libraryDeleteOperations' + +vi.mock('@/services/signalr', () => ({ + signalRService: { + onFilesRemoved: vi.fn(() => () => undefined), + onAudiobookUpdate: vi.fn(() => () => undefined), + }, +})) + +vi.mock('@/services/api', () => ({ + apiService: { + removeFromLibrary: vi.fn(), + }, +})) + +const removeFromLibraryMock = vi.mocked(apiService.removeFromLibrary) + +function audiobook(id: number, title: string): Audiobook { + return { id, title } as Audiobook +} + +describe('library delete notification operations', () => { + beforeEach(() => { + setActivePinia(createPinia()) + removeFromLibraryMock.mockReset() + }) + + it('tracks an individual deletion while the request is in flight', async () => { + let completeRequest!: (value: { message: string; id: number }) => void + removeFromLibraryMock.mockImplementation( + () => + new Promise((resolve) => { + completeRequest = resolve + }), + ) + const libraryStore = useLibraryStore() + const operationsStore = useLibraryDeleteOperationsStore() + libraryStore.audiobooks = [audiobook(42, 'Slow Delete')] + + const deletion = libraryStore.removeFromLibrary(42) + + expect(operationsStore.operations).toHaveLength(1) + expect(operationsStore.operations[0]).toMatchObject({ + kind: 'single', + title: 'Slow Delete', + audiobookId: 42, + status: 'deleting', + progress: 35, + }) + + completeRequest({ message: 'deleted', id: 42 }) + await expect(deletion).resolves.toBe(true) + + expect(operationsStore.operations[0]).toMatchObject({ + status: 'completed', + progress: 100, + processed: 1, + deleted: 1, + }) + expect(libraryStore.audiobooks).toEqual([]) + }) + + it('keeps an interrupted bulk operation failed until every item is processed', () => { + const operationsStore = useLibraryDeleteOperationsStore() + const operationId = operationsStore.beginBulk(3) + + operationsStore.setBulkCurrentItem(operationId, 'First') + operationsStore.updateBulkItem(operationId, 'First', true) + operationsStore.setBulkCurrentItem(operationId, 'Second') + operationsStore.finishBulk(operationId) + + expect(operationsStore.operations[0]).toMatchObject({ + status: 'failed', + total: 3, + processed: 1, + deleted: 1, + failed: 0, + currentTitle: 'Second', + }) + expect(operationsStore.operations[0]?.progress).toBeCloseTo(100 / 3) + }) + + it('keeps active deletes visible while allowing finished notifications to be dismissed or cleared', () => { + const operationsStore = useLibraryDeleteOperationsStore() + const activeId = operationsStore.beginSingle(1, 'Active') + const completedId = operationsStore.beginSingle(2, 'Completed') + operationsStore.completeSingle(completedId) + const failedId = operationsStore.beginSingle(3, 'Failed') + operationsStore.failSingle(failedId, 'Blocked') + + operationsStore.dismiss(activeId) + operationsStore.dismiss(completedId) + + expect( + operationsStore.operations.find((operation) => operation.id === activeId)?.dismissed, + ).toBe(false) + expect( + operationsStore.operations.find((operation) => operation.id === completedId)?.dismissed, + ).toBe(true) + + operationsStore.clearFinished() + + expect(operationsStore.operations).toHaveLength(1) + expect(operationsStore.operations[0]?.id).toBe(activeId) + }) + + it('never evicts active delete notifications just to enforce the history cap', () => { + const operationsStore = useLibraryDeleteOperationsStore() + + for (let index = 0; index < 55; index += 1) { + operationsStore.beginSingle(index + 1, `Active ${index + 1}`) + } + + expect(operationsStore.operations).toHaveLength(55) + expect(operationsStore.operations.every((operation) => operation.status === 'deleting')).toBe( + true, + ) + }) + + it('tracks real aggregate bulk progress and preserves rows whose deletion failed', async () => { + removeFromLibraryMock + .mockResolvedValueOnce({ message: 'deleted', id: 1 }) + .mockRejectedValueOnce(new Error('Delete blocked')) + .mockResolvedValueOnce({ message: 'deleted', id: 3 }) + const libraryStore = useLibraryStore() + const operationsStore = useLibraryDeleteOperationsStore() + libraryStore.audiobooks = [audiobook(1, 'First'), audiobook(2, 'Second'), audiobook(3, 'Third')] + + const result = await libraryStore.bulkRemoveFromLibrary([1, 2, 3]) + + expect(result).toEqual({ success: true, deletedCount: 2 }) + expect(operationsStore.operations).toHaveLength(1) + expect(operationsStore.operations[0]).toMatchObject({ + kind: 'bulk', + status: 'failed', + progress: 100, + total: 3, + processed: 3, + deleted: 2, + failed: 1, + currentTitle: 'Third', + error: 'Delete blocked', + }) + expect(libraryStore.audiobooks.map((book) => book.id)).toEqual([2]) + }) +}) diff --git a/fe/src/__tests__/manualImport.contract.spec.ts b/fe/src/__tests__/manualImport.contract.spec.ts new file mode 100644 index 000000000..7871c9abd --- /dev/null +++ b/fe/src/__tests__/manualImport.contract.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import type { Audiobook, ManualImportResult } from '@/types' + +describe('manual import result contract', () => { + it('matches the backend result shape', () => { + const audiobook = { id: 42, title: 'Recovered Book' } as Audiobook + const result = { + success: true, + sourcePath: '/incoming/book.m4b', + destinationPath: '/library/book/book.m4b', + audiobook, + skipped: false, + } satisfies ManualImportResult + + expect(result.sourcePath).toBe('/incoming/book.m4b') + expect(result.destinationPath).toBe('/library/book/book.m4b') + expect(result.audiobook.id).toBe(42) + }) +}) diff --git a/fe/src/__tests__/moveJobs.store.spec.ts b/fe/src/__tests__/moveJobs.store.spec.ts new file mode 100644 index 000000000..acd14d7d7 --- /dev/null +++ b/fe/src/__tests__/moveJobs.store.spec.ts @@ -0,0 +1,490 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +type MoveJobUpdate = { + jobId?: string + audiobookId?: number + status?: string + progress?: number + phase?: string + target?: string + error?: string +} + +const toastMocks = vi.hoisted(() => ({ + info: vi.fn(), + success: vi.fn(), + error: vi.fn(), +})) + +const apiMocks = vi.hoisted(() => ({ + getActiveMoveJobs: vi.fn(), + getMoveJobStatus: vi.fn(), +})) + +const signalRMocks = vi.hoisted(() => { + const state = { + callback: null as ((job: MoveJobUpdate) => void) | null, + unsubscribe: vi.fn(), + onMoveJobUpdate: vi.fn(), + } + state.onMoveJobUpdate.mockImplementation((callback: (job: MoveJobUpdate) => void) => { + state.callback = callback + return state.unsubscribe + }) + return state +}) + +vi.mock('@/services/api', () => ({ + apiService: { + getActiveMoveJobs: apiMocks.getActiveMoveJobs, + getMoveJobStatus: apiMocks.getMoveJobStatus, + }, +})) + +vi.mock('@/services/toastService', () => ({ + useToast: () => toastMocks, +})) + +vi.mock('@/services/signalr', () => ({ + signalRService: { + onMoveJobUpdate: signalRMocks.onMoveJobUpdate, + }, +})) + +import { useMoveJobsStore } from '@/stores/moveJobs' + +describe('move jobs store', () => { + beforeEach(() => { + vi.clearAllMocks() + setActivePinia(createPinia()) + signalRMocks.callback = null + apiMocks.getActiveMoveJobs.mockResolvedValue([]) + apiMocks.getMoveJobStatus.mockImplementation(() => new Promise(() => {})) + signalRMocks.onMoveJobUpdate.mockImplementation((callback: (job: MoveJobUpdate) => void) => { + signalRMocks.callback = callback + return signalRMocks.unsubscribe + }) + }) + + it('starts SignalR subscription idempotently', () => { + const store = useMoveJobsStore() + + store.start() + store.start() + + expect(signalRMocks.onMoveJobUpdate).toHaveBeenCalledTimes(1) + + store.stop() + expect(signalRMocks.unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('recovers active move jobs when the store starts', async () => { + apiMocks.getActiveMoveJobs.mockResolvedValue([ + { + jobId: 'job-active', + audiobookId: 42, + status: 'Running', + progress: 61.5, + phase: 'Copying', + target: '/library/book', + }, + ]) + const store = useMoveJobsStore() + + store.start() + + await vi.waitFor(() => + expect(store.trackedById['job-active']).toMatchObject({ + status: 'Running', + progress: 61.5, + phase: 'Copying', + }), + ) + }) + + it('recovers a missed terminal update when a tracked job disappears from the active snapshot', async () => { + apiMocks.getActiveMoveJobs + .mockResolvedValueOnce([ + { + jobId: 'job-1', + audiobookId: 42, + status: 'Running', + progress: 40, + target: '/library/book', + }, + ]) + .mockResolvedValueOnce([]) + apiMocks.getMoveJobStatus.mockResolvedValue({ + jobId: 'job-1', + audiobookId: 42, + status: 'Completed', + progress: 100, + target: '/library/book', + }) + const store = useMoveJobsStore() + + store.start() + await vi.waitFor(() => expect(store.trackedById['job-1']?.status).toBe('Running')) + + await store.loadActiveJobs() + + expect(apiMocks.getMoveJobStatus).toHaveBeenCalledWith('job-1') + expect(toastMocks.success).toHaveBeenCalledWith( + 'Move completed', + 'Files moved to /library/book', + ) + expect(store.trackedById['job-1']).toBeUndefined() + }) + + it('preserves a job when a newer status read still reports it active', async () => { + apiMocks.getActiveMoveJobs + .mockResolvedValueOnce([ + { + jobId: 'job-1', + audiobookId: 42, + status: 'Running', + progress: 40, + target: '/library/book', + }, + ]) + .mockResolvedValueOnce([]) + apiMocks.getMoveJobStatus.mockResolvedValue({ + jobId: 'job-1', + audiobookId: 42, + status: 'Running', + progress: 55, + target: '/library/book', + }) + const store = useMoveJobsStore() + + store.start() + await vi.waitFor(() => expect(store.trackedById['job-1']?.status).toBe('Running')) + + await store.loadActiveJobs() + + expect(store.trackedById['job-1']?.status).toBe('Running') + expect(toastMocks.success).not.toHaveBeenCalled() + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('does not resurrect a terminal job from an older in-flight active snapshot', async () => { + let resolveActive: + | (( + jobs: Array<{ + jobId: string + audiobookId: number + status: string + progress: number + target: string + }>, + ) => void) + | undefined + apiMocks.getActiveMoveJobs.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveActive = resolve + }), + ) + const store = useMoveJobsStore() + store.trackQueuedJob({ jobId: 'job-1', audiobookId: 42, target: '/library/book' }) + + const refresh = store.loadActiveJobs() + signalRMocks.callback?.({ + jobId: 'job-1', + audiobookId: 42, + status: 'Completed', + progress: 100, + target: '/library/book', + }) + resolveActive?.([ + { + jobId: 'job-1', + audiobookId: 42, + status: 'Running', + progress: 75, + target: '/library/book', + }, + ]) + await refresh + + expect(store.trackedById['job-1']).toBeUndefined() + expect(toastMocks.success).toHaveBeenCalledTimes(1) + }) + + it('ignores an older active snapshot when a newer refresh finishes first', async () => { + let resolveOlder: + | (( + jobs: Array<{ + jobId: string + audiobookId: number + status: string + progress: number + target: string + }>, + ) => void) + | undefined + apiMocks.getActiveMoveJobs + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOlder = resolve + }), + ) + .mockResolvedValueOnce([]) + const store = useMoveJobsStore() + + const olderRefresh = store.loadActiveJobs() + await store.loadActiveJobs() + resolveOlder?.([ + { + jobId: 'job-old', + audiobookId: 42, + status: 'Running', + progress: 50, + target: '/library/book', + }, + ]) + await olderRefresh + + expect(store.trackedById['job-old']).toBeUndefined() + }) + + it('does not prune a newly tracked job from an older in-flight active snapshot', async () => { + let resolveActive: ((jobs: never[]) => void) | undefined + apiMocks.getActiveMoveJobs.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveActive = resolve + }), + ) + const store = useMoveJobsStore() + + const refresh = store.loadActiveJobs() + store.trackQueuedJob({ jobId: 'job-new', target: '/library/new' }) + resolveActive?.([]) + await refresh + + expect(store.trackedById['job-new']?.status).toBe('Queued') + }) + + it('tracks queued move jobs and subscribes on first track', () => { + const store = useMoveJobsStore() + + store.trackQueuedJob({ + jobId: 'JOB-1', + audiobookId: 42, + target: '/library/book', + }) + + expect(signalRMocks.onMoveJobUpdate).toHaveBeenCalledTimes(1) + expect(store.trackedById['job-1']).toEqual({ + jobId: 'JOB-1', + audiobookId: 42, + status: 'Queued', + progress: 0, + target: '/library/book', + }) + }) + + it('shows one in-progress toast when a tracked job starts running', () => { + const store = useMoveJobsStore() + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + + signalRMocks.callback?.({ jobId: 'job-1', status: 'Running', target: '/library/book' }) + signalRMocks.callback?.({ jobId: 'job-1', status: 'Running', target: '/library/book' }) + + expect(toastMocks.info).toHaveBeenCalledTimes(1) + expect(toastMocks.info).toHaveBeenCalledWith( + 'Move in progress', + 'Moving files to /library/book', + ) + expect(store.trackedById['job-1']?.status).toBe('Running') + }) + + it('tracks realtime move progress and phase without repeating the running toast', () => { + const store = useMoveJobsStore() + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + + signalRMocks.callback?.({ + jobId: 'job-1', + status: 'Running', + progress: 18.5, + phase: 'Copying', + target: '/library/book', + }) + signalRMocks.callback?.({ + jobId: 'job-1', + status: 'Running', + progress: 63.25, + phase: 'Copying', + target: '/library/book', + }) + + expect(store.trackedById['job-1']).toMatchObject({ + status: 'Running', + progress: 63.25, + phase: 'Copying', + }) + expect(toastMocks.info).toHaveBeenCalledTimes(1) + }) + + it('shows success toast and clears tracked job on completion', () => { + const store = useMoveJobsStore() + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + + signalRMocks.callback?.({ jobId: 'job-1', status: 'Completed', target: '/library/book' }) + + expect(toastMocks.success).toHaveBeenCalledWith( + 'Move completed', + 'Files moved to /library/book', + ) + expect(store.trackedById['job-1']).toBeUndefined() + }) + + it('shows attention toast and clears tracked job on NeedsAttention', () => { + const store = useMoveJobsStore() + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + + signalRMocks.callback?.({ + jobId: 'job-1', + status: 'NeedsAttention', + target: '/library/book', + error: 'Manual review required', + }) + + expect(toastMocks.error).toHaveBeenCalledWith('Move needs attention', 'Manual review required') + expect(store.trackedById['job-1']).toBeUndefined() + }) + + it('reconciles a job that completed before tracking began', async () => { + apiMocks.getMoveJobStatus.mockResolvedValue({ + jobId: 'job-1', + status: 'Completed', + target: '/library/book', + }) + const store = useMoveJobsStore() + + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + + await vi.waitFor(() => expect(store.trackedById['job-1']).toBeUndefined()) + expect(toastMocks.success).toHaveBeenCalledWith( + 'Move completed', + 'Files moved to /library/book', + ) + }) + + it('does not recreate a terminal job when a stale status response arrives later', async () => { + let resolveStatus: + | ((value: { jobId: string; status: string; target: string }) => void) + | undefined + apiMocks.getMoveJobStatus.mockImplementation( + () => + new Promise((resolve) => { + resolveStatus = resolve + }), + ) + const store = useMoveJobsStore() + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + + signalRMocks.callback?.({ jobId: 'job-1', status: 'Completed', target: '/library/book' }) + resolveStatus?.({ jobId: 'job-1', status: 'Queued', target: '/library/book' }) + await Promise.resolve() + + expect(store.trackedById['job-1']).toBeUndefined() + expect(toastMocks.success).toHaveBeenCalledTimes(1) + }) + + it('does not regress a running job when a stale queued status response arrives', async () => { + let resolveStatus: + | ((value: { jobId: string; status: string; target: string }) => void) + | undefined + apiMocks.getMoveJobStatus.mockImplementation( + () => + new Promise((resolve) => { + resolveStatus = resolve + }), + ) + const store = useMoveJobsStore() + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + + signalRMocks.callback?.({ jobId: 'job-1', status: 'Running', target: '/library/book' }) + resolveStatus?.({ jobId: 'job-1', status: 'Queued', target: '/library/book' }) + await Promise.resolve() + + expect(store.trackedById['job-1']?.status).toBe('Running') + expect(toastMocks.info).toHaveBeenCalledTimes(1) + }) + + it('ignores an unknown realtime status instead of regressing a running job', () => { + const store = useMoveJobsStore() + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + signalRMocks.callback?.({ jobId: 'job-1', status: 'Running', target: '/library/book' }) + + signalRMocks.callback?.({ jobId: 'job-1', status: 'Paused', target: '/library/book' }) + + expect(store.trackedById['job-1']?.status).toBe('Running') + expect(toastMocks.info).toHaveBeenCalledTimes(1) + expect(toastMocks.success).not.toHaveBeenCalled() + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('ignores an unknown reconciliation status', async () => { + apiMocks.getMoveJobStatus.mockResolvedValue({ + jobId: 'job-1', + status: 'Paused', + target: '/library/book', + }) + const store = useMoveJobsStore() + + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + + await vi.waitFor(() => expect(apiMocks.getMoveJobStatus).toHaveBeenCalledWith('job-1')) + expect(store.trackedById['job-1']?.status).toBe('Queued') + expect(toastMocks.info).not.toHaveBeenCalled() + expect(toastMocks.success).not.toHaveBeenCalled() + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('keeps tracking when status reconciliation fails', async () => { + apiMocks.getMoveJobStatus.mockRejectedValue(new Error('offline')) + const store = useMoveJobsStore() + + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + + await vi.waitFor(() => expect(apiMocks.getMoveJobStatus).toHaveBeenCalledWith('job-1')) + expect(store.trackedById['job-1']?.status).toBe('Queued') + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('shows informational terminal toast and clears tracked job on Superseded', () => { + const store = useMoveJobsStore() + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + + signalRMocks.callback?.({ jobId: 'job-1', status: 'Superseded', target: '/library/book' }) + + expect(toastMocks.info).toHaveBeenCalledWith( + 'Move superseded', + 'A newer library state replaced this queued move.', + ) + expect(toastMocks.error).not.toHaveBeenCalled() + expect(store.trackedById['job-1']).toBeUndefined() + }) +}) diff --git a/fe/src/__tests__/rootFolders.reauthorization.store.spec.ts b/fe/src/__tests__/rootFolders.reauthorization.store.spec.ts new file mode 100644 index 000000000..c73927283 --- /dev/null +++ b/fe/src/__tests__/rootFolders.reauthorization.store.spec.ts @@ -0,0 +1,290 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { apiService } from '@/services/api' +import { useRootFoldersStore } from '@/stores/rootFolders' + +describe('root folder storage and relocation store actions', () => { + beforeEach(() => { + vi.clearAllMocks() + setActivePinia(createPinia()) + }) + + it('does not let an older load overwrite a newer root-folder snapshot', async () => { + const older = { + id: 3, + name: 'Library', + path: '/srv/Old', + isDefault: false, + caseSensitivityMode: 'Auto' as const, + } + const newer = { ...older, path: '/srv/New' } + let resolveOlder: ((folders: (typeof older)[]) => void) | undefined + vi.mocked(apiService.getRootFolders) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOlder = resolve + }), + ) + .mockResolvedValueOnce([newer]) + const store = useRootFoldersStore() + + const olderLoad = store.load() + await store.load() + resolveOlder?.([older]) + await olderLoad + + expect(store.folders).toEqual([newer]) + expect(store.loading).toBe(false) + }) + + it('sends the exact current path as the server relocation precondition', async () => { + const current = { + id: 3, + name: 'Library', + path: '/srv/Old', + isDefault: false, + caseSensitivityMode: 'Auto' as const, + } + const updated = { ...current, path: '/srv/New' } + vi.mocked(apiService.changeRootFolderPath).mockResolvedValueOnce({ + relocationId: 'relocation-1', + rootFolderId: 3, + currentPath: current.path, + targetPath: updated.path, + status: 'Pending', + totalJobs: 1, + completedJobs: 0, + targetIdentityEnrollmentState: 'Authorized', + }) + vi.mocked(apiService.getRootFolders).mockResolvedValueOnce([updated]) + const store = useRootFoldersStore() + store.folders = [current] + + await store.update(3, updated, { + expectedCurrentPath: current.path, + pathChangeConfirmed: true, + moveFiles: true, + deleteEmptySource: true, + }) + + expect(apiService.changeRootFolderPath).toHaveBeenCalledWith( + 3, + expect.objectContaining({ + targetPath: updated.path, + expectedCurrentPath: current.path, + }), + ) + }) + + it('routes case-sensitivity changes through metadata-only path migration', async () => { + const current = { + id: 3, + name: 'Library', + path: '/srv/Library', + isDefault: false, + caseSensitivityMode: 'Sensitive' as const, + } + const updated = { + ...current, + name: 'Renamed', + caseSensitivityMode: 'Insensitive' as const, + } + vi.mocked(apiService.changeRootFolderPath).mockResolvedValueOnce({ + relocationId: null, + rootFolderId: 3, + currentPath: current.path, + targetPath: current.path, + status: 'Completed', + totalJobs: 0, + completedJobs: 0, + targetIdentityEnrollmentState: 'Authorized', + }) + vi.mocked(apiService.getRootFolders).mockResolvedValueOnce([updated]) + const store = useRootFoldersStore() + store.folders = [current] + + await store.update(3, updated) + + expect(apiService.updateRootFolder).not.toHaveBeenCalled() + expect(apiService.changeRootFolderPath).toHaveBeenCalledWith(3, { + targetPath: current.path, + mode: 'metadataOnly', + deleteEmptySource: false, + desiredName: updated.name, + desiredIsDefault: false, + targetCaseSensitivityMode: 'Insensitive', + expectedCurrentPath: current.path, + }) + expect(apiService.getRootFolders).toHaveBeenCalledTimes(1) + }) + + it('routes same-path storage-semantics repair through confirmed metadata-only migration', async () => { + const current = { + id: 3, + name: 'Library', + path: '/srv/Library', + isDefault: false, + caseSensitivityMode: 'Auto' as const, + storageState: 'Unavailable' as const, + storageReason: 'FilesystemSemanticsChanged' as const, + } + vi.mocked(apiService.changeRootFolderPath).mockResolvedValueOnce({ + relocationId: null, + rootFolderId: 3, + currentPath: current.path, + targetPath: current.path, + status: 'Completed', + totalJobs: 0, + completedJobs: 0, + targetIdentityEnrollmentState: 'Authorized', + }) + vi.mocked(apiService.getRootFolders).mockResolvedValueOnce([ + { + ...current, + storageState: 'Healthy' as const, + storageReason: 'None' as const, + }, + ]) + const store = useRootFoldersStore() + store.folders = [current] + + await store.update(3, current, { + expectedCurrentPath: current.path, + pathChangeConfirmed: true, + moveFiles: false, + deleteEmptySource: false, + }) + + expect(apiService.updateRootFolder).not.toHaveBeenCalled() + expect(apiService.changeRootFolderPath).toHaveBeenCalledWith(3, { + targetPath: current.path, + mode: 'metadataOnly', + deleteEmptySource: false, + desiredName: current.name, + desiredIsDefault: false, + targetCaseSensitivityMode: 'Auto', + expectedCurrentPath: current.path, + }) + }) + + it('returns metadata-only attention as a successful root repair', async () => { + const current = { + id: 3, + name: 'Library', + path: '/srv/Library', + isDefault: false, + caseSensitivityMode: 'Sensitive' as const, + } + const updated = { + ...current, + caseSensitivityMode: 'Insensitive' as const, + } + vi.mocked(apiService.changeRootFolderPath).mockResolvedValueOnce({ + relocationId: 'relocation-semantics', + rootFolderId: 3, + currentPath: current.path, + targetPath: current.path, + status: 'NeedsAttention', + totalJobs: 0, + completedJobs: 0, + error: + 'The relocation requires attention. Review the affected move jobs and retry after resolving the underlying issue.', + targetIdentityEnrollmentState: 'Authorized', + }) + const updatedWithAttention = { + ...updated, + activeRelocation: { + relocationId: 'relocation-semantics', + rootFolderId: 3, + currentPath: current.path, + targetPath: current.path, + status: 'NeedsAttention' as const, + totalJobs: 1, + completedJobs: 0, + error: 'The relocation requires attention.', + targetIdentityEnrollmentState: 'Authorized' as const, + }, + } + vi.mocked(apiService.getRootFolders).mockResolvedValueOnce([updatedWithAttention]) + const store = useRootFoldersStore() + store.folders = [current] + + await expect(store.update(3, updated)).resolves.toEqual(updatedWithAttention) + + expect(apiService.updateRootFolder).not.toHaveBeenCalled() + expect(apiService.getRootFolders).toHaveBeenCalledTimes(1) + }) + + it('surfaces a synchronous relocation attention result instead of reporting success', async () => { + const current = { + id: 3, + name: 'Library', + path: '/srv/Old', + isDefault: false, + caseSensitivityMode: 'Auto' as const, + } + const updated = { ...current, path: '/srv/New' } + vi.mocked(apiService.changeRootFolderPath).mockResolvedValueOnce({ + relocationId: 'relocation-1', + rootFolderId: 3, + currentPath: current.path, + targetPath: updated.path, + status: 'NeedsAttention', + totalJobs: 1, + completedJobs: 0, + error: + 'The relocation requires attention. Review the affected move jobs and retry after resolving the underlying issue.', + targetIdentityEnrollmentState: 'Authorized', + }) + vi.mocked(apiService.getRootFolders).mockResolvedValueOnce([current]) + const store = useRootFoldersStore() + store.folders = [current] + + await expect( + store.update(3, updated, { + expectedCurrentPath: current.path, + pathChangeConfirmed: true, + moveFiles: true, + deleteEmptySource: true, + }), + ).rejects.toThrow('relocation requires attention') + + expect(apiService.getRootFolders).toHaveBeenCalledTimes(1) + }) + + it('passes the exact path and observation token when confirming a root folder', async () => { + const current = { + id: 3, + name: 'Library', + path: '/srv/Library ', + isDefault: false, + caseSensitivityMode: 'Auto' as const, + storageState: 'Unconfirmed' as const, + confirmationToken: 'observation-token', + } + vi.mocked(apiService.confirmRootFolder).mockResolvedValueOnce(current) + vi.mocked(apiService.getRootFolders).mockResolvedValueOnce([current]) + const store = useRootFoldersStore() + + await expect( + store.confirmCurrentFolder(current.id, current.path, current.confirmationToken), + ).resolves.toEqual(current) + + expect(apiService.confirmRootFolder).toHaveBeenCalledWith( + current.id, + current.path, + current.confirmationToken, + ) + expect(apiService.getRootFolders).toHaveBeenCalledTimes(1) + }) +}) diff --git a/fe/src/__tests__/scanNotifications.spec.ts b/fe/src/__tests__/scanNotifications.spec.ts new file mode 100644 index 000000000..07970c864 --- /dev/null +++ b/fe/src/__tests__/scanNotifications.spec.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useScanNotificationsStore } from '@/stores/scanNotifications' + +describe('scan notification store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('keeps internal scan updates hidden unless a manual scan is registered', () => { + const store = useScanNotificationsStore() + + store.applyUpdate({ jobId: 'internal-1', audiobookId: 42, status: 'Processing' }) + store.applyUpdate({ + jobId: 'internal-1', + audiobookId: 42, + status: 'Completed', + found: 2, + created: 1, + }) + + expect(store.jobs).toHaveLength(1) + expect(store.jobs[0]).toMatchObject({ + status: 'Completed', + visible: false, + found: 2, + created: 1, + }) + }) + + it('reveals the latest state when a fast manual scan completes before registration', () => { + const store = useScanNotificationsStore() + + store.applyUpdate({ jobId: 'manual-fast', audiobookId: 42, status: 'Processing' }) + store.applyUpdate({ + jobId: 'manual-fast', + audiobookId: 42, + status: 'Completed', + found: 3, + created: 2, + }) + store.registerManualScan('manual-fast', 42) + + expect(store.jobs[0]).toMatchObject({ + jobId: 'manual-fast', + audiobookId: 42, + status: 'Completed', + found: 3, + created: 2, + visible: true, + }) + }) + + it('does not regress terminal state when queued arrives after completion', () => { + const store = useScanNotificationsStore() + + store.applyUpdate({ + jobId: 'manual-race', + audiobookId: 42, + status: 'Completed', + found: 1, + created: 1, + }) + store.applyUpdate({ jobId: 'manual-race', audiobookId: 42, status: 'Queued' }) + + expect(store.jobs[0]).toMatchObject({ + status: 'Completed', + visible: true, + found: 1, + created: 1, + }) + }) + + it('preserves visible manual scans when hidden internal scan history is trimmed', () => { + const store = useScanNotificationsStore() + + store.registerManualScan('manual-visible', 1) + for (let index = 0; index < 60; index += 1) { + store.applyUpdate({ + jobId: `internal-${index}`, + audiobookId: index + 10, + status: 'Processing', + }) + } + + expect(store.jobs).toHaveLength(50) + expect(store.jobs.some((job) => job.jobId === 'manual-visible' && job.visible)).toBe(true) + }) + + it('never evicts active visible manual scans just to enforce the history cap', () => { + const store = useScanNotificationsStore() + + for (let index = 0; index < 55; index += 1) { + store.registerManualScan(`manual-${index}`, index + 1) + } + + expect(store.jobs).toHaveLength(55) + expect(store.jobs.every((job) => job.visible && job.status === 'Queued')).toBe(true) + }) + + it('does not let a conflicting late terminal state overwrite completion', () => { + const store = useScanNotificationsStore() + + store.registerManualScan('scan-terminal', 42) + store.applyUpdate({ + jobId: 'scan-terminal', + audiobookId: 42, + status: 'Completed', + found: 3, + created: 2, + }) + store.applyUpdate({ + jobId: 'scan-terminal', + audiobookId: 42, + status: 'Failed', + error: 'Scan status is no longer available', + }) + + expect(store.jobs[0]).toMatchObject({ + status: 'Completed', + found: 3, + created: 2, + }) + expect(store.jobs[0]?.error).toBeUndefined() + }) + + it('keeps active scans through clear and makes terminal scans dismissible', () => { + const store = useScanNotificationsStore() + + store.registerManualScan('active', 1) + store.registerManualScan('finished', 2) + store.applyUpdate({ jobId: 'finished', audiobookId: 2, status: 'Completed' }) + + store.dismiss('active') + expect(store.jobs.find((job) => job.jobId === 'active')?.dismissed).toBe(false) + + store.dismiss('finished') + expect(store.jobs.find((job) => job.jobId === 'finished')?.dismissed).toBe(true) + + store.clearFinished() + expect(store.jobs.map((job) => job.jobId)).toEqual(['active']) + }) +}) diff --git a/fe/src/__tests__/services/apiErrors.spec.ts b/fe/src/__tests__/services/apiErrors.spec.ts new file mode 100644 index 000000000..b04a046ad --- /dev/null +++ b/fe/src/__tests__/services/apiErrors.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { getApiValidationError } from '@/services/apiErrors' + +describe('getApiValidationError', () => { + it('returns a matching structured field error and preserves the resolved destination', () => { + const error = Object.assign(new Error('API error'), { + status: 400, + body: JSON.stringify({ + code: 'destination_path_outside_roots', + field: 'destinationPath', + message: 'DestinationPath must be inside a configured root folder or output path', + resolvedDestination: '/outside/Author/Title', + }), + }) + + expect(getApiValidationError(error, 'destinationPath')).toEqual({ + code: 'destination_path_outside_roots', + field: 'destinationPath', + message: 'DestinationPath must be inside a configured root folder or output path', + resolvedDestination: '/outside/Author/Title', + }) + }) + + it('uses RFC problem-details detail for filesystem initialization failures', () => { + const error = Object.assign(new Error('API error'), { + status: 503, + body: JSON.stringify({ + title: 'Service unavailable', + status: 503, + code: 'filesystem_initializing', + detail: 'Library filesystem initialization is still in progress.', + }), + }) + + expect(getApiValidationError(error)).toEqual({ + code: 'filesystem_initializing', + field: undefined, + message: 'Library filesystem initialization is still in progress.', + resolvedDestination: undefined, + jobId: undefined, + status: undefined, + requestedPath: undefined, + recoveryDisposition: undefined, + canRetry: undefined, + }) + }) + + it('does not return an error for another field', () => { + const error = Object.assign(new Error('API error'), { + body: JSON.stringify({ + field: 'title', + message: 'Title is invalid', + }), + }) + + expect(getApiValidationError(error, 'destinationPath')).toBeNull() + }) + + it.each(['not-json', '{}', '{"message":""}'])( + 'fails closed for an unusable response body: %s', + (body) => { + expect(getApiValidationError(Object.assign(new Error('API error'), { body }))).toBeNull() + }, + ) +}) diff --git a/fe/src/__tests__/test-setup.ts b/fe/src/__tests__/test-setup.ts index f37251826..9dda7f996 100644 --- a/fe/src/__tests__/test-setup.ts +++ b/fe/src/__tests__/test-setup.ts @@ -164,8 +164,15 @@ vi.mock('@/services/api', () => { executeRename: vi.fn(async () => []), getQualityProfiles: vi.fn(async () => []), getApiConfigurations: vi.fn(async () => []), - // add getRootFolders to apiService so tests that spy on apiService.getRootFolders work + // Root-folder mutation methods used by store/component integration tests. getRootFolders: vi.fn(async () => []), + updateRootFolder: vi.fn(async (_id: number, payload: unknown) => payload), + changeRootFolderPath: vi.fn(async () => ({})), + confirmRootFolder: vi.fn(async () => ({})), + getRootFolderMetadataRepairDetails: vi.fn(async () => ({})), + removeRootFolderMetadataRepairFile: vi.fn(async () => ({})), + abandonUnpublishedRootFolderRelocation: vi.fn(async () => ({})), + retryRootFolderRelocation: vi.fn(async () => ({})), // add checkVolume to apiService so components that call `apiService.checkVolume` in // unit tests have a sensible default value that matches the real API signature. @@ -236,6 +243,14 @@ vi.mock('@/services/signalr', () => ({ void cb return () => {} }, + onConnected: (cb?: (...args: unknown[]) => void) => { + void cb + return () => {} + }, + onRootFolderRelocationUpdate: (cb?: (...args: unknown[]) => void) => { + void cb + return () => {} + }, onDownloadUpdate: (cb?: (...args: unknown[]) => void) => { void cb return () => {} diff --git a/fe/src/__tests__/utils/path.spec.ts b/fe/src/__tests__/utils/path.spec.ts index 88ece48b9..13ef62e2a 100644 --- a/fe/src/__tests__/utils/path.spec.ts +++ b/fe/src/__tests__/utils/path.spec.ts @@ -15,13 +15,34 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { describe, it, expect } from 'vitest' import { toForward, trimTrailingSlash, + trimTrailingDirectorySeparators, normalizeForCompare, isAbsolutePath, + isRootedPath, + hasRelativePathSegment, + hasParentTraversalSegment, + hasEmptyMiddlePathSegment, + hasControlCharacter, + hasOuterWhitespace, + hasPathSegmentOuterWhitespace, + hasWindowsDriveRelativePath, + hasIncompleteWindowsUncAuthority, + hasWindowsTrailingSpaceOrPeriodSegment, + hasWindowsInvalidCharacter, + pathsOverlap, + pathsEqual, + pathIsInside, + hasWindowsReservedDeviceSegment, + validateLibraryDestinationPath, stripRootPrefix, + detectPathKind, + joinPaths, } from '@/utils/path' describe('path utils', () => { @@ -30,41 +51,332 @@ describe('path utils', () => { expect(toForward(null)).toBe('') }) - it('trimTrailingSlash removes trailing slashes', () => { + it('trimTrailingSlash removes trailing slashes without collapsing drive roots', () => { expect(trimTrailingSlash('C:/path/')).toBe('C:/path') expect(trimTrailingSlash('C:\\path\\')).toBe('C:\\path') expect(trimTrailingSlash('no-slash')).toBe('no-slash') + expect(trimTrailingSlash('/')).toBe('/') + expect(trimTrailingSlash('C:\\')).toBe('C:\\') + expect(trimTrailingSlash('C:\\\\')).toBe('C:\\') + expect(trimTrailingSlash('C:////')).toBe('C:/') + }) + + it('preserves a trailing backslash as part of a Unix directory name', () => { + expect(trimTrailingDirectorySeparators('/library/Book\\', 'unix')).toBe('/library/Book\\') + expect(pathsEqual('/library/Book\\', '/library/Book', 'unix', 'Sensitive')).toBe(false) + expect(stripRootPrefix('/library', '/library/Book\\', 'Sensitive', 'unix')).toBe('Book\\') + expect(joinPaths('/library', 'Book\\', 'unix')).toBe('/library/Book\\') }) it('normalizeForCompare lowercases and trims', () => { expect(normalizeForCompare('C:\\Temp\\Dir\\')).toBe('c:/temp/dir') }) - it('isAbsolutePath detects absolute paths', () => { + it('isAbsolutePath respects explicit filesystem context', () => { expect(isAbsolutePath('C:\\some\\path')).toBe(true) expect(isAbsolutePath('/unix/path')).toBe(true) + expect(isAbsolutePath('\\library', 'windows')).toBe(false) + expect(isAbsolutePath('\\', 'windows')).toBe(true) + expect(isAbsolutePath('\\library', 'unix')).toBe(false) + expect(isAbsolutePath('C:\\library', 'unix')).toBe(false) + expect(isAbsolutePath('/library', 'windows')).toBe(false) + expect(isAbsolutePath('/', 'windows')).toBe(true) expect(isAbsolutePath('relative/path')).toBe(false) }) - it('stripRootPrefix removes root prefix when present', () => { + it('distinguishes rooted input from fully absolute paths', () => { + expect(isRootedPath('C:\\some\\path', 'windows')).toBe(true) + expect(isRootedPath('\\some\\path', 'windows')).toBe(true) + expect(isRootedPath('/some/path', 'unix')).toBe(true) + expect(isRootedPath('\\some\\path', 'unix')).toBe(false) + expect(isRootedPath('some/path', 'unix')).toBe(false) + expect(isRootedPath('some\\path', 'windows')).toBe(false) + }) + + it('classifies and rejects Windows drive-relative paths', () => { + expect(detectPathKind('C:')).toBe('windows') + expect(detectPathKind('C:relative')).toBe('windows') + expect(hasWindowsDriveRelativePath('C:')).toBe(true) + expect(hasWindowsDriveRelativePath('C:relative')).toBe(true) + expect(hasWindowsDriveRelativePath('C:\\')).toBe(false) + expect(validateLibraryDestinationPath('C:')).toContain('separator after the drive letter') + expect(validateLibraryDestinationPath('C:relative')).toContain( + 'separator after the drive letter', + ) + expect(validateLibraryDestinationPath('C:\\')).toBe(null) + expect(validateLibraryDestinationPath('C:/')).toBe(null) + expect( + validateLibraryDestinationPath('C:\\', { + pathKind: 'windows', + allowFileSystemRoot: false, + }), + ).toContain('filesystem root') + expect( + validateLibraryDestinationPath('/', { + pathKind: 'unix', + allowFileSystemRoot: false, + }), + ).toContain('filesystem root') + expect(validateLibraryDestinationPath('Books', { requireAbsolute: true })).toContain( + 'absolute directory path', + ) + expect( + validateLibraryDestinationPath('\\library', { + pathKind: 'unix', + requireAbsolute: true, + }), + ).toContain('absolute directory path') + expect(validateLibraryDestinationPath('Books')).toBe(null) + expect(normalizeForCompare('C:\\\\', 'windows')).toBe('c:/') + expect(stripRootPrefix('C:\\', 'C:\\Books', 'Insensitive', 'windows')).toBe('Books') + expect(joinPaths('C:\\\\', 'Books', 'windows')).toBe('C:\\Books') + }) + + it('classifies absolute Unix paths with backslashes as Unix paths', () => { + expect(detectPathKind('/books/Author\\Name')).toBe('unix') + expect(normalizeForCompare('/books/Author\\Name')).toBe('/books/Author\\Name') + }) + + it('requires context for double-slash absolute paths', () => { + expect(detectPathKind('//server/share/Books')).toBe('unknown') + expect(detectPathKind('//server/share/Books', 'windows')).toBe('windows') + expect(detectPathKind('//server/share/Books', 'unix')).toBe('unix') + expect(hasEmptyMiddlePathSegment('//server/share/Books')).toBe(false) + expect(hasEmptyMiddlePathSegment('//server/share/Books', 'windows')).toBe(false) + expect(hasEmptyMiddlePathSegment('//server/share/Books', 'unix')).toBe(false) + expect(validateLibraryDestinationPath('//server/share/Books/Author')).toBe(null) + expect( + validateLibraryDestinationPath('//server/share/Books/CON', { pathKind: 'windows' }), + ).toContain('reserved Windows') + expect(validateLibraryDestinationPath('//server/share/Books/CON', { pathKind: 'unix' })).toBe( + null, + ) + }) + + it('detects exact relative path segments without blocking periods in names', () => { + expect(hasRelativePathSegment('D:\\Books\\Title\\.')).toBe(true) + expect(hasRelativePathSegment('D:\\Books\\Title\\..')).toBe(true) + expect(hasRelativePathSegment('/books/./title')).toBe(true) + expect(hasRelativePathSegment('/books/../title')).toBe(true) + expect(hasRelativePathSegment('/books/Dr. Seuss')).toBe(false) + expect(hasRelativePathSegment('/books/.metadata')).toBe(false) + expect(hasRelativePathSegment('/books/title...')).toBe(false) + }) + + it('hasParentTraversalSegment detects parent directory traversal', () => { + expect(hasParentTraversalSegment('D:\\Books\\Title\\..')).toBe(true) + expect(hasParentTraversalSegment('/books/title/../other')).toBe(true) + expect(hasParentTraversalSegment('/books/title..')).toBe(false) + expect(hasParentTraversalSegment('/books/.../title')).toBe(false) + expect(hasParentTraversalSegment(null)).toBe(false) + }) + + it('detects empty middle path segments without rejecting roots', () => { + expect(hasEmptyMiddlePathSegment('D:\\Books\\\\Title')).toBe(true) + expect(hasEmptyMiddlePathSegment('/books//title')).toBe(true) + expect(hasEmptyMiddlePathSegment('D:\\Books\\Title')).toBe(false) + expect(hasEmptyMiddlePathSegment('/books/title')).toBe(false) + expect(hasEmptyMiddlePathSegment('D:\\')).toBe(false) + expect(hasEmptyMiddlePathSegment('\\\\server\\share\\Audiobooks')).toBe(false) + expect(hasEmptyMiddlePathSegment('\\\\server\\share\\\\Audiobooks')).toBe(true) + }) + + it('validates Windows UNC authority structure', () => { + expect(hasIncompleteWindowsUncAuthority('\\\\server', 'windows')).toBe(true) + expect(hasIncompleteWindowsUncAuthority('\\\\server\\', 'windows')).toBe(true) + expect(hasIncompleteWindowsUncAuthority('\\\\server\\share', 'windows')).toBe(false) + expect(hasIncompleteWindowsUncAuthority('//server/share', 'windows')).toBe(false) + expect(hasIncompleteWindowsUncAuthority('//server', 'unix')).toBe(false) + + expect( + validateLibraryDestinationPath('\\\\server', { + pathKind: 'windows', + requireAbsolute: true, + }), + ).toContain('server and share') + expect( + validateLibraryDestinationPath('\\\\server\\share', { + pathKind: 'windows', + requireAbsolute: true, + }), + ).toBe(null) + expect( + validateLibraryDestinationPath('\\\\server\\share', { + pathKind: 'windows', + requireAbsolute: true, + allowFileSystemRoot: false, + }), + ).toContain('filesystem root') + expect( + validateLibraryDestinationPath('\\\\server\\NUL\\Books', { + pathKind: 'windows', + }), + ).toContain('reserved Windows') + expect( + validateLibraryDestinationPath('\\\\NUL\\share\\Books', { + pathKind: 'windows', + }), + ).toContain('reserved Windows') + expect(validateLibraryDestinationPath('//server', { pathKind: 'unix' })).toBe(null) + }) + + it('detects control characters and segment whitespace', () => { + expect(hasControlCharacter('D:\\Books\\Title\n')).toBe(true) + expect(hasControlCharacter('D:\\Books\\Title')).toBe(false) + expect(hasOuterWhitespace(' D:\\Books\\Title')).toBe(true) + expect(hasOuterWhitespace('D:\\Books\\Title ')).toBe(true) + expect(hasOuterWhitespace('D:\\Listenarr Test\\Title')).toBe(false) + expect(hasPathSegmentOuterWhitespace('D:\\Books\\test ')).toBe(true) + expect(hasPathSegmentOuterWhitespace('D:\\Books\\ test')).toBe(true) + expect(hasPathSegmentOuterWhitespace('D:\\Listenarr Test\\Title')).toBe(false) + }) + + it('detects Windows-only trailing space or period segments', () => { + expect(hasWindowsTrailingSpaceOrPeriodSegment('D:\\Books\\test ')).toBe(true) + expect(hasWindowsTrailingSpaceOrPeriodSegment('D:\\Books\\test.')).toBe(true) + expect(hasWindowsTrailingSpaceOrPeriodSegment('D:\\Books\\ test')).toBe(false) + expect(hasWindowsTrailingSpaceOrPeriodSegment('/books/test ')).toBe(false) + expect(hasWindowsTrailingSpaceOrPeriodSegment('/books/ test ')).toBe(false) + }) + + it('detects Windows invalid characters and reserved device names', () => { + expect(hasWindowsInvalidCharacter('D:\\Books\\Bad|Folder')).toBe(true) + expect(hasWindowsInvalidCharacter('D:\\Books\\Bad:Folder')).toBe(true) + expect(hasWindowsInvalidCharacter('D:\\Books\\Good Folder')).toBe(false) + expect(hasWindowsReservedDeviceSegment('D:\\Books\\CON')).toBe(true) + expect(hasWindowsReservedDeviceSegment('D:\\Books\\NUL.txt')).toBe(true) + expect(hasWindowsReservedDeviceSegment('D:\\Books\\COM1.folder')).toBe(true) + expect(hasWindowsReservedDeviceSegment('D:\\Books\\COM¹.folder')).toBe(true) + expect(hasWindowsReservedDeviceSegment('D:\\Books\\com²')).toBe(true) + expect(hasWindowsReservedDeviceSegment('D:\\Books\\LPT³.txt')).toBe(true) + expect(hasWindowsReservedDeviceSegment('\\\\server\\NUL\\Books', 'windows')).toBe(true) + expect(hasWindowsReservedDeviceSegment('\\\\NUL\\share\\Books', 'windows')).toBe(true) + expect(hasWindowsReservedDeviceSegment('\\\\server\\COM¹\\Books', 'windows')).toBe(true) + expect(hasWindowsReservedDeviceSegment('\\\\LPT³\\share\\Books', 'windows')).toBe(true) + expect(hasWindowsReservedDeviceSegment('D:\\Books\\COM⁴.txt')).toBe(false) + expect(hasWindowsReservedDeviceSegment('D:\\Books\\Concert')).toBe(false) + }) + + it('matches the shared Windows reserved-device fixture', () => { + const fixture = JSON.parse( + readFileSync( + resolve(process.cwd(), '../test-fixtures/windows-reserved-device-names.json'), + 'utf8', + ), + ) as { reserved: string[]; nonReserved: string[] } + + for (const name of fixture.reserved) { + expect(hasWindowsReservedDeviceSegment(`C:\\Books\\${name}.folder`, 'windows'), name).toBe( + true, + ) + } + for (const name of fixture.nonReserved) { + expect(hasWindowsReservedDeviceSegment(`C:\\Books\\${name}.folder`, 'windows'), name).toBe( + false, + ) + } + }) + + it('detects overlapping source and destination paths', () => { + expect(pathsOverlap('D:\\Books\\Title\\Child', 'D:\\Books\\Title', 'windows')).toBe(true) + expect(pathsOverlap('D:\\Books\\Title', 'D:\\Books\\Title\\Child', 'windows')).toBe(true) + expect(pathsOverlap('D:\\Books\\Title2', 'D:\\Books\\Title', 'windows')).toBe(false) + expect(pathsOverlap('/books/title/child', '/books/title', 'unix')).toBe(true) + expect(pathsOverlap('/books/title2', '/books/title', 'unix')).toBe(false) + expect(pathsOverlap('/Books/title', '/books', 'unix')).toBe(false) + expect(pathIsInside('/Author/Title', '/', 'unix')).toBe(true) + }) + + it('uses server-provided case sensitivity instead of path shape', () => { + expect(pathsEqual('/Books/Title', '/books/title', 'unix', 'Insensitive')).toBe(true) + expect(pathsEqual('C:\\Books\\Title', 'c:\\books\\title', 'windows', 'Sensitive')).toBe(false) + expect(pathIsInside('/Books/Title', '/books', 'unix', 'Insensitive')).toBe(true) + }) + + it('validates library destination paths while allowing platform-valid whitespace', () => { + expect(validateLibraryDestinationPath('D:\\Books\\Title\\.')).toContain( + 'current-directory path segments', + ) + expect(validateLibraryDestinationPath('/books/title/.')).toContain( + 'current-directory path segments', + ) + expect(validateLibraryDestinationPath('D:\\Books\\Title\\..')).toContain( + 'Path traversal is not allowed', + ) + expect(validateLibraryDestinationPath('/books/title/..')).toContain( + 'Path traversal is not allowed', + ) + expect(validateLibraryDestinationPath('D:\\Books\\\\Title')).toContain('empty path segments') + expect(validateLibraryDestinationPath('D:\\Books\\Bad*Folder')).toContain('invalid on Windows') + expect(validateLibraryDestinationPath('D:\\Books\\CON.txt')).toContain('reserved Windows') + expect(validateLibraryDestinationPath('D:\\Books\\test ')).toContain( + 'cannot end with a space or period', + ) + expect(validateLibraryDestinationPath('D:\\Books\\test.')).toContain( + 'cannot end with a space or period', + ) + expect(validateLibraryDestinationPath('D:\\Books\\ test')).toBe(null) + expect(validateLibraryDestinationPath('/books/ test /')).toBe(null) + expect(validateLibraryDestinationPath('D:\\Books\\Dr. Seuss')).toBe(null) + expect(validateLibraryDestinationPath('D:\\Books\\.metadata')).toBe(null) + expect(validateLibraryDestinationPath('D:\\Books\\Title...')).toContain( + 'cannot end with a space or period', + ) + expect(validateLibraryDestinationPath('/books/Title...')).toBe(null) + expect( + validateLibraryDestinationPath('D:\\Books\\Title\\Child', { + pathKind: 'windows', + sourcePath: 'D:\\Books\\Title', + }), + ).toBe(null) + expect( + validateLibraryDestinationPath('/books/title/child', { + pathKind: 'unix', + sourcePath: '/books/title', + }), + ).toBe(null) + expect( + validateLibraryDestinationPath('D:\\Books', { + pathKind: 'windows', + sourcePath: 'D:\\Books\\Title', + }), + ).toBe(null) + }) + + it('stripRootPrefix removes only a complete root boundary', () => { const root = 'C:\\temp\\Isaac Asimov\\Foundation' const full = 'C:\\temp\\Isaac Asimov\\Foundation\\Prelude to Foundation' - const rel = stripRootPrefix(root, full) - expect(rel).toBe('Prelude to Foundation') + expect(stripRootPrefix(root, full)).toBe('Prelude to Foundation') + expect(stripRootPrefix(root, root)).toBe('') - // preserves backslash style when root uses backslashes - const root2 = 'C:/temp/Isaac Asimov/Foundation' - const full2 = 'C:/temp/Isaac Asimov/Foundation/Prelude to Foundation' - const rel2 = stripRootPrefix(root2, full2) - expect(rel2).toBe('Prelude to Foundation') + const forwardRoot = 'C:/temp/Isaac Asimov/Foundation' + const forwardFull = 'C:/temp/Isaac Asimov/Foundation/Prelude to Foundation' + expect(stripRootPrefix(forwardRoot, forwardFull)).toBe('Prelude to Foundation') - // returns null when no match expect(stripRootPrefix('C:/root/other', full)).toBe(null) - - // matches using last segments - const root3 = 'C:/temp/Isaac Asimov/Foundation/Extra' - const full3 = 'C:/some/prefix/isaac asimov/foundation/Prelude' - const rel3 = stripRootPrefix(root3, full3) - expect(rel3).toBe('Prelude') + expect(stripRootPrefix('C:/root/books', 'C:/root/bookshelf/Title')).toBe(null) + expect(stripRootPrefix('C:/root/books/Extra', 'C:/other/root/bookshelf/Title')).toBe(null) + expect( + stripRootPrefix( + 'C:/temp/Isaac Asimov/Foundation/Extra', + 'C:/some/prefix/isaac asimov/foundation/Prelude', + ), + ).toBe(null) + expect(stripRootPrefix('C:/Books', 'D:/Books/Title')).toBe(null) + expect(stripRootPrefix('C:/Books', 'c:/books/Title', 'Sensitive')).toBe(null) + expect(stripRootPrefix('C:/Books', 'c:/books/Title', 'Insensitive')).toBe('Title') + expect(stripRootPrefix('\\\\server\\share\\Books', '//server/share/Books/Title')).toBe('Title') + expect(stripRootPrefix('\\\\server\\share\\Books', '//server/other/Books/Title')).toBe(null) + expect( + stripRootPrefix( + '//server/share/Books', + '//server/share/Books/Title', + 'Insensitive', + 'windows', + ), + ).toBe('Title') + expect(stripRootPrefix('//srv/library', '//srv/library/Title', 'Sensitive', 'unix')).toBe( + 'Title', + ) }) }) diff --git a/fe/src/__tests__/utils/rootFolderPath.spec.ts b/fe/src/__tests__/utils/rootFolderPath.spec.ts new file mode 100644 index 000000000..6e6ecbaac --- /dev/null +++ b/fe/src/__tests__/utils/rootFolderPath.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import type { RootFolder } from '@/types' +import { rootFolderPathChanged } from '@/utils/rootFolderPath' + +function createRoot(path: string, pathSyntax: RootFolder['pathSyntax'] = null): RootFolder { + return { + id: 1, + name: 'Library', + path, + pathSyntax, + isDefault: true, + caseSensitivityMode: 'Auto', + resolvedCaseSensitivity: 'Unknown', + pathIdentityState: 'Unavailable', + } +} + +describe('rootFolderPathChanged', () => { + it('treats an unambiguous Windows repair of an ambiguous persisted root as a path change', () => { + const root = createRoot('//server/share/library') + + expect(rootFolderPathChanged(root, '\\\\server\\share\\library')).toBe(true) + }) + + it('does not manufacture a path change when an ambiguous persisted root is unchanged', () => { + const root = createRoot('//server/share/library') + + expect(rootFolderPathChanged(root, '//server/share/library')).toBe(false) + }) +}) diff --git a/fe/src/components/base/ProgressBar.vue b/fe/src/components/base/ProgressBar.vue index 8ba7c23a8..c4b0506be 100644 --- a/fe/src/components/base/ProgressBar.vue +++ b/fe/src/components/base/ProgressBar.vue @@ -55,6 +55,7 @@ interface Props { showSize?: boolean // Show size info label?: string // Optional label above bar animating?: boolean // For activity-style animation + indeterminate?: boolean // Show activity without implying measurable completion } const props = withDefaults(defineProps(), { @@ -66,6 +67,7 @@ const props = withDefaults(defineProps(), { showSize: false, label: undefined, animating: false, + indeterminate: false, }) // Format bytes to human readable size @@ -98,12 +100,18 @@ const displaySize = computed(() => {
-
-
+
+
{{ Math.round(value) }}%
@@ -204,6 +212,20 @@ const displaySize = computed(() => { animation: progress-shimmer 2s infinite; } +.progress-fill.fill-activity.indeterminate { + animation: progress-indeterminate 1.4s ease-in-out infinite; + transition: none; +} + +@keyframes progress-indeterminate { + 0% { + transform: translateX(-120%); + } + 100% { + transform: translateX(320%); + } +} + @keyframes progress-shimmer { 0%, 100% { diff --git a/fe/src/components/domain/audiobook/AddLibraryModal.vue b/fe/src/components/domain/audiobook/AddLibraryModal.vue index 047440642..9680dcd6c 100644 --- a/fe/src/components/domain/audiobook/AddLibraryModal.vue +++ b/fe/src/components/domain/audiobook/AddLibraryModal.vue @@ -377,21 +377,9 @@
- +
-
- - Enter an absolute path where files will be stored - - - Select a named root (or custom path) and edit the path relative to it on the - right. + + Select a configured root and edit the path relative to it on the right. +
+ Effective destination: + {{ estimatedFullPath }} +
+
+ + {{ destinationPathValidationError }} +
@@ -438,7 +434,11 @@ Cancel - -
-
-
- -

- Click the edit button to change the destination folder. - - Choose a root folder from the dropdown, or select - "Custom path" to specify any location. The right field is for - organizing within the selected root. + + Choose a configured root folder from the dropdown. The right + field is the path relative to that root.

+
+ +
+ + {{ + moveRecoveryState.canRetry + ? 'An interrupted move needs to be resumed.' + : 'A previous move needs attention.' + }} + + + Destination: {{ moveRecoveryState.requestedPath }} + + {{ moveRecoveryState.error }} +
+ +
+
+ Effective destination: + {{ editDestinationPath }} +
+
+ + {{ destinationPathValidationError }} +
@@ -723,20 +744,12 @@ > Close -
- - Move Job: {{ moveJob.jobId }} — {{ moveJob.status }} - -
- Target: {{ moveJob.target }} -
-
@@ -107,18 +106,27 @@

Bulk Update Results

- {{ results.filter((r) => r.success).length }} succeeded, - {{ results.filter((r) => !r.success).length }} failed + {{ successfulResults.length }} succeeded, {{ partialResults.length }} partially + succeeded, {{ failedResults.length }} failed

Audiobook ID {{ res.id }} - {{ - res.success ? 'Success' : 'Failed' - }} + {{ resultStatusLabel(res) }}
+

+ {{ partialResultMessage(res) }} +

  • {{ err }}
@@ -163,6 +171,8 @@ import Checkbox from '@/components/form/Checkbox.vue' import { Modal, ModalBody, ModalHeader } from '@/components/feedback' import MoveAudiobookModal from '@/components/feedback/MoveAudiobookModal.vue' import { apiService } from '@/services/api' +import { useMoveJobsStore } from '@/stores/moveJobs' +import { executeBulkEdit, type BulkEditItemResult } from '@/utils/bulkEditOrchestration' import { buildApiPath } from '@/services/apiBase' import { useToast } from '@/services/toastService' import type { QualityProfile } from '@/types' @@ -180,8 +190,7 @@ interface FormData { qualityProfileId: number | null // root change controls rootChangeEnabled: boolean - rootId: number | null | 0 - rootCustomPath: string | null + rootId: number | null } const props = defineProps() @@ -191,13 +200,13 @@ const emit = defineEmits<{ }>() const qualityProfiles = ref([]) -const rootFolders = ref([]) const rootStore = useRootFoldersStore() +const moveJobsStore = useMoveJobsStore() const saving = ref(false) // Root change helper values const defaultOutputPath = ref(null) -const results = ref>([]) +const results = ref([]) const showResults = ref(false) const bulkUpdateEndpoint = buildApiPath('/library/bulk-update') @@ -215,20 +224,30 @@ const formData = ref({ qualityProfileId: null, rootChangeEnabled: false, rootId: null, - rootCustomPath: null, +}) + +const resolvedRootPath = computed(() => { + if (!formData.value.rootChangeEnabled) return null + if (formData.value.rootId && formData.value.rootId > 0) { + return rootStore.folders.find((folder) => folder.id === formData.value.rootId)?.path ?? null + } + return defaultOutputPath.value }) const hasChanges = computed(() => { return ( formData.value.monitored !== null || formData.value.qualityProfileId !== null || - (formData.value.rootChangeEnabled === true && - (formData.value.rootId !== null || - (formData.value.rootCustomPath && formData.value.rootCustomPath.length > 0))) + formData.value.rootChangeEnabled === true ) }) const toast = useToast() +const successfulResults = computed(() => results.value.filter((result) => result.success)) +const partialResults = computed(() => results.value.filter(isPartialResult)) +const failedResults = computed(() => + results.value.filter((result) => !result.success && !isPartialResult(result)), +) watch( () => props.isOpen, @@ -248,16 +267,12 @@ async function loadData() { // Load root folders from configuration await rootStore.load() if (rootStore.folders.length > 0) { - rootFolders.value = rootStore.folders.map((f) => f.path) // Capture default output path for fallback when user picks "Use default" const def = rootStore.folders.find((f) => f.isDefault) defaultOutputPath.value = def?.path ?? null } else { const appSettings = await apiService.getApplicationSettings() - if (appSettings.outputPath) { - rootFolders.value = [appSettings.outputPath] - defaultOutputPath.value = appSettings.outputPath - } + defaultOutputPath.value = appSettings.outputPath || null } } catch (error) { console.error('Failed to load bulk edit data:', error) @@ -270,7 +285,6 @@ function resetForm() { qualityProfileId: null, rootChangeEnabled: false, rootId: null, - rootCustomPath: null, } } @@ -331,178 +345,79 @@ async function handleSave() { saving.value = true try { - // Build update payload with only changed fields const updates: Record = {} - if (formData.value.monitored !== null) { updates.monitored = formData.value.monitored } - if (formData.value.qualityProfileId !== null) { updates.qualityProfileId = formData.value.qualityProfileId } - // Handle root folder change with move confirmation let userWantsMove = false let userWantsDeleteEmpty = false let newRootPath: string | null = null - - // Store original basePaths before updating if we're changing root folders - const originalBasePaths = new Map() - if (formData.value.rootChangeEnabled === true) { - // Resolve chosen root path: named root, custom path or default - if (formData.value.rootId === 0) { - newRootPath = formData.value.rootCustomPath || null - } else if (formData.value.rootId && formData.value.rootId > 0) { - const found = rootStore.folders.find((f) => f.id === formData.value.rootId) - newRootPath = found?.path ?? null - } else if (formData.value.rootId === null) { - // Use default path (if available) - newRootPath = defaultOutputPath.value + newRootPath = resolvedRootPath.value + if (!newRootPath) { + throw new Error('Select a valid destination root before saving.') } - if (newRootPath !== null) { - // Fetch all audiobooks BEFORE updating to capture their original basePaths - const ids = Array.from(props.selectedIds) - for (const id of ids) { - try { - const audiobook = await apiService.getAudiobook(id) - if (audiobook?.basePath) { - originalBasePaths.set(id, audiobook.basePath) - } - } catch (err) { - console.error(`Failed to fetch audiobook ${id} before update:`, err) - } - } - - // Ask user if they want to move files - const choice = await askMoveConfirmation(newRootPath) - if (!choice || !choice.proceed) { - saving.value = false - return // User cancelled - } - userWantsMove = Boolean(choice.moveFiles) - userWantsDeleteEmpty = Boolean(choice.deleteEmptySource) - updates.rootFolder = newRootPath - } + const choice = await askMoveConfirmation(newRootPath) + if (!choice?.proceed) return + userWantsMove = Boolean(choice.moveFiles) + userWantsDeleteEmpty = Boolean(choice.deleteEmptySource) + updates.rootFolder = newRootPath } - // Add move options if root folder is being changed - if (formData.value.rootChangeEnabled && newRootPath) { - ;(updates as { moveFiles?: boolean; deleteEmptySource?: boolean }).moveFiles = userWantsMove - ;(updates as { moveFiles?: boolean; deleteEmptySource?: boolean }).deleteEmptySource = - userWantsDeleteEmpty - } - - // Convert Set to Array for API call const ids = Array.from(props.selectedIds) + logger.debug('[BulkEditModal] Preparing bulk update', { + endpoint: bulkUpdateEndpoint, + ids, + updates, + physicalMove: userWantsMove, + timestamp: new Date().toISOString(), + }) - // Debug logging: payload and environment - // This will help diagnose NS_ERROR_CONNECTION_REFUSED in browser - try { - logger.debug('[BulkEditModal] Preparing bulk update', { - endpoint: bulkUpdateEndpoint, - origin: window?.location?.origin, + const outcome = await executeBulkEdit( + { ids, updates, - navigatorOnline: typeof navigator !== 'undefined' ? navigator.onLine : undefined, - timestamp: new Date().toISOString(), - }) - } catch { - // ignore logging errors in non-browser envs - } - - // Call bulk update API - const resp = await apiService.bulkUpdateAudiobooks(ids, updates) - - // Save per-id results for display - results.value = resp.results || [] + destinationRoot: newRootPath, + moveFiles: userWantsMove, + deleteEmptySource: userWantsDeleteEmpty, + }, + { + bulkUpdateAudiobooks: (audiobookIds, metadataUpdates, pathChange) => + apiService.bulkUpdateAudiobooks(audiobookIds, metadataUpdates, pathChange), + trackQueuedJob: (job) => moveJobsStore.trackQueuedJob(job), + }, + ) + + results.value = outcome.results showResults.value = true + const successCount = successfulResults.value.length + const partialCount = partialResults.value.length + const failureCount = failedResults.value.length + if (partialCount > 0 || failureCount > 0) { + toast.error( + 'Bulk update incomplete', + `${successCount} succeeded, ${partialCount} partially succeeded, and ${failureCount} failed. Review the per-audiobook results.`, + ) + return + } - // If user wants to move files, enqueue move jobs for each audiobook - if (userWantsMove && newRootPath) { - console.log('[BulkEditModal] Starting move job enqueue process', { - userWantsMove, - newRootPath, - totalIds: ids.length, - originalBasePathsSize: originalBasePaths.size, - }) - - let moveCount = 0 - for (const id of ids) { - // Only enqueue move for audiobooks that were successfully updated - const result = results.value.find((r) => r.id === id) - console.log(`[BulkEditModal] Processing audiobook ${id}`, { - hasResult: !!result, - success: result?.success, - }) - - if (result && result.success) { - try { - // Get the ORIGINAL basePath (before update) and the NEW basePath (after update) - const originalBasePath = originalBasePaths.get(id) - const audiobook = await apiService.getAudiobook(id) - const newBasePath = audiobook?.basePath - - console.log(`[BulkEditModal] Audiobook ${id} paths:`, { - originalBasePath, - newBasePath, - pathsAreDifferent: originalBasePath !== newBasePath, - }) - - if (originalBasePath && newBasePath && originalBasePath !== newBasePath) { - console.log(`[BulkEditModal] Enqueueing move for audiobook ${id}`, { - destination: newBasePath, - source: originalBasePath, - deleteEmpty: userWantsDeleteEmpty, - }) - - const moveResult = await apiService.moveAudiobook(id, newBasePath, { - sourcePath: originalBasePath, - moveFiles: true, - deleteEmptySource: userWantsDeleteEmpty, - }) - - console.log(`[BulkEditModal] Move enqueued for audiobook ${id}:`, moveResult) - moveCount++ - } else { - console.warn( - `[BulkEditModal] Skipping move for audiobook ${id} - invalid paths or paths are the same`, - ) - } - } catch (moveErr) { - console.error(`Failed to enqueue move for audiobook ${id}:`, moveErr) - // Don't fail the entire operation, just log the error - } - } - } - - console.log(`[BulkEditModal] Finished move enqueue process. Queued ${moveCount} moves.`) - - if (moveCount > 0) { - toast.info( - 'Move jobs queued', - `Queued ${moveCount} move job(s). Files will be moved in the background.`, - ) - } else { - console.warn('[BulkEditModal] No move jobs were queued!') - } + if (userWantsMove) { + toast.info( + 'Move jobs queued', + `Queued ${successCount} move job(s). Files will be moved in the background.`, + ) } else { - console.log('[BulkEditModal] Skipping move job enqueue', { userWantsMove, newRootPath }) + toast.success('Bulk update', `Updated ${successCount} audiobook(s)`) } - // Count successes - const successCount = results.value.filter((r) => r.success).length - toast.success('Bulk update', `Updated ${successCount} of ${results.value.length} audiobook(s)`) - - // Notify parent that changes were saved emit('saved') - - // Close the modal after successful operation close() } catch (error) { - // Enhanced error logging so browser console shows more details try { const err = error as Error & { url?: string } console.error('[BulkEditModal] Failed to save bulk edits:', { @@ -512,18 +427,16 @@ async function handleSave() { url: err?.url || bulkUpdateEndpoint, }) } catch { - // fallback console.error('Failed to save bulk edits (minimal):', error) } - // Try to extract a readable message from the API error let message = 'Failed to save changes. Please try again.' try { const err = error as Error & { body?: string } if (err.body) { try { const parsed = JSON.parse(err.body) - if (parsed && parsed.message) message = parsed.message + if (parsed?.message) message = parsed.message } catch { message = err.body } @@ -531,7 +444,7 @@ async function handleSave() { message = err.message } } catch { - // fallback + // Keep the generic message when an error cannot be inspected. } toast.error('Bulk update failed', message) } finally { @@ -539,6 +452,26 @@ async function handleSave() { } } +function isPartialResult(result: BulkEditItemResult): boolean { + return !result.success && result.metadataUpdated === true +} + +function resultStatusLabel(result: BulkEditItemResult): 'Success' | 'Partial' | 'Failed' { + if (result.success) return 'Success' + return isPartialResult(result) ? 'Partial' : 'Failed' +} + +function partialResultMessage(result: BulkEditItemResult): string { + switch (result.pathChangeOutcome) { + case 'failed': + return 'Metadata saved; the requested path change failed.' + case 'not-enqueued': + return 'Metadata saved; the requested move was not queued.' + default: + return 'Metadata saved; the requested path change did not complete.' + } +} + function close() { // Reset results when closing results.value = [] @@ -652,6 +585,15 @@ function close() { color: #f44336; } +.status.partial, +.partial-message { + color: #ffb74d; +} + +.partial-message { + margin: 0.5rem 0 0; +} + .error-list { margin: 0.5rem 0 0 0; padding-left: 1.25rem; diff --git a/fe/src/components/domain/organize/RenamePreviewModal.vue b/fe/src/components/domain/organize/RenamePreviewModal.vue index 5a9faee9a..5848df5e8 100644 --- a/fe/src/components/domain/organize/RenamePreviewModal.vue +++ b/fe/src/components/domain/organize/RenamePreviewModal.vue @@ -172,7 +172,17 @@ v-if="!finished" type="button" class="btn btn-primary" - :disabled="loading || executing || selectedCount === 0" + :disabled=" + loading || + executing || + selectedCount === 0 || + !filesystemReadinessStore.filesystemReady + " + :title=" + !filesystemReadinessStore.filesystemReady + ? 'Available after library filesystem initialization completes' + : undefined + " @click="confirm" > @@ -203,6 +213,7 @@ import { import { Modal, ModalBody, ModalFooter, ModalHeader } from '@/components/feedback' import RenamePathDiff from './RenamePathDiff.vue' import { apiService } from '@/services/api' +import { useFilesystemReadinessStore } from '@/stores/filesystemReadiness' import type { RenameOperation, RenamePreview, RenameResult } from '@/types' const props = withDefaults( @@ -221,6 +232,7 @@ const emit = defineEmits<{ done: [] }>() +const filesystemReadinessStore = useFilesystemReadinessStore() const loading = ref(false) const loaded = ref(false) const executing = ref(false) @@ -271,6 +283,8 @@ async function confirm() { .filter((preview) => selected.value.has(preview.audiobookId)) .map((preview) => ({ audiobookId: preview.audiobookId, + currentFolderPath: preview.currentFolderPath, + currentFolderSemantics: preview.currentFolderSemantics, newFolderPath: preview.folderChanged ? preview.newFolderPath : undefined, fileRenames: preview.fileRenames .filter((entry) => entry.changed) diff --git a/fe/src/components/feedback/ManualImportModal.vue b/fe/src/components/feedback/ManualImportModal.vue index 7977f1f78..b0bea3b1b 100644 --- a/fe/src/components/feedback/ManualImportModal.vue +++ b/fe/src/components/feedback/ManualImportModal.vue @@ -58,7 +58,12 @@ @@ -336,6 +351,7 @@ import { import { apiService } from '@/services/api' import { useLibraryStore } from '@/stores/library' import { useConfigurationStore } from '@/stores/configuration' +import { useFilesystemReadinessStore } from '@/stores/filesystemReadiness' const props = withDefaults(defineProps<{ isOpen?: boolean; initialPath?: string }>(), { isOpen: false, @@ -463,6 +479,7 @@ const selectRecent = (path: string) => { const libraryStore = useLibraryStore() const library = computed(() => libraryStore.audiobooks) const configurationStore = useConfigurationStore() +const filesystemReadinessStore = useFilesystemReadinessStore() const qualityProfiles = computed(() => configurationStore.qualityProfiles) const showMatch = ref(false) const matchTarget = ref(null) diff --git a/fe/src/components/feedback/MoveAudiobookModal.vue b/fe/src/components/feedback/MoveAudiobookModal.vue index 00997d9e0..0bb55b104 100644 --- a/fe/src/components/feedback/MoveAudiobookModal.vue +++ b/fe/src/components/feedback/MoveAudiobookModal.vue @@ -24,27 +24,45 @@ @@ -124,6 +87,7 @@ function onChange(e: Event) { flex-direction: column; gap: 0.5rem; } + .root-select-content.inline { display: flex; gap: 0.5rem; @@ -132,15 +96,11 @@ function onChange(e: Event) { width: 100%; } -.recent-paths { - width: 100%; -} - .form-select { padding: 0.75rem 1rem; height: 40px; box-sizing: border-box; - background-color: #1a1a1a; /* match input background */ + background-color: #1a1a1a; border: 1px solid #333; border-radius: 6px; color: white; @@ -155,14 +115,6 @@ function onChange(e: Event) { box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.2); } -.form-input { - padding: 0.6rem 0.75rem; - background-color: #1a1a1a; - border: 1px solid #333; - color: white; - border-radius: 6px; -} - .loading-row { display: flex; align-items: center; diff --git a/fe/src/components/settings/RootFolderFormModal.vue b/fe/src/components/settings/RootFolderFormModal.vue index 79ab8c174..2b22f7455 100644 --- a/fe/src/components/settings/RootFolderFormModal.vue +++ b/fe/src/components/settings/RootFolderFormModal.vue @@ -35,6 +35,23 @@ /> + + + + Detected: {{ root.resolvedCaseSensitivity ?? 'Unknown' }} · Storage: + {{ root.storageState ?? 'Unavailable' }} + + +
+ + Path and filesystem semantics changes are available after library filesystem + initialization completes. + + diff --git a/fe/src/components/settings/RootFoldersSettings.vue b/fe/src/components/settings/RootFoldersSettings.vue index 881e0696a..d5aced6f3 100644 --- a/fe/src/components/settings/RootFoldersSettings.vue +++ b/fe/src/components/settings/RootFoldersSettings.vue @@ -52,6 +52,29 @@

{{ folder.name }}

Default + Healthy + Missing + + Folder changed + + + Needs confirmation + + + Unavailable + + + Initializing + + + Initialization failed + + {{ folder.resolvedCaseSensitivity }}
@@ -61,6 +84,11 @@ @click="scanUnmatched(folder)" title="Scan for unmatched files" data-cy="scan-unmatched" + :disabled=" + filesystemReadinessStore.filesystemReady === false || + folder.canMutateFilesystem === false || + !!folder.activeRelocation + " > @@ -69,14 +97,26 @@ @click="edit(folder)" title="Edit" data-cy="edit-root-folder" + :disabled="!!folder.activeRelocation" > + @@ -85,6 +125,7 @@ @click="confirmDelete(folder)" title="Delete" data-cy="delete-root-folder" + :disabled="!!folder.activeRelocation" > @@ -94,6 +135,192 @@ {{ folder.path }}
+

+ {{ folder.storageMessage }} +

+
+
+
+ + +
+ {{ relocationTitle(folder.activeRelocation) }} + + {{ relocationProgressLabel(folder.activeRelocation) }} + +
+
+
+ + +
+
+ +
+ +
+ +

+ {{ relocationDescription(folder.activeRelocation) }} +

+ +

+ Destination: {{ folder.activeRelocation.targetPath }} +

+ +
+ + {{ folder.activeRelocation.skippedAudiobookIds.length }} + {{ + folder.activeRelocation.skippedAudiobookIds.length === 1 + ? 'audiobook needs attention' + : 'audiobooks need attention' + }} + +
+
+
+ + Audiobook #{{ audiobookId }} + + + {{ skippedReasonLabel(folder.activeRelocation, audiobookId) }} + + +
+ + +
+
+
+
@@ -123,17 +350,94 @@

This will only remove the reference and will not delete files from disk.

+ + + + + + + + + + + + +