From 58e47ba107b57af07c8791f43cea5b0617b12584 Mon Sep 17 00:00:00 2001 From: Kevin Heneveld <1192102+kevinheneveld@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:02:36 -0800 Subject: [PATCH] feat(organize): library-wide organize sweep with metadata-stub target replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A library-wide "Organize" action (Settings → General → Library Maintenance): GET /library/organize/preview buckets every audiobook against the canonical path its metadata computes to — already_canonical / will_move / collision / invalid_target, with machine-readable reason codes so the UI can group invalid rows and offer per-kind resolution. POST /library/organize/apply re-validates each confirmed id against live state and queues per-book background moves; rows that no longer qualify are skipped with a reason instead of aborting the rest. Targets are computed by the same LibraryPathPlanner the add/move flows use, so organize can never disagree with the paths the rest of the app computes. Fileless rows are excluded entirely (nothing on disk to move); rows whose BasePath equals a library root are refused at both preview and apply (the post-copy source delete would wipe the root). A populated target is only a hard conflict when it holds something worth protecting: a metadata-only husk (covers, .opf — leftovers from a removed release) that nothing in the DB references is classified as a replaceable stub and queued with an explicit ReplaceStubTarget flag persisted on the MoveJob (new migration). The move processor re-verifies the on-disk no-audio invariant before deleting the stub. "Nested one level too deep" rows (target is an ancestor of source — refused by the move queue) get a strongly-guarded one-click flatten (POST /library/organize/flatten): refuses unless every file under the target already lives under the source, so only empty wrapper directories are ever collapsed, and rebases BasePath + file paths in place on success. GET /library/move/summary aggregates the move queue (per-status counts, in-flight jobs, recent completed/failed tails, per-job file count/bytes) for a live MoveQueueStatusBanner shown in the modal and the Maintenance section; the modal also subscribes to MoveJobUpdate so per-job errors land next to the right book. Filesystem primitives (stub detection, flatten evaluate/execute) live in infrastructure behind a new IOrganizeFilesystem application contract, keeping the api/application layers filesystem-free per the architecture tests. Co-Authored-By: Claude Fable 5 --- .../domain/organize/MoveQueueStatusBanner.vue | 414 +++++ .../domain/organize/OrganizeLibraryModal.vue | 1203 +++++++++++++ .../settings/LibraryMaintenanceSection.vue | 114 ++ fe/src/services/api.ts | 41 + fe/src/types/index.ts | 127 ++ fe/src/views/settings/GeneralSettingsTab.vue | 2 + .../Library/LibraryController.Organize.cs | 75 + .../Features/Library/LibraryController.cs | 8 +- .../Library/LibraryMoveSummaryWorkflow.cs | 152 ++ .../Features/Library/LibraryOrganizeModels.cs | 181 ++ .../LibraryOrganizeSweepWorkflow.Helpers.cs | 355 ++++ .../Library/LibraryOrganizeSweepWorkflow.cs | 414 +++++ .../Startup/ListenarrWorkflowRegistration.cs | 2 + .../Audiobooks/Contracts/IMoveQueueService.cs | 2 +- .../Repositories/IMoveJobRepository.cs | 1 + .../Audiobooks/Jobs/MoveQueueService.cs | 7 +- .../Organizing/IOrganizeFilesystem.cs | 94 + listenarr.domain/Audiobooks/MoveJob.cs | 5 + .../Library/LibraryRegistrationExtensions.cs | 3 + .../Moving/MoveJobProcessor.TargetGate.cs | 79 + .../Library/Moving/MoveJobProcessor.cs | 35 +- .../Library/Organizing/OrganizeFilesystem.cs | 221 +++ ...AddReplaceStubTargetToMoveJobs.Designer.cs | 1551 +++++++++++++++++ ...02180000_AddReplaceStubTargetToMoveJobs.cs | 29 + .../ListenArrDbContextModelSnapshot.cs | 3 + .../Repositories/EfMoveJobRepository.cs | 7 + tests/Builders/ServiceCollectionBuilder.cs | 4 + .../LibraryController_OrganizeTests.cs | 744 ++++++++ .../Library/Moving/MoveJobProcessorTests.cs | 73 +- .../Organizing/OrganizeFilesystemTests.cs | 153 ++ 30 files changed, 6065 insertions(+), 34 deletions(-) create mode 100644 fe/src/components/domain/organize/MoveQueueStatusBanner.vue create mode 100644 fe/src/components/domain/organize/OrganizeLibraryModal.vue create mode 100644 fe/src/components/settings/LibraryMaintenanceSection.vue create mode 100644 listenarr.api/Features/Library/LibraryController.Organize.cs create mode 100644 listenarr.api/Features/Library/LibraryMoveSummaryWorkflow.cs create mode 100644 listenarr.api/Features/Library/LibraryOrganizeModels.cs create mode 100644 listenarr.api/Features/Library/LibraryOrganizeSweepWorkflow.Helpers.cs create mode 100644 listenarr.api/Features/Library/LibraryOrganizeSweepWorkflow.cs create mode 100644 listenarr.application/Audiobooks/Organizing/IOrganizeFilesystem.cs create mode 100644 listenarr.infrastructure/Library/Moving/MoveJobProcessor.TargetGate.cs create mode 100644 listenarr.infrastructure/Library/Organizing/OrganizeFilesystem.cs create mode 100644 listenarr.infrastructure/Persistence/Migrations/20260702180000_AddReplaceStubTargetToMoveJobs.Designer.cs create mode 100644 listenarr.infrastructure/Persistence/Migrations/20260702180000_AddReplaceStubTargetToMoveJobs.cs create mode 100644 tests/Features/Api/Features/Library/LibraryController_OrganizeTests.cs create mode 100644 tests/Features/Infrastructure/Library/Organizing/OrganizeFilesystemTests.cs diff --git a/fe/src/components/domain/organize/MoveQueueStatusBanner.vue b/fe/src/components/domain/organize/MoveQueueStatusBanner.vue new file mode 100644 index 000000000..10767896a --- /dev/null +++ b/fe/src/components/domain/organize/MoveQueueStatusBanner.vue @@ -0,0 +1,414 @@ + + + + + + + diff --git a/fe/src/components/domain/organize/OrganizeLibraryModal.vue b/fe/src/components/domain/organize/OrganizeLibraryModal.vue new file mode 100644 index 000000000..c4b992f8d --- /dev/null +++ b/fe/src/components/domain/organize/OrganizeLibraryModal.vue @@ -0,0 +1,1203 @@ + + + + + + diff --git a/fe/src/components/settings/LibraryMaintenanceSection.vue b/fe/src/components/settings/LibraryMaintenanceSection.vue new file mode 100644 index 000000000..09d4fb04d --- /dev/null +++ b/fe/src/components/settings/LibraryMaintenanceSection.vue @@ -0,0 +1,114 @@ + + + + + + diff --git a/fe/src/services/api.ts b/fe/src/services/api.ts index 91c699d90..8032d78e7 100644 --- a/fe/src/services/api.ts +++ b/fe/src/services/api.ts @@ -60,6 +60,10 @@ import type { RenamePreview, RenameOperation, RenameResult, + OrganizeLibraryPreview, + OrganizeLibraryApplyResult, + OrganizeFlattenResult, + MoveQueueSummary, } from '@/types' import { getStartupConfigCached, resetCache as resetStartupConfigCache } from './startupConfigCache' import { sessionTokenManager } from '@/utils/sessionToken' @@ -1238,6 +1242,43 @@ class ApiService { }) } + async getOrganizeLibraryPreview(): Promise { + return this.request(`/library/organize/preview`, { + method: 'GET', + }) + } + + async applyOrganizeLibrary(audiobookIds: number[]): Promise { + return this.request(`/library/organize/apply`, { + method: 'POST', + body: JSON.stringify({ audiobookIds }), + }) + } + + /** + * Collapse a single nested-one-level-too-deep row into its canonical parent + * folder. Only valid for `target_ancestor` invalid rows; the server + * re-validates and refuses anything else. + */ + async flattenOrganizeRow(audiobookId: number): Promise { + return this.request(`/library/organize/flatten`, { + method: 'POST', + body: JSON.stringify({ audiobookId }), + }) + } + + /** + * Read the persisted move-queue state — status counts plus the most-recent + * completed and failed jobs. Backs the in-modal and Maintenance-page + * progress banner. `recentLimit` is clamped to [0, 200] server-side. + */ + async getMoveQueueSummary(recentLimit = 5): Promise { + return this.request( + `/library/move/summary?recentLimit=${encodeURIComponent(String(recentLimit))}`, + { method: 'GET' }, + ) + } + async removeFromLibrary( id: number, options?: { deleteFiles?: boolean; deleteFolder?: boolean }, diff --git a/fe/src/types/index.ts b/fe/src/types/index.ts index 596251121..5548a5091 100644 --- a/fe/src/types/index.ts +++ b/fe/src/types/index.ts @@ -1109,3 +1109,130 @@ export interface RenameResult { error?: string renamedFiles: FileRenameResultItem[] } + +/** + * One audiobook in the organize-library preview. The server has bucketed + * each row into one of `already_canonical` / `will_move` / `collision` / + * `invalid_target` based on the configured FolderNamingPattern. + */ +export type OrganizePreviewStatus = + | 'already_canonical' + | 'will_move' + | 'collision' + | 'invalid_target' + +export interface OrganizePreviewRow { + id: number + title: string | null + author: string | null + currentPath: string | null + targetPath: string | null + fileCount: number + totalSize: number + status: OrganizePreviewStatus + /** Set when status is `collision` — the shared normalized target. */ + collisionKey: string | null + /** Set when status is `invalid_target` — the human-readable reason. */ + reason: string | null + /** + * Set when status is `invalid_target` — machine-readable companion to + * `reason`. One of the `OrganizeInvalidReasonCode` values the backend emits + * (`missing_title`, `missing_author`, `source_at_root`, `target_ancestor`, + * `target_exists`, `source_missing`, …). The UI groups invalid rows by this code. + */ + reasonCode: string | null + /** + * True only for `target_ancestor` rows that pass the backend's read-only + * flatten feasibility check. The UI shows the one-click "Flatten" action only + * when this is true. + */ + canFlatten: boolean + /** + * True for `will_move` rows whose target folder exists on disk but holds only + * leftover metadata (no audio, nothing referenced by the DB). The move will + * replace the stub. + */ + replacesStubTarget: boolean +} + +/** + * One row in the currently-processing / recent-completed / recent-failed + * tails of a `GET /library/move/summary` response. Mirrors `MoveJob` + * fields the summary endpoint projects, plus the audiobook's title and + * per-job file count / total bytes so the UI can show size context + * without a second round-trip. + */ +export interface MoveQueueJobSummary { + id: string + audiobookId: number + audiobookTitle: string | null + status: string + error: string | null + requestedPath: string | null + sourcePath: string | null + enqueuedAt: string + updatedAt: string | null + attemptCount: number + /** Number of tracked AudiobookFile rows for the referenced audiobook. */ + fileCount: number + /** Sum of AudiobookFile.Size (bytes) for the referenced audiobook. 0 when unknown. */ + totalBytes: number +} + +/** + * Shape returned by `GET /library/move/summary`. `total` is the sum of + * every non-purged MoveJob row; the per-status counts always sum to it + * (with anything unrecognized falling into `other` so the UI never + * silently drops a count). `queuedFiles` / `queuedBytes` aggregate + * across all Queued rows so the banner can show "queue: N files, M GB + * remaining" for ETA context. + */ +export interface MoveQueueSummary { + total: number + queued: number + processing: number + completed: number + failed: number + other: number + queuedFiles: number + queuedBytes: number + currentlyProcessing: MoveQueueJobSummary[] + recentCompleted: MoveQueueJobSummary[] + recentFailed: MoveQueueJobSummary[] +} + +export interface OrganizeLibraryPreview { + rows: OrganizePreviewRow[] + alreadyCanonicalCount: number + willMoveCount: number + collisionCount: number + invalidTargetCount: number +} + +export interface OrganizeApplySkipped { + audiobookId: number + reason: string +} + +export interface OrganizeFlattenResult { + success: boolean + filesMoved: number + newPath: string | null + error: string | null +} + +export interface OrganizeQueuedJob { + jobId: string + audiobookId: number + audiobookTitle: string | null + targetPath: string | null +} + +export interface OrganizeLibraryApplyResult { + queued: number + skipped: number + failedToQueue: number + queuedJobs: OrganizeQueuedJob[] + skippedDetails: OrganizeApplySkipped[] + warnings: string[] +} diff --git a/fe/src/views/settings/GeneralSettingsTab.vue b/fe/src/views/settings/GeneralSettingsTab.vue index 58e65e809..a1624e80c 100644 --- a/fe/src/views/settings/GeneralSettingsTab.vue +++ b/fe/src/views/settings/GeneralSettingsTab.vue @@ -56,6 +56,7 @@ @update:settings="(val) => Object.assign(localSettings, val)" @update:apiKey="(val) => emit('update:apiKey', val)" > + @@ -74,6 +75,7 @@ import DownloadSettingsSection from '@/components/settings/DownloadSettingsSecti import FeaturesSection from '@/components/settings/FeaturesSection.vue' import SearchSettingsSection from '@/components/settings/SearchSettingsSection.vue' import AuthenticationSection from '@/components/settings/AuthenticationSection.vue' +import LibraryMaintenanceSection from '@/components/settings/LibraryMaintenanceSection.vue' interface Props { settings: ApplicationSettings | null diff --git a/listenarr.api/Features/Library/LibraryController.Organize.cs b/listenarr.api/Features/Library/LibraryController.Organize.cs new file mode 100644 index 000000000..35c919e04 --- /dev/null +++ b/listenarr.api/Features/Library/LibraryController.Organize.cs @@ -0,0 +1,75 @@ +/* + * 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 . + */ + +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Api.Features.Library +{ + public partial class LibraryController + { + /// + /// Aggregate view of the background move queue (counts + in-flight + + /// recent tails) for the maintenance banner. + /// + /// How many recent completed/failed jobs to include (clamped to [0, 200]). + /// Cancellation token bound to the request. + [HttpGet("move/summary")] + public async Task GetMoveQueueSummary([FromQuery] int recentLimit = 25, CancellationToken ct = default) + { + return await _moveSummaryWorkflow.SummaryAsync(recentLimit, ct); + } + + /// + /// Preview the library-wide Organize sweep: bucket every audiobook + /// into already_canonical / will_move / collision / invalid_target + /// against the path its metadata computes to. Read-only. + /// + /// Cancellation token bound to the request. + [HttpGet("organize/preview")] + public async Task GetOrganizePreview(CancellationToken ct) + { + return await _organizeSweepWorkflow.PreviewAsync(ct); + } + + /// + /// Queue a background move for each confirmed preview row. Every id is + /// re-validated against live state before queuing; rows that no longer + /// qualify are skipped with a reason instead of aborting the rest. + /// + /// Audiobook ids confirmed in the preview UI. + /// Cancellation token bound to the request. + [HttpPost("organize/apply")] + public async Task ApplyOrganize([FromBody] OrganizeLibraryApplyRequest request, CancellationToken ct) + { + return await _organizeSweepWorkflow.ApplyAsync(request, ct); + } + + /// + /// Collapse a single "nested one level too deep" preview row into its + /// canonical parent folder. Strongly guarded — only empty wrapper + /// directories are ever collapsed. + /// + /// The audiobook id of the nested row. + /// Cancellation token bound to the request. + [HttpPost("organize/flatten")] + public async Task FlattenOrganize([FromBody] OrganizeFlattenRequest request, CancellationToken ct) + { + return await _organizeSweepWorkflow.FlattenAsync(request, ct); + } + } +} diff --git a/listenarr.api/Features/Library/LibraryController.cs b/listenarr.api/Features/Library/LibraryController.cs index 6f8134d5d..1d0d8b67b 100644 --- a/listenarr.api/Features/Library/LibraryController.cs +++ b/listenarr.api/Features/Library/LibraryController.cs @@ -40,6 +40,8 @@ public partial class LibraryController : ControllerBase private readonly LibraryPreviewPathWorkflow _previewPathWorkflow; private readonly LibraryQueryWorkflow _queryWorkflow; private readonly LibraryRenameWorkflow _renameWorkflow; + private readonly LibraryOrganizeSweepWorkflow _organizeSweepWorkflow; + private readonly LibraryMoveSummaryWorkflow _moveSummaryWorkflow; /// Initializes the library transport façade. public LibraryController( ILibraryListService libraryListService, @@ -55,7 +57,9 @@ public LibraryController( LibraryIdentifierWorkflow identifierWorkflow, LibraryPreviewPathWorkflow previewPathWorkflow, LibraryQueryWorkflow queryWorkflow, - LibraryRenameWorkflow renameWorkflow) + LibraryRenameWorkflow renameWorkflow, + LibraryOrganizeSweepWorkflow organizeSweepWorkflow, + LibraryMoveSummaryWorkflow moveSummaryWorkflow) { _libraryListService = libraryListService; _addWorkflow = addWorkflow; @@ -71,6 +75,8 @@ public LibraryController( _previewPathWorkflow = previewPathWorkflow; _queryWorkflow = queryWorkflow; _renameWorkflow = renameWorkflow; + _organizeSweepWorkflow = organizeSweepWorkflow; + _moveSummaryWorkflow = moveSummaryWorkflow; } /// diff --git a/listenarr.api/Features/Library/LibraryMoveSummaryWorkflow.cs b/listenarr.api/Features/Library/LibraryMoveSummaryWorkflow.cs new file mode 100644 index 000000000..d66d5d016 --- /dev/null +++ b/listenarr.api/Features/Library/LibraryMoveSummaryWorkflow.cs @@ -0,0 +1,152 @@ +/* + * 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 . + */ + +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Api.Features.Library +{ + /// + /// Aggregate view of the background move queue for the maintenance UI: + /// per-status counts, the jobs processing right now, and short tails of + /// recent completions/failures — each annotated with the audiobook's title + /// and file count/bytes so the banner can say "moving X (12 files, 3.4 GB)" + /// and "queue: N files, M GB remaining". + /// + public sealed class LibraryMoveSummaryWorkflow + { + private readonly IMoveJobRepository _moveJobRepository; + private readonly IAudiobookRepository _audiobookRepository; + private readonly IAudiobookFileRepository _audioFileRepository; + + public LibraryMoveSummaryWorkflow( + IMoveJobRepository moveJobRepository, + IAudiobookRepository audiobookRepository, + IAudiobookFileRepository audioFileRepository) + { + _moveJobRepository = moveJobRepository; + _audiobookRepository = audiobookRepository; + _audioFileRepository = audioFileRepository; + } + + public async Task SummaryAsync(int recentLimit, CancellationToken ct) + { + if (recentLimit < 0) recentLimit = 0; + if (recentLimit > 200) recentLimit = 200; + + var all = await _moveJobRepository.GetAllAsync(ct); + + var byStatus = all + .GroupBy(j => j.Status ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase); + + int CountOf(string s) => byStatus.TryGetValue(s, out var n) ? n : 0; + + var recentCompleted = all + .Where(j => string.Equals(j.Status, "Completed", StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(j => j.UpdatedAt ?? j.EnqueuedAt) + .Take(recentLimit) + .ToList(); + var recentFailed = all + .Where(j => string.Equals(j.Status, "Failed", StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(j => j.UpdatedAt ?? j.EnqueuedAt) + .Take(recentLimit) + .ToList(); + var processingNow = all + .Where(j => string.Equals(j.Status, "Processing", StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(j => j.UpdatedAt ?? j.EnqueuedAt) + .ToList(); + var queuedNow = all + .Where(j => string.Equals(j.Status, "Queued", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + // File counts + byte totals + titles for the in-scope audiobooks so + // the banner can render meaningful per-job and per-queue size info. + var inScopeAudiobookIds = new HashSet(); + foreach (var j in recentCompleted) inScopeAudiobookIds.Add(j.AudiobookId); + foreach (var j in recentFailed) inScopeAudiobookIds.Add(j.AudiobookId); + foreach (var j in processingNow) inScopeAudiobookIds.Add(j.AudiobookId); + foreach (var j in queuedNow) inScopeAudiobookIds.Add(j.AudiobookId); + + var fileStatsByAudiobookId = new Dictionary(); + var titlesByAudiobookId = new Dictionary(); + if (inScopeAudiobookIds.Count > 0) + { + var allFiles = await _audioFileRepository.GetAllAsync(ct); + fileStatsByAudiobookId = allFiles + .Where(f => inScopeAudiobookIds.Contains(f.AudiobookId)) + .GroupBy(f => f.AudiobookId) + .ToDictionary(g => g.Key, g => (g.Count(), g.Sum(f => f.Size ?? 0L))); + + var allAudiobooks = await _audiobookRepository.GetAllAsync(); + titlesByAudiobookId = allAudiobooks + .Where(a => inScopeAudiobookIds.Contains(a.Id)) + .ToDictionary(a => a.Id, a => a.Title); + } + + (int FileCount, long TotalBytes) StatsFor(int audiobookId) => + fileStatsByAudiobookId.TryGetValue(audiobookId, out var s) ? s : (0, 0L); + + object Project(MoveJob j) + { + var (fc, tb) = StatsFor(j.AudiobookId); + titlesByAudiobookId.TryGetValue(j.AudiobookId, out var title); + return new + { + id = j.Id, + audiobookId = j.AudiobookId, + audiobookTitle = title, + status = j.Status, + error = j.Error, + requestedPath = j.RequestedPath, + sourcePath = j.SourcePath, + enqueuedAt = j.EnqueuedAt, + updatedAt = j.UpdatedAt, + attemptCount = j.AttemptCount, + fileCount = fc, + totalBytes = tb, + }; + } + + var queuedFiles = 0; + var queuedBytes = 0L; + foreach (var j in queuedNow) + { + var (fc, tb) = StatsFor(j.AudiobookId); + queuedFiles += fc; + queuedBytes += tb; + } + + return new OkObjectResult(new + { + total = all.Count, + queued = CountOf("Queued"), + processing = CountOf("Processing"), + completed = CountOf("Completed"), + failed = CountOf("Failed"), + // Any status the server hasn't seen the producer use yet falls + // into a residual bucket so the FE never silently drops a count. + other = all.Count - CountOf("Queued") - CountOf("Processing") - CountOf("Completed") - CountOf("Failed"), + queuedFiles, + queuedBytes, + currentlyProcessing = processingNow.Select(Project).ToList(), + recentCompleted = recentCompleted.Select(Project).ToList(), + recentFailed = recentFailed.Select(Project).ToList(), + }); + } + } +} diff --git a/listenarr.api/Features/Library/LibraryOrganizeModels.cs b/listenarr.api/Features/Library/LibraryOrganizeModels.cs new file mode 100644 index 000000000..3b7f3b80e --- /dev/null +++ b/listenarr.api/Features/Library/LibraryOrganizeModels.cs @@ -0,0 +1,181 @@ +/* + * 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 . + */ + +namespace Listenarr.Api.Features.Library +{ + /// + /// Bucket assigned to each audiobook in the organize-library preview. + /// + public static class OrganizePreviewStatus + { + public const string AlreadyCanonical = "already_canonical"; + public const string WillMove = "will_move"; + public const string Collision = "collision"; + public const string InvalidTarget = "invalid_target"; + } + + /// + /// Machine-readable code accompanying an invalid_target row's + /// human-readable . Lets the UI + /// group rows by failure kind and pick the right "how to resolve" copy / + /// action without parsing the prose reason string (the prose may be + /// reworded freely without breaking the frontend or tests). + /// + public static class OrganizeInvalidReasonCode + { + /// Title metadata is blank — no canonical path can be built. + public const string MissingTitle = "missing_title"; + /// Author metadata is blank — no canonical path can be built. + public const string MissingAuthor = "missing_author"; + /// No FolderNamingPattern is configured. + public const string PatternNotConfigured = "pattern_not_configured"; + /// Current path lives outside every configured library root. + public const string OutsideRoot = "outside_root"; + /// The naming pattern rendered to an empty path for this row. + public const string EmptyPattern = "empty_pattern"; + /// BasePath equals a library root folder — re-scan needed. + public const string SourceAtRoot = "source_at_root"; + /// Target is an ancestor of source (move would flatten/nest-collapse). + public const string TargetAncestor = "target_ancestor"; + /// Target directory already exists on disk and contains files. + public const string TargetExists = "target_exists"; + /// + /// An ancestor/nested row whose source folder no longer exists on disk — + /// the record's files are gone. Re-scan the library or remove the record. + /// + public const string SourceMissing = "source_missing"; + } + + /// + /// One row in the organize-library preview. Computed by applying the + /// configured FolderNamingPattern to the audiobook's metadata and + /// comparing the resulting path against the current BasePath. + /// + public class OrganizePreviewRowDto + { + public int Id { get; set; } + public string? Title { get; set; } + public string? Author { get; set; } + public string? CurrentPath { get; set; } + public string? TargetPath { get; set; } + public int FileCount { get; set; } + public long TotalSize { get; set; } + /// One of . + public string Status { get; set; } = string.Empty; + /// + /// Set when is collision — the normalized + /// target path shared by multiple audiobooks. Lets the UI group + /// collision rows together. + /// + public string? CollisionKey { get; set; } + /// + /// Set when is invalid_target — the + /// reason the target couldn't be computed (e.g. "Missing author"). + /// + public string? Reason { get; set; } + /// + /// Set when is invalid_target — the + /// machine-readable companion to . One of + /// . The UI groups invalid rows + /// by this code and renders per-kind resolution guidance. + /// + public string? ReasonCode { get; set; } + /// + /// True only for invalid_target rows whose + /// is target_ancestor and that pass the read-only flatten + /// feasibility check (source exists on disk, target holds no foreign + /// files). The UI shows the one-click "Flatten" action only when this is + /// true, so it never offers a flatten the executor would refuse. + /// + public bool CanFlatten { get; set; } + /// + /// True for will_move rows whose target directory exists on disk + /// but holds only leftover metadata (no audio files, nothing referenced + /// by the DB). The move will delete and replace the stub instead of + /// refusing; the UI labels these so the operator knows the target isn't + /// pristine. + /// + public bool ReplacesStubTarget { get; set; } + } + + public class OrganizeLibraryPreviewDto + { + public List Rows { get; set; } = new(); + public int AlreadyCanonicalCount { get; set; } + public int WillMoveCount { get; set; } + public int CollisionCount { get; set; } + public int InvalidTargetCount { get; set; } + } + + /// + /// Apply-endpoint body. Caller passes the explicit ids confirmed in the + /// preview UI; the server re-validates each one before queuing. + /// + public class OrganizeLibraryApplyRequest + { + public List AudiobookIds { get; set; } = new(); + } + + public class OrganizeApplySkippedDto + { + public int AudiobookId { get; set; } + public string Reason { get; set; } = string.Empty; + } + + /// + /// One queued background move surfaced in the apply response. The + /// frontend uses to correlate SignalR + /// MoveJobUpdate events back to the specific audiobook so a + /// failure's error string lands next to the right book in the + /// results UI. + /// + public class OrganizeQueuedJobDto + { + public string JobId { get; set; } = string.Empty; + public int AudiobookId { get; set; } + public string? AudiobookTitle { get; set; } + public string? TargetPath { get; set; } + } + + /// + /// Body for the organize "flatten" action — collapse a single + /// nested-one-level-too-deep row into its canonical parent folder. + /// + public class OrganizeFlattenRequest + { + public int AudiobookId { get; set; } + } + + public class OrganizeFlattenResultDto + { + public bool Success { get; set; } + public int FilesMoved { get; set; } + public string? NewPath { get; set; } + public string? Error { get; set; } + } + + public class OrganizeLibraryApplyResultDto + { + public int Queued { get; set; } + public int Skipped { get; set; } + public int FailedToQueue { get; set; } + public List QueuedJobs { get; set; } = new(); + public List SkippedDetails { get; set; } = new(); + public List Warnings { get; set; } = new(); + } +} diff --git a/listenarr.api/Features/Library/LibraryOrganizeSweepWorkflow.Helpers.cs b/listenarr.api/Features/Library/LibraryOrganizeSweepWorkflow.Helpers.cs new file mode 100644 index 000000000..5f106571e --- /dev/null +++ b/listenarr.api/Features/Library/LibraryOrganizeSweepWorkflow.Helpers.cs @@ -0,0 +1,355 @@ +/* + * 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 . + */ + +using Listenarr.Application.Audiobooks.Organizing; +using Listenarr.Domain.Common; +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Api.Features.Library +{ + public sealed partial class LibraryOrganizeSweepWorkflow + { + /// + /// Collapse a single "nested one level too deep" row (organize-preview + /// reason code target_ancestor) into its canonical parent folder. + /// These rows — a historical import artifact where the files sit in a + /// redundant subfolder beneath their canonical folder — can't go through + /// the normal move queue because the target is an ancestor of the source, + /// which the queue refuses. The flatten is strongly guarded (see + /// ): it only collapses + /// empty wrapper directories and never touches a target that holds + /// foreign files. On success the audiobook's BasePath and file paths are + /// rebased in place so the DB matches the new on-disk layout. + /// + public async Task FlattenAsync(OrganizeFlattenRequest? request, CancellationToken ct) + { + if (request == null || request.AudiobookId <= 0) + { + return new BadRequestObjectResult(new OrganizeFlattenResultDto { Success = false, Error = "No audiobook id provided" }); + } + + var audiobooks = await _repo.GetByIdsWithFilesAsync(new List { request.AudiobookId }, ct); + var audiobook = audiobooks.FirstOrDefault(); + if (audiobook == null) + { + return new NotFoundObjectResult(new OrganizeFlattenResultDto { Success = false, Error = $"Audiobook {request.AudiobookId} not found" }); + } + + var settings = await _configurationService.GetApplicationSettingsAsync(); + var rootFolders = await _rootFolderService.GetAllAsync(); + + var currentPath = NormalizeOrganizePath(audiobook.BasePath); + if (string.IsNullOrEmpty(currentPath)) + { + return new BadRequestObjectResult(new OrganizeFlattenResultDto { Success = false, Error = "Audiobook has no current folder path." }); + } + + var (target, invalidReason, _) = ComputeOrganizeTarget(audiobook, settings, rootFolders); + if (!string.IsNullOrEmpty(invalidReason)) + { + return new BadRequestObjectResult(new OrganizeFlattenResultDto { Success = false, Error = invalidReason }); + } + + // Only the ancestor/nested case is a flatten. Re-validate against the + // live state rather than trusting the caller's snapshot. + if (!IsTargetAncestorOfSource(currentPath, target)) + { + return new BadRequestObjectResult(new OrganizeFlattenResultDto + { + Success = false, + Error = "This row is not a 'nested one level too deep' case; flatten only applies to those.", + }); + } + + var outcome = _organizeFilesystem.ExecuteFlatten(currentPath, target, Guid.NewGuid()); + if (!outcome.Success) + { + _logger.LogWarning("Organize flatten for audiobook {Id} ({Source} -> {Target}) refused: {Reason}", + audiobook.Id, LogRedaction.SanitizeFilePath(currentPath), LogRedaction.SanitizeFilePath(target), outcome.ErrorMessage); + return new BadRequestObjectResult(new OrganizeFlattenResultDto { Success = false, Error = outcome.ErrorMessage }); + } + + // Files are on disk at the canonical path now — rebase the DB record + // so a re-scan isn't required. + var newBase = NormalizeOrganizePath(target); + audiobook.BasePath = newBase; + if (audiobook.Files != null) + { + foreach (var file in audiobook.Files.Where(f => !string.IsNullOrWhiteSpace(f.Path))) + { + var fp = NormalizeOrganizePath(file.Path); + if (string.Equals(fp, currentPath, StringComparison.OrdinalIgnoreCase) || FileUtils.IsPathInsideOf(fp, currentPath)) + { + var rel = Path.GetRelativePath(currentPath, fp); + file.Path = NormalizeOrganizePath(Path.Combine(newBase, rel)); + } + } + } + if (!string.IsNullOrWhiteSpace(audiobook.FilePath)) + { + var fp = NormalizeOrganizePath(audiobook.FilePath); + if (string.Equals(fp, currentPath, StringComparison.OrdinalIgnoreCase) || FileUtils.IsPathInsideOf(fp, currentPath)) + { + var rel = Path.GetRelativePath(currentPath, fp); + audiobook.FilePath = NormalizeOrganizePath(Path.Combine(newBase, rel)); + } + } + + try + { + await _repo.SaveChangesAsync(ct); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, + "Flatten moved audiobook {Id} files to {Target} on disk but persisting the new paths failed; a re-scan will recover.", + audiobook.Id, LogRedaction.SanitizeFilePath(newBase)); + return new OkObjectResult(new OrganizeFlattenResultDto + { + Success = true, + FilesMoved = outcome.FilesMoved, + NewPath = newBase, + Error = "Files were flattened on disk but the database update failed — re-scan the library to reconcile.", + }); + } + + try + { + await _historyRepository.AddAsync(new History + { + AudiobookId = audiobook.Id, + AudiobookTitle = audiobook.Title, + EventType = "Organized", + Message = $"Flattened nested folder into canonical path ({outcome.FilesMoved} file(s))", + Source = "Organize", + Timestamp = DateTime.UtcNow, + NotificationSent = false, + }, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Non-fatal: failed to add history entry after flatten for audiobook {Id}", audiobook.Id); + } + + _logger.LogInformation("Flattened audiobook {Id} from {Source} into {Target} ({Files} files)", + audiobook.Id, LogRedaction.SanitizeFilePath(currentPath), LogRedaction.SanitizeFilePath(newBase), outcome.FilesMoved); + return new OkObjectResult(new OrganizeFlattenResultDto { Success = true, FilesMoved = outcome.FilesMoved, NewPath = newBase }); + } + + /// + /// Build the canonical target folder path for . + /// Delegates the pattern rendering to — + /// the same planner the add and move flows use — so the organize sweep + /// can never disagree with the paths the rest of the app computes. + /// Returns the (target, invalidReason, invalidReasonCode) triple; + /// a non-empty reason buckets the row as + /// . + /// + private (string Target, string? InvalidReason, string? InvalidReasonCode) ComputeOrganizeTarget( + Audiobook audiobook, + ApplicationSettings settings, + List rootFolders) + { + var firstAuthor = audiobook.Authors?.FirstOrDefault(a => !string.IsNullOrWhiteSpace(a)); + if (string.IsNullOrWhiteSpace(audiobook.Title)) + { + return (string.Empty, "Missing title", OrganizeInvalidReasonCode.MissingTitle); + } + if (string.IsNullOrWhiteSpace(firstAuthor)) + { + return (string.Empty, "Missing author", OrganizeInvalidReasonCode.MissingAuthor); + } + if (string.IsNullOrWhiteSpace(settings?.FolderNamingPattern)) + { + return (string.Empty, "FolderNamingPattern is not configured", OrganizeInvalidReasonCode.PatternNotConfigured); + } + + var root = ResolveOrganizeRoot(audiobook.BasePath, settings, rootFolders); + if (string.IsNullOrEmpty(root)) + { + return (string.Empty, "Audiobook is outside any configured library root", OrganizeInvalidReasonCode.OutsideRoot); + } + + var combined = LibraryPathPlanner.ComputeAudiobookBaseDirectoryFromPattern( + audiobook, root, settings.FolderNamingPattern, _fileNamingService); + if (string.IsNullOrWhiteSpace(combined)) + { + return (string.Empty, "Naming pattern produced an empty path", OrganizeInvalidReasonCode.EmptyPattern); + } + + return (NormalizeOrganizePath(combined), null, null); + } + + /// + /// Pick the canonical root for : the + /// longest configured root folder (or OutputPath) that contains + /// the current base path. Falls back to the default root, then to + /// settings.OutputPath, when the audiobook has no base path + /// yet. Returns empty when the audiobook lives outside every root. + /// + private static string ResolveOrganizeRoot( + string? basePath, + ApplicationSettings settings, + List rootFolders) + { + var configuredRoots = rootFolders + .Where(r => !string.IsNullOrWhiteSpace(r.Path)) + .Select(r => NormalizeOrganizePath(r.Path!)) + .ToList(); + if (!string.IsNullOrWhiteSpace(settings.OutputPath)) + { + var outputNormalized = NormalizeOrganizePath(settings.OutputPath); + if (!configuredRoots.Any(r => string.Equals(r, outputNormalized, StringComparison.OrdinalIgnoreCase))) + { + configuredRoots.Add(outputNormalized); + } + } + + if (string.IsNullOrWhiteSpace(basePath)) + { + var defaultRoot = rootFolders.FirstOrDefault(r => r.IsDefault)?.Path; + if (!string.IsNullOrWhiteSpace(defaultRoot)) return NormalizeOrganizePath(defaultRoot); + if (!string.IsNullOrWhiteSpace(settings.OutputPath)) return NormalizeOrganizePath(settings.OutputPath); + return configuredRoots.FirstOrDefault() ?? string.Empty; + } + + var current = NormalizeOrganizePath(basePath); + return configuredRoots + .Where(r => string.Equals(r, current, StringComparison.OrdinalIgnoreCase) || FileUtils.IsPathInsideOf(current, r)) + .OrderByDescending(r => r.Length) + .FirstOrDefault() ?? string.Empty; + } + + private static string NormalizeOrganizePath(string? path) + => string.IsNullOrWhiteSpace(path) ? string.Empty : FileUtils.NormalizeStoredPath(path); + + private static string NormalizeOrganizeKey(string path) + { + if (string.IsNullOrEmpty(path)) return string.Empty; + var trimmed = path.TrimEnd('/', '\\'); + return trimmed.ToUpperInvariant(); + } + + /// + /// Returns true if the audiobook's current path matches a configured + /// root folder exactly. Rows in this state can't be moved by the + /// organize-library flow: the "source" would be the library root + /// itself, and the post-copy delete of the source would wipe every + /// other audiobook on the same root. + /// + private static bool IsSourceAtRootFolder(string? currentPath, IEnumerable rootFolders) + { + if (string.IsNullOrWhiteSpace(currentPath)) return false; + var currentKey = NormalizeOrganizeKey(NormalizeOrganizePath(currentPath)); + if (string.IsNullOrEmpty(currentKey)) return false; + foreach (var rf in rootFolders) + { + if (string.IsNullOrWhiteSpace(rf?.Path)) continue; + var rootKey = NormalizeOrganizeKey(NormalizeOrganizePath(rf.Path)); + if (currentKey == rootKey) return true; + } + return false; + } + + /// + /// Returns true when and + /// normalize to the same key — the canonical-equality case used by the + /// already_canonical bucket. Bucketing the "target on disk exists with + /// content" case would otherwise misclassify these rows: the + /// audiobook's own files trivially make the target non-empty. + /// + private static bool IsCurrentPathEqualToTarget(string? currentPath, string target) + { + var currentKey = NormalizeOrganizeKey(NormalizeOrganizePath(currentPath)); + var targetKey = NormalizeOrganizeKey(NormalizeOrganizePath(target)); + return !string.IsNullOrEmpty(currentKey) && currentKey == targetKey; + } + + /// + /// Returns true when the move would flatten the source into one of its + /// own ancestors — e.g. source /audiobooks/Author/Title/Narrator + /// targeting /audiobooks/Author/Title after a pattern change that + /// removed a directory level. The move queue refuses this ("Source and + /// target paths overlap"); mirroring the check here keeps these rows out + /// of will_move so the preview reflects what apply would actually do. + /// + private static bool IsTargetAncestorOfSource(string? source, string target) + { + if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(target)) return false; + string sourceFull; + string targetFull; + try + { + sourceFull = Path.GetFullPath(source); + targetFull = Path.GetFullPath(target); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + // Unparseable path — let the apply-time guard surface it instead. + return false; + } + if (string.Equals(sourceFull, targetFull, StringComparison.OrdinalIgnoreCase)) return false; + var sep = Path.DirectorySeparatorChar; + var sourceWithSep = sourceFull.TrimEnd(sep) + sep; + var targetWithSep = targetFull.TrimEnd(sep) + sep; + return sourceWithSep.StartsWith(targetWithSep, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Returns true when the target directory exists on disk and is not + /// empty. The move queue refuses moves into populated targets; + /// surfacing the same condition at preview time keeps these rows out + /// of will_move. Callers must rule out the canonical-equality case + /// (target == current) before invoking this. + /// + private bool TargetExistsWithContent(string target) => _organizeFilesystem.TargetExistsWithContent(target); + + /// + /// True when an existing, populated organize target can be safely + /// replaced by the move: it holds no audio on disk (see + /// ), no tracked + /// file row lives at or under it, and no other audiobook's BasePath + /// points at or under it. The move processor re-checks the on-disk half + /// at execute time; the DB half is checked here because the processor is + /// pure file-system code. + /// + private bool IsReplaceableStubTarget( + string target, + int audiobookId, + IReadOnlyCollection allAudiobooks, + IReadOnlyCollection allFiles) + { + if (!_organizeFilesystem.IsMetadataStubDirectory(target)) return false; + + var key = NormalizeOrganizeKey(NormalizeOrganizePath(target)); + if (string.IsNullOrEmpty(key)) return false; + var prefix = key + "/"; + + bool AtOrUnderTarget(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return false; + var k = NormalizeOrganizeKey(NormalizeOrganizePath(path)); + return k == key || k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); + } + + if (allFiles.Any(f => AtOrUnderTarget(f.Path))) return false; + if (allAudiobooks.Any(a => a.Id != audiobookId && AtOrUnderTarget(a.BasePath))) return false; + return true; + } + } +} diff --git a/listenarr.api/Features/Library/LibraryOrganizeSweepWorkflow.cs b/listenarr.api/Features/Library/LibraryOrganizeSweepWorkflow.cs new file mode 100644 index 000000000..675e0952f --- /dev/null +++ b/listenarr.api/Features/Library/LibraryOrganizeSweepWorkflow.cs @@ -0,0 +1,414 @@ +/* + * 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 . + */ + +using Listenarr.Application.Audiobooks.Organizing; +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Api.Features.Library +{ + /// + /// Library-wide "Organize" sweep: bucket every audiobook against the + /// canonical path its metadata computes to (preview, read-only), then queue + /// per-book background moves for the rows the operator confirmed (apply). + /// A separate flatten action (see the Helpers partial) resolves the + /// "nested one level too deep" rows the move queue refuses. + /// + public sealed partial class LibraryOrganizeSweepWorkflow + { + private readonly IAudiobookRepository _repo; + private readonly IAudiobookFileRepository _audioFileRepository; + private readonly IHistoryRepository _historyRepository; + private readonly IRootFolderService _rootFolderService; + private readonly IConfigurationService _configurationService; + private readonly IFileNamingService _fileNamingService; + private readonly IMoveQueueService? _moveQueueService; + private readonly IOrganizeFilesystem _organizeFilesystem; + private readonly ILogger _logger; + + public LibraryOrganizeSweepWorkflow( + IAudiobookRepository repo, + IAudiobookFileRepository audioFileRepository, + IHistoryRepository historyRepository, + IRootFolderService rootFolderService, + IConfigurationService configurationService, + IFileNamingService fileNamingService, + IOrganizeFilesystem organizeFilesystem, + ILogger logger, + IMoveQueueService? moveQueueService = null) + { + _repo = repo; + _audioFileRepository = audioFileRepository; + _historyRepository = historyRepository; + _rootFolderService = rootFolderService; + _configurationService = configurationService; + _fileNamingService = fileNamingService; + _moveQueueService = moveQueueService; + _organizeFilesystem = organizeFilesystem; + _logger = logger; + } + + /// + /// Walk every audiobook and bucket it into one of: + /// already_canonical, will_move, collision, or + /// invalid_target. Read-only — no DB or filesystem changes. + /// + public async Task PreviewAsync(CancellationToken ct) + { + var settings = await _configurationService.GetApplicationSettingsAsync(); + var rootFolders = await _rootFolderService.GetAllAsync(); + + var allAudiobooks = await _repo.GetAllAsync(); + var allFiles = await _audioFileRepository.GetAllAsync(ct); + var filesByAudiobookId = allFiles + .GroupBy(f => f.AudiobookId) + .ToDictionary(g => g.Key, g => g.ToList()); + + // First pass: compute target per audiobook + initial bucket + // (invalid_target vs proposed). Collision detection happens after + // because it needs the full target map. + var rows = new List(allAudiobooks.Count); + var targetGroups = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var audiobook in allAudiobooks) + { + ct.ThrowIfCancellationRequested(); + var files = filesByAudiobookId.TryGetValue(audiobook.Id, out var fs) ? fs : new List(); + + // Rows with no tracked files have nothing to organize — whatever + // BasePath says, there's no on-disk content to move. Letting them + // through puts monitored-but-not-downloaded records in will_move, + // where each queued job then fails the source-path-exists check. + if (files.Count == 0) + { + continue; + } + + var currentPath = NormalizeOrganizePath(audiobook.BasePath); + var row = new OrganizePreviewRowDto + { + Id = audiobook.Id, + Title = audiobook.Title, + Author = audiobook.Authors?.FirstOrDefault(a => !string.IsNullOrWhiteSpace(a)), + CurrentPath = currentPath, + FileCount = files.Count, + TotalSize = files.Sum(f => f.Size ?? 0L), + }; + + // Source-at-root guard: rows whose BasePath equals a configured + // root folder (e.g. "/audiobooks") can't be moved — the post- + // copy delete of the source would obliterate every other + // audiobook on that root. + if (IsSourceAtRootFolder(currentPath, rootFolders)) + { + row.Status = OrganizePreviewStatus.InvalidTarget; + row.ReasonCode = OrganizeInvalidReasonCode.SourceAtRoot; + row.Reason = "Source path is the library root folder. Re-scan the library so this audiobook's BasePath points at its actual subfolder before organizing."; + rows.Add(row); + continue; + } + + var (target, invalidReason, invalidReasonCode) = ComputeOrganizeTarget(audiobook, settings, rootFolders); + if (!string.IsNullOrEmpty(invalidReason)) + { + row.Status = OrganizePreviewStatus.InvalidTarget; + row.Reason = invalidReason; + row.ReasonCode = invalidReasonCode; + rows.Add(row); + continue; + } + + // Apply-feasibility checks. The move queue refuses these at + // execute time; surfacing them here keeps un-moveable rows + // out of the will_move bucket so the user isn't told an + // operation is queued and then watches it fail one by one. + // Skip these for the canonical-equality case — if target + // equals current, target-exists-with-content is just the + // audiobook's own files, not a conflict. + if (!IsCurrentPathEqualToTarget(currentPath, target)) + { + if (IsTargetAncestorOfSource(currentPath, target)) + { + row.Status = OrganizePreviewStatus.InvalidTarget; + row.TargetPath = target; + // Disk-check the flatten so the preview never offers a + // one-click flatten the executor would refuse, and so an + // orphaned record (folder gone from disk) is surfaced as + // its own "files missing" case rather than a flatten-able + // nested row. + var (feasibility, _) = _organizeFilesystem.EvaluateFlatten(currentPath, target); + if (feasibility == FlattenFeasibility.SourceMissing) + { + row.ReasonCode = OrganizeInvalidReasonCode.SourceMissing; + row.Reason = "The record's folder no longer exists on disk — its files are gone. Re-scan the library to clear it, or remove the record."; + } + else + { + row.ReasonCode = OrganizeInvalidReasonCode.TargetAncestor; + row.Reason = "Target is an ancestor of source; the move would flatten the source into one of its own parent directories. Adjust the Folder Naming Pattern or relocate the source manually."; + row.CanFlatten = feasibility == FlattenFeasibility.Ok; + } + rows.Add(row); + continue; + } + if (TargetExistsWithContent(target)) + { + // A populated target is only a hard conflict when it + // holds something worth protecting. A metadata-only husk + // (covers, .opf, playlists — leftovers from a removed + // release) that nothing in the DB references can be + // replaced by the move; surface it as will_move with a + // flag instead of making the operator delete it by hand. + if (IsReplaceableStubTarget(target, audiobook.Id, allAudiobooks, allFiles)) + { + row.ReplacesStubTarget = true; + } + else + { + row.Status = OrganizePreviewStatus.InvalidTarget; + row.ReasonCode = OrganizeInvalidReasonCode.TargetExists; + row.Reason = "Target directory already exists on disk and contains files. Resolve the existing content (move or delete) before organizing this row."; + row.TargetPath = target; + rows.Add(row); + continue; + } + } + } + + row.TargetPath = target; + var key = NormalizeOrganizeKey(target); + if (!targetGroups.TryGetValue(key, out var members)) + { + members = new List(); + targetGroups[key] = members; + } + members.Add(audiobook.Id); + rows.Add(row); + } + + // Second pass: assign bucket from target groups. + var collisionKeys = targetGroups + .Where(kv => kv.Value.Count > 1) + .ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase); + + foreach (var row in rows) + { + if (row.Status == OrganizePreviewStatus.InvalidTarget) continue; + var key = NormalizeOrganizeKey(row.TargetPath ?? string.Empty); + if (collisionKeys.ContainsKey(key)) + { + row.Status = OrganizePreviewStatus.Collision; + row.CollisionKey = key; + continue; + } + row.Status = NormalizeOrganizeKey(row.CurrentPath ?? string.Empty) == key + ? OrganizePreviewStatus.AlreadyCanonical + : OrganizePreviewStatus.WillMove; + } + + var preview = new OrganizeLibraryPreviewDto + { + Rows = rows.OrderBy(r => r.Status switch + { + OrganizePreviewStatus.WillMove => 0, + OrganizePreviewStatus.Collision => 1, + OrganizePreviewStatus.InvalidTarget => 2, + _ => 3, + }).ThenBy(r => r.Author ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .ThenBy(r => r.Title ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .ToList(), + AlreadyCanonicalCount = rows.Count(r => r.Status == OrganizePreviewStatus.AlreadyCanonical), + WillMoveCount = rows.Count(r => r.Status == OrganizePreviewStatus.WillMove), + CollisionCount = rows.Count(r => r.Status == OrganizePreviewStatus.Collision), + InvalidTargetCount = rows.Count(r => r.Status == OrganizePreviewStatus.InvalidTarget), + }; + return new OkObjectResult(preview); + } + + /// + /// Queue a per-book move for each id the user confirmed in the + /// preview. Each id is re-validated against the live DB state before + /// queuing: ids that no longer compute to will_move, or that + /// collide with another id in the same request, are skipped with a + /// warning rather than aborting the rest. Missing ids return + /// BadRequest without queuing anything. + /// + public async Task ApplyAsync(OrganizeLibraryApplyRequest? request, CancellationToken ct) + { + if (request?.AudiobookIds == null || request.AudiobookIds.Count == 0) + { + return new BadRequestObjectResult(new { message = "No audiobook ids provided" }); + } + + if (_moveQueueService == null) + { + return new ObjectResult(new { message = "Move queue not available" }) + { + StatusCode = StatusCodes.Status503ServiceUnavailable + }; + } + + var requestedIds = request.AudiobookIds.Distinct().ToList(); + var audiobooks = await _repo.GetByIdsWithFilesAsync(requestedIds, ct); + var foundIds = audiobooks.Select(a => a.Id).ToHashSet(); + var missing = requestedIds.Where(id => !foundIds.Contains(id)).ToList(); + if (missing.Count > 0) + { + return new BadRequestObjectResult(new + { + message = $"Audiobook id(s) not found: {string.Join(", ", missing)}", + missingIds = missing, + }); + } + + var settings = await _configurationService.GetApplicationSettingsAsync(); + var rootFolders = await _rootFolderService.GetAllAsync(); + + // Re-compute targets for the selected set so the collision check + // reflects the live world, not the user's snapshot. + var targets = new Dictionary(); + var result = new OrganizeLibraryApplyResultDto(); + var byKey = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + // Full library state for the stub-target reference checks below — + // a target counts as a replaceable stub only if NOTHING in the DB + // points at it, which requires looking beyond the requested ids. + var allAudiobooksForStubCheck = await _repo.GetAllAsync(); + var allFilesForStubCheck = await _audioFileRepository.GetAllAsync(ct); + var replaceStubIds = new HashSet(); + + foreach (var audiobook in audiobooks) + { + // Defense in depth: even if the preview gate missed this or + // the operator's snapshot is stale, refuse to queue a move + // whose source is the library root (would wipe everything on + // delete-source). + if (IsSourceAtRootFolder(audiobook.BasePath, rootFolders)) + { + result.Skipped++; + result.SkippedDetails.Add(new OrganizeApplySkippedDto + { + AudiobookId = audiobook.Id, + Reason = "Source path is the library root folder; re-scan to correct BasePath before organizing.", + }); + continue; + } + + var (target, invalidReason, _) = ComputeOrganizeTarget(audiobook, settings, rootFolders); + if (!string.IsNullOrEmpty(invalidReason)) + { + result.Skipped++; + result.SkippedDetails.Add(new OrganizeApplySkippedDto { AudiobookId = audiobook.Id, Reason = invalidReason }); + continue; + } + + var currentKey = NormalizeOrganizeKey(NormalizeOrganizePath(audiobook.BasePath)); + var targetKey = NormalizeOrganizeKey(target); + if (currentKey == targetKey) + { + result.Skipped++; + result.SkippedDetails.Add(new OrganizeApplySkippedDto + { + AudiobookId = audiobook.Id, + Reason = "Already at canonical path", + }); + continue; + } + + // Mirror the preview's populated-target handling: replaceable + // metadata stubs get queued with the replace flag; anything + // else occupying the target is skipped here instead of failing + // one by one in the move queue. + if (!IsTargetAncestorOfSource(NormalizeOrganizePath(audiobook.BasePath), target) + && TargetExistsWithContent(target)) + { + if (IsReplaceableStubTarget(target, audiobook.Id, allAudiobooksForStubCheck, allFilesForStubCheck)) + { + replaceStubIds.Add(audiobook.Id); + } + else + { + result.Skipped++; + result.SkippedDetails.Add(new OrganizeApplySkippedDto + { + AudiobookId = audiobook.Id, + Reason = "Target directory already exists on disk and contains files", + }); + continue; + } + } + + targets[audiobook.Id] = target; + if (!byKey.TryGetValue(targetKey, out var ids)) + { + ids = new List(); + byKey[targetKey] = ids; + } + ids.Add(audiobook.Id); + } + + // Drop ids that collide with another id in the same request. + foreach (var (key, ids) in byKey) + { + if (ids.Count <= 1) continue; + foreach (var id in ids) + { + targets.Remove(id); + result.Skipped++; + result.SkippedDetails.Add(new OrganizeApplySkippedDto + { + AudiobookId = id, + Reason = $"Collides with audiobook id(s) {string.Join(", ", ids.Where(i => i != id))} at the same target", + }); + } + result.Warnings.Add($"Skipped {ids.Count} audiobooks that compute to the same target path '{key}'"); + } + + foreach (var audiobook in audiobooks) + { + if (!targets.TryGetValue(audiobook.Id, out var target)) continue; + try + { + var sourcePath = NormalizeOrganizePath(audiobook.BasePath); + var jobId = await _moveQueueService.EnqueueMoveAsync( + audiobook.Id, target, sourcePath, + replaceStubTarget: replaceStubIds.Contains(audiobook.Id)); + result.Queued++; + result.QueuedJobs.Add(new OrganizeQueuedJobDto + { + JobId = jobId.ToString(), + AudiobookId = audiobook.Id, + AudiobookTitle = audiobook.Title, + TargetPath = target, + }); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Failed to enqueue organize move for audiobook {AudiobookId}", audiobook.Id); + result.FailedToQueue++; + result.SkippedDetails.Add(new OrganizeApplySkippedDto + { + AudiobookId = audiobook.Id, + Reason = $"Failed to enqueue: {ex.Message}", + }); + } + } + + return new OkObjectResult(result); + } + } +} diff --git a/listenarr.api/Startup/ListenarrWorkflowRegistration.cs b/listenarr.api/Startup/ListenarrWorkflowRegistration.cs index 6bffbef75..9fa2d152c 100644 --- a/listenarr.api/Startup/ListenarrWorkflowRegistration.cs +++ b/listenarr.api/Startup/ListenarrWorkflowRegistration.cs @@ -86,6 +86,8 @@ private static IServiceCollection AddListenarrControllerWorkflows(this IServiceC services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/listenarr.application/Audiobooks/Contracts/IMoveQueueService.cs b/listenarr.application/Audiobooks/Contracts/IMoveQueueService.cs index a21ace821..ca1589609 100644 --- a/listenarr.application/Audiobooks/Contracts/IMoveQueueService.cs +++ b/listenarr.application/Audiobooks/Contracts/IMoveQueueService.cs @@ -20,7 +20,7 @@ namespace Listenarr.Application.Audiobooks.Contracts { public interface IMoveQueueService { - Task EnqueueMoveAsync(int audiobookId, string requestedPath, string? sourcePath = null); + Task EnqueueMoveAsync(int audiobookId, string requestedPath, string? sourcePath = null, bool replaceStubTarget = false); Task RequeueMoveAsync(Guid jobId); Task GetJobAsync(Guid id, CancellationToken cancellationToken = default); Task UpdateJobStatusAsync(Guid id, string status, string? error = null, CancellationToken cancellationToken = default); diff --git a/listenarr.application/Audiobooks/Contracts/Repositories/IMoveJobRepository.cs b/listenarr.application/Audiobooks/Contracts/Repositories/IMoveJobRepository.cs index 6191e31f1..295ea0eb1 100644 --- a/listenarr.application/Audiobooks/Contracts/Repositories/IMoveJobRepository.cs +++ b/listenarr.application/Audiobooks/Contracts/Repositories/IMoveJobRepository.cs @@ -22,6 +22,7 @@ public interface IMoveJobRepository { Task GetByIdAsync(Guid id, CancellationToken ct = default); Task> GetByStatusAsync(IEnumerable statuses, CancellationToken ct = default); + Task> GetAllAsync(CancellationToken ct = default); Task AddAsync(MoveJob job, CancellationToken ct = default); Task UpdateAsync(MoveJob job, CancellationToken ct = default); } diff --git a/listenarr.application/Audiobooks/Jobs/MoveQueueService.cs b/listenarr.application/Audiobooks/Jobs/MoveQueueService.cs index ced337aed..4fd7d460d 100644 --- a/listenarr.application/Audiobooks/Jobs/MoveQueueService.cs +++ b/listenarr.application/Audiobooks/Jobs/MoveQueueService.cs @@ -46,7 +46,7 @@ public MoveQueueService( public ChannelReader Reader => _channel.Reader; - public async Task EnqueueMoveAsync(int audiobookId, string requestedPath, string? sourcePath = null) + public async Task EnqueueMoveAsync(int audiobookId, string requestedPath, string? sourcePath = null, bool replaceStubTarget = false) { var deduplicationKey = BuildDeduplicationKey(audiobookId, requestedPath); var existingDb = await _persistence.GetActiveByKeyAsync(deduplicationKey); @@ -65,7 +65,8 @@ public async Task EnqueueMoveAsync(int audiobookId, string requestedPath, ActiveDeduplicationKey = deduplicationKey, EnqueuedAt = _timeProvider.GetUtcNow().UtcDateTime, Status = "Queued", - SourcePath = sourcePath + SourcePath = sourcePath, + ReplaceStubTarget = replaceStubTarget }; try @@ -190,7 +191,7 @@ public async Task UpdateJobStatusAsync( return null; } - var newJobId = await EnqueueMoveAsync(job.AudiobookId, job.RequestedPath ?? string.Empty, job.SourcePath); + var newJobId = await EnqueueMoveAsync(job.AudiobookId, job.RequestedPath ?? string.Empty, job.SourcePath, job.ReplaceStubTarget); _logger.LogInformation("Requeueing move job {OldJobId} as job {NewJobId} for audiobook {AudiobookId}", jobId, newJobId, job.AudiobookId); return newJobId; } diff --git a/listenarr.application/Audiobooks/Organizing/IOrganizeFilesystem.cs b/listenarr.application/Audiobooks/Organizing/IOrganizeFilesystem.cs new file mode 100644 index 000000000..8e876f3f9 --- /dev/null +++ b/listenarr.application/Audiobooks/Organizing/IOrganizeFilesystem.cs @@ -0,0 +1,94 @@ +/* + * 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 . + */ + +namespace Listenarr.Application.Audiobooks.Organizing +{ + /// + /// Why a flatten would (or wouldn't) succeed. + /// is the only value + /// proceeds on. + /// + public enum FlattenFeasibility + { + Ok, + SourcePathEmpty, + TargetPathEmpty, + SourceMissing, + SamePath, + NotAncestor, + TargetMissing, + NoUsableParent, + TargetHasForeignFiles, + NoFiles, + } + + public sealed class FlattenOutcome + { + public bool Success { get; init; } + public int FilesMoved { get; init; } + public string? ErrorMessage { get; init; } + public string? TempPathUsed { get; init; } + } + + /// + /// Filesystem primitives for the organize-library flow: metadata-stub + /// detection, populated-target checks, and the strongly-guarded flatten + /// used for "nested one level too deep" rows. Implemented in the + /// infrastructure layer (the only layer allowed to touch the filesystem); + /// consumed by the organize preview/apply workflows and the move-job + /// processor's execute-time guards. + /// + public interface IOrganizeFilesystem + { + /// + /// True when is an existing directory that + /// contains no audio files at any depth — a metadata-only husk (covers, + /// .opf, playlists) left behind by a removed release. On any read error + /// it is NOT treated as a stub. + /// + bool IsMetadataStubDirectory(string path); + + /// + /// True when the target directory exists on disk and is not empty. + /// + bool TargetExistsWithContent(string target); + + /// + /// Read-only feasibility check for : decides + /// whether collapsing into ancestor + /// is safe, and returns the file count, without + /// mutating the filesystem. Shared by the organize preview (to bucket a + /// nested row and decide whether to offer the one-click flatten) and by + /// so the preview's promise and the + /// executor's guard can never disagree. + /// + (FlattenFeasibility Feasibility, int FileCount) EvaluateFlatten(string source, string dest); + + /// Human-readable explanation of a non-Ok . + string DescribeFeasibility(FlattenFeasibility feasibility); + + /// + /// Flatten a redundantly-nested folder: collapse everything under + /// up into strict ancestor . + /// Strongly guarded via — refuses unless + /// every file under dest already lives under source, so only empty + /// wrapper directories are ever collapsed. + /// + FlattenOutcome ExecuteFlatten(string source, string dest, Guid jobId); + } +} diff --git a/listenarr.domain/Audiobooks/MoveJob.cs b/listenarr.domain/Audiobooks/MoveJob.cs index 101759b56..cfe77cd53 100644 --- a/listenarr.domain/Audiobooks/MoveJob.cs +++ b/listenarr.domain/Audiobooks/MoveJob.cs @@ -34,5 +34,10 @@ public class MoveJob // Optional source path snapshot provided at enqueue time. Persist this so jobs // remain durable and can be inspected / resumed across restarts. public string? SourcePath { get; set; } + // When true, the processor may delete-and-replace a target directory that + // exists but contains no audio (leftover metadata from a removed release). + // Set only by the organize flow after verifying nothing in the DB + // references the target. + public bool ReplaceStubTarget { get; set; } } } diff --git a/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs b/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs index 28c0bb0e8..6d5432f63 100644 --- a/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs +++ b/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs @@ -9,6 +9,7 @@ */ using Listenarr.Infrastructure.Persistence.Repositories; using Microsoft.Extensions.DependencyInjection; +using Listenarr.Application.Audiobooks.Organizing; namespace Listenarr.Infrastructure.DependencyInjection.Library; @@ -17,6 +18,8 @@ internal static class LibraryRegistrationExtensions public static IServiceCollection AddLibraryServices(this IServiceCollection services) { services.AddScoped(); + // Singleton: stateless filesystem primitives, consumed by the singleton MoveJobProcessor. + services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.TargetGate.cs b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.TargetGate.cs new file mode 100644 index 000000000..f14ad722b --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.TargetGate.cs @@ -0,0 +1,79 @@ +/* + * 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 . + */ +using Listenarr.Domain.Common; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Library.Moving +{ + public partial class MoveJobProcessor + { + /// + /// Gate on an existing target directory. Returns true when the move may + /// proceed. An empty target is reclaimed as-is. A populated target fails + /// the job — unless the organize flow explicitly flagged it as a + /// replaceable metadata stub (no audio at any depth, nothing in the DB + /// referencing it), in which case the on-disk no-audio invariant is + /// re-verified here and the stub is deleted so the move can replace it. + /// The DB half of the stub check happened at enqueue time; if audio + /// landed in the target since, refuse rather than destroy it. + /// + private async Task TryReclaimTargetAsync(MoveJob job, string target, CancellationToken stoppingToken) + { + if (!Directory.Exists(target)) + { + return true; + } + + var targetHasContent = Directory.EnumerateFileSystemEntries(target).Any(); + if (!targetHasContent) + { + // Target exists but is empty - safe to proceed (will use it instead of creating new) + logger.LogInformation("Target directory {Target} exists but is empty; proceeding with move", LogRedaction.SanitizeFilePath(target)); + return true; + } + + if (job.ReplaceStubTarget && organizeFilesystem.IsMetadataStubDirectory(target)) + { + logger.LogInformation( + "Move job {JobId}: target {Target} is a verified metadata-only stub; deleting it so the move can replace it", + job.Id, + LogRedaction.SanitizeFilePath(target)); + Directory.Delete(target, true); + return true; + } + + await moveQueueService.UpdateJobStatusAsync(job.Id, "Failed", "Target directory already exists and contains files", stoppingToken); + metrics.Increment("worker.move.job.failed"); + return false; + } + + private static bool IsFilesystemRoot(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + var fullPath = Path.GetFullPath(path) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var root = Path.GetPathRoot(fullPath)?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return !string.IsNullOrWhiteSpace(root) + && string.Equals(fullPath, root, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } + } +} diff --git a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.cs b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.cs index 0371a7656..6e2c0cf45 100644 --- a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.cs +++ b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.cs @@ -15,20 +15,22 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +using Listenarr.Application.Audiobooks.Organizing; using Listenarr.Application.Mapping; using Listenarr.Domain.Common; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Listenarr.Infrastructure.Library.Moving { - public class MoveJobProcessor( + public partial class MoveJobProcessor( IMoveQueueService moveQueueService, IToastService toastService, IScanQueueService scanQueueService, ILogger logger, IServiceScopeFactory scopeFactory, IHubContext hubContext, - IAppMetricsService metrics) : IMoveJobProcessor + IAppMetricsService metrics, + IOrganizeFilesystem organizeFilesystem) : IMoveJobProcessor { public async Task ProcessJobAsync(MoveJob job, CancellationToken stoppingToken) { @@ -130,18 +132,11 @@ public async Task ProcessJobAsync(MoveJob job, CancellationToken stoppingToken) if (!Directory.Exists(targetParent)) Directory.CreateDirectory(targetParent); - // Check if target exists and has content - only fail if it has files/folders we'd overwrite - if (Directory.Exists(target)) + // Populated targets fail unless the organize flow flagged a replaceable + // metadata stub (see the TargetGate partial for the reclaim rules). + if (!await TryReclaimTargetAsync(job, target, stoppingToken)) { - var targetHasContent = Directory.EnumerateFileSystemEntries(target).Any(); - if (targetHasContent) - { - await moveQueueService.UpdateJobStatusAsync(job.Id, "Failed", "Target directory already exists and contains files", stoppingToken); - metrics.Increment("worker.move.job.failed"); - return; - } - // Target exists but is empty - safe to proceed (will use it instead of creating new) - logger.LogInformation("Target directory {Target} exists but is empty; proceeding with move", LogRedaction.SanitizeFilePath(target)); + return; } // Create a temporary directory under the target parent @@ -482,19 +477,5 @@ await toastService.PublishToastAsync( metrics.Increment("worker.move.job.failed"); } } - - private static bool IsFilesystemRoot(string path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return false; - } - - var fullPath = Path.GetFullPath(path) - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - var root = Path.GetPathRoot(fullPath)?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return !string.IsNullOrWhiteSpace(root) - && string.Equals(fullPath, root, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); - } } } diff --git a/listenarr.infrastructure/Library/Organizing/OrganizeFilesystem.cs b/listenarr.infrastructure/Library/Organizing/OrganizeFilesystem.cs new file mode 100644 index 000000000..6c00be524 --- /dev/null +++ b/listenarr.infrastructure/Library/Organizing/OrganizeFilesystem.cs @@ -0,0 +1,221 @@ +/* + * 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 . + */ +using Listenarr.Application.Audiobooks.Organizing; +using Listenarr.Domain.Common; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Library.Organizing +{ + /// + /// Pure filesystem primitives shared by the organize-library flow (preview + /// classification in the API layer) and the move-job processor (execute-time + /// guards): metadata-stub detection and the strongly-guarded flatten used + /// for "nested one level too deep" rows. + /// + public sealed class OrganizeFilesystem(ILogger logger) : IOrganizeFilesystem + { + /// + /// True when is an existing directory that + /// contains no audio files at any depth — a metadata-only husk (covers, + /// .opf, playlists) left behind by a removed release. On any read error + /// we don't treat it as a stub. + /// + public bool IsMetadataStubDirectory(string path) + { + if (string.IsNullOrWhiteSpace(path)) return false; + try + { + if (!Directory.Exists(path)) return false; + return !Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories) + .Any(FileUtils.IsAudioFile); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + return false; + } + } + + /// + /// True when the target directory exists on disk and is not empty. On a + /// permission or IO error reading the target, returns false — the + /// apply-time guard surfaces the real problem instead of a guess here. + /// + public bool TargetExistsWithContent(string target) + { + if (string.IsNullOrWhiteSpace(target)) return false; + try + { + if (!Directory.Exists(target)) return false; + return Directory.EnumerateFileSystemEntries(target).Any(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + return false; + } + } + + /// + /// Read-only feasibility check for : decides + /// whether collapsing into ancestor + /// is safe, and returns the file count, without + /// touching the filesystem. Shared by the organize-preview (to bucket a + /// nested row and decide whether to offer the one-click flatten) and by + /// (which refuses anything but + /// ) so the preview's promise and the + /// executor's guard can never disagree. + /// + public (FlattenFeasibility Feasibility, int FileCount) EvaluateFlatten(string source, string dest) + { + if (string.IsNullOrWhiteSpace(source)) return (FlattenFeasibility.SourcePathEmpty, 0); + if (string.IsNullOrWhiteSpace(dest)) return (FlattenFeasibility.TargetPathEmpty, 0); + if (!Directory.Exists(source)) return (FlattenFeasibility.SourceMissing, 0); + + var sourceFull = TrimTrailingSeparator(Path.GetFullPath(source)); + var destFull = TrimTrailingSeparator(Path.GetFullPath(dest)); + var sep = Path.DirectorySeparatorChar; + + if (string.Equals(sourceFull, destFull, StringComparison.OrdinalIgnoreCase)) + return (FlattenFeasibility.SamePath, 0); + // dest must be a STRICT ancestor of source. If it isn't, this isn't a flatten. + if (!(sourceFull + sep).StartsWith(destFull + sep, StringComparison.OrdinalIgnoreCase)) + return (FlattenFeasibility.NotAncestor, 0); + if (!Directory.Exists(destFull)) + return (FlattenFeasibility.TargetMissing, 0); + var destParent = Path.GetDirectoryName(destFull); + if (string.IsNullOrEmpty(destParent) || IsFilesystemRoot(destParent)) + return (FlattenFeasibility.NoUsableParent, 0); + + // Guard: every FILE under dest must already live under source. If dest + // holds any file outside source's subtree, flattening would merge into + // populated content — refuse and leave it for manual resolution. + var sourceWithSep = sourceFull + sep; + try + { + var destFiles = Directory.EnumerateFiles(destFull, "*", SearchOption.AllDirectories) + .Select(f => TrimTrailingSeparator(Path.GetFullPath(f))) + .ToList(); + if (destFiles.Any(f => !f.StartsWith(sourceWithSep, StringComparison.OrdinalIgnoreCase))) + return (FlattenFeasibility.TargetHasForeignFiles, 0); + if (destFiles.Count == 0) + return (FlattenFeasibility.NoFiles, 0); + return (FlattenFeasibility.Ok, destFiles.Count); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + // Can't read the target — treat as not-feasible so neither the + // preview nor the executor offers a flatten it can't safely do. + return (FlattenFeasibility.TargetHasForeignFiles, 0); + } + } + + /// Human-readable explanation of a non-Ok . + public string DescribeFeasibility(FlattenFeasibility f) => f switch + { + FlattenFeasibility.SourcePathEmpty => "Source path is empty", + FlattenFeasibility.TargetPathEmpty => "Target path is empty", + FlattenFeasibility.SourceMissing => "Source path does not exist", + FlattenFeasibility.SamePath => "Source and target are the same path", + FlattenFeasibility.NotAncestor => "Target is not an ancestor of source; not a flatten", + FlattenFeasibility.TargetMissing => "Target directory does not exist", + FlattenFeasibility.NoUsableParent => "Target has no usable parent directory (looks like a library root)", + FlattenFeasibility.TargetHasForeignFiles => "Target directory contains files outside the nested source folder; resolve manually", + FlattenFeasibility.NoFiles => "Nothing to flatten: no files under the source folder", + _ => "Flatten is not available for this row", + }; + + /// + /// Flatten a redundantly-nested folder: collapse everything under + /// up into , where + /// is a strict ancestor of + /// (e.g. source /a/Title/Narrator/Title → dest /a/Title/Narrator). + /// This is the exact case the normal move queue refuses ("Source and + /// target paths overlap"). It backs the organize-library "nested one + /// level too deep" rows, which a normal move can't resolve. + /// + /// Strongly guarded via so no real file is + /// ever clobbered or lost: refuses unless dest is a strict ancestor of + /// source AND every file under dest already lives under source. That + /// guarantees dest holds nothing but source's subtree wrapped in redundant + /// (empty) directories, so the operation only collapses empty wrappers. The + /// move itself is two same-volume + /// renames via a sibling staging dir, with the source extracted before the + /// redundant dest shell is removed — so an interruption leaves the files + /// recoverable at the staging path rather than destroyed. + /// + public FlattenOutcome ExecuteFlatten(string source, string dest, Guid jobId) + { + var (feasibility, fileCount) = EvaluateFlatten(source, dest); + if (feasibility != FlattenFeasibility.Ok) + { + return new FlattenOutcome { Success = false, ErrorMessage = DescribeFeasibility(feasibility) }; + } + + var sourceFull = TrimTrailingSeparator(Path.GetFullPath(source)); + var destFull = TrimTrailingSeparator(Path.GetFullPath(dest)); + var destParent = Path.GetDirectoryName(destFull)!; + var tempName = Path.Combine(destParent, $"flatten.tmp-{jobId:N}"); + try + { + if (Directory.Exists(tempName)) Directory.Delete(tempName, true); + + // 1. Extract source's subtree to a sibling staging dir (atomic + // same-volume rename). dest now holds only empty wrappers. + Directory.Move(sourceFull, tempName); + + // 2. Remove the redundant dest shell (guaranteed file-free by the + // guard above), then promote the extracted subtree into dest. + Directory.Delete(destFull, true); + var finalParent = Path.GetDirectoryName(destFull); + if (!string.IsNullOrEmpty(finalParent) && !Directory.Exists(finalParent)) + { + Directory.CreateDirectory(finalParent); + } + Directory.Move(tempName, destFull); + + return new FlattenOutcome { Success = true, FilesMoved = fileCount, TempPathUsed = tempName }; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogError(ex, + "Flatten failed collapsing {Source} into {Dest}. Files may be staged at {Temp} — recover manually if needed.", + LogRedaction.SanitizeFilePath(sourceFull), + LogRedaction.SanitizeFilePath(destFull), + LogRedaction.SanitizeFilePath(tempName)); + return new FlattenOutcome + { + Success = false, + ErrorMessage = $"Flatten failed: {ex.Message}", + TempPathUsed = tempName, + }; + } + } + + private static string TrimTrailingSeparator(string path) => + path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + private static bool IsFilesystemRoot(string path) + { + if (string.IsNullOrWhiteSpace(path)) return false; + var fullPath = TrimTrailingSeparator(Path.GetFullPath(path)); + var root = Path.GetPathRoot(fullPath); + root = root == null ? null : TrimTrailingSeparator(root); + return !string.IsNullOrWhiteSpace(root) + && string.Equals(fullPath, root, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } + } +} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260702180000_AddReplaceStubTargetToMoveJobs.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260702180000_AddReplaceStubTargetToMoveJobs.Designer.cs new file mode 100644 index 000000000..824637b8d --- /dev/null +++ b/listenarr.infrastructure/Persistence/Migrations/20260702180000_AddReplaceStubTargetToMoveJobs.Designer.cs @@ -0,0 +1,1551 @@ +// +using System; +using Listenarr.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Listenarr.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ListenArrDbContext))] + [Migration("20260702180000_AddReplaceStubTargetToMoveJobs")] + partial class AddReplaceStubTargetToMoveJobs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("Listenarr.Domain.ActivityHistory.DownloadHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DownloadClient") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EventDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("ImportedAt") + .HasColumnType("TEXT"); + + b.Property("OutputPath") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Protocol") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("WasImported") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("DownloadId"); + + b.HasIndex("EventDate"); + + b.HasIndex("DownloadId", "EventType"); + + b.ToTable("DownloadHistories", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.ActivityHistory.History", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookExternalId") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("AudiobookTitle") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadId") + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Message") + .HasColumnType("TEXT"); + + b.Property("NotificationSent") + .HasColumnType("INTEGER"); + + b.Property("Outcome") + .HasColumnType("INTEGER"); + + b.Property("ParentEventId") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceTitle") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookExternalId"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("DownloadClientId"); + + b.HasIndex("DownloadId"); + + b.HasIndex("EventType"); + + b.HasIndex("Outcome"); + + b.HasIndex("Timestamp"); + + b.ToTable("History"); + }); + + modelBuilder.Entity("Listenarr.Domain.ActivityHistory.ProcessExecutionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Arguments") + .HasColumnType("TEXT"); + + b.Property("DurationMs") + .HasColumnType("INTEGER"); + + b.Property("ExitCode") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("Stderr") + .HasColumnType("TEXT"); + + b.Property("Stdout") + .HasColumnType("TEXT"); + + b.Property("TimedOut") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ProcessExecutionLogs"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Abridged") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("AuthorAsins") + .HasColumnType("TEXT"); + + b.Property("Authors") + .HasColumnType("TEXT"); + + b.Property("BasePath") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Edition") + .HasColumnType("TEXT"); + + b.Property("Explicit") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastSearchTime") + .HasColumnType("TEXT"); + + b.Property("Monitored") + .HasColumnType("INTEGER"); + + b.Property("Narrators") + .HasColumnType("TEXT"); + + b.Property("OpenLibraryId") + .HasColumnType("TEXT"); + + b.Property("PublishYear") + .HasColumnType("TEXT"); + + b.Property("PublishedDate") + .HasColumnType("TEXT"); + + b.Property("Publisher") + .HasColumnType("TEXT"); + + b.Property("Quality") + .HasColumnType("TEXT"); + + b.Property("QualityProfileId") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("Series") + .HasColumnType("TEXT"); + + b.Property("SeriesNumber") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LastSearchTime"); + + b.HasIndex("Monitored"); + + b.HasIndex("QualityProfileId"); + + b.ToTable("Audiobooks"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookExternalIdentifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("Region") + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ValueRaw") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("Type", "ValueNormalized"); + + b.HasIndex("AudiobookId", "Type", "IsPrimary"); + + b.HasIndex("Type", "ValueNormalized", "Region"); + + b.ToTable("AudiobookExternalIdentifiers", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("Bitrate") + .HasColumnType("INTEGER"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("Container") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DurationSeconds") + .HasColumnType("REAL"); + + b.Property("Format") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.ToTable("AudiobookFiles"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookSeriesMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("SeriesAsin") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SeriesNumber") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("AudiobookId", "IsPrimary"); + + b.HasIndex("AudiobookId", "SortOrder"); + + b.ToTable("AudiobookSeriesMemberships", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AuthorCacheEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("AuthorNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CatalogBooks") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastFetchedAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SimilarAuthors") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorAsin", "Region"); + + b.HasIndex("AuthorNameNormalized", "Region") + .IsUnique(); + + b.ToTable("AuthorCacheEntries"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MonitoredAuthor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("AuthorNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastCheckedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastSuccessfulSyncAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LastCheckedAt"); + + b.HasIndex("AuthorNameNormalized", "Region", "Language") + .IsUnique(); + + b.ToTable("MonitoredAuthors"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MonitoredSeries", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastCheckedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastSuccessfulSyncAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("SeriesNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LastCheckedAt"); + + b.HasIndex("SeriesNameNormalized", "Region", "Language") + .IsUnique(); + + b.ToTable("MonitoredSeries"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveDeduplicationKey") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("AttemptCount") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("EnqueuedAt") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("ReplaceStubTarget") + .HasColumnType("INTEGER"); + + b.Property("RequestedPath") + .HasColumnType("TEXT"); + + b.Property("SourcePath") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ActiveDeduplicationKey") + .IsUnique() + .HasFilter("\"ActiveDeduplicationKey\" IS NOT NULL"); + + b.HasIndex("AudiobookId", "Status"); + + b.ToTable("MoveJobs"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.QualityProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGroupNames") + .HasColumnType("TEXT") + .HasColumnName("CustomGroupNames"); + + b.Property("CutoffQuality") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("MaximumAge") + .HasColumnType("INTEGER"); + + b.Property("MaximumSize") + .HasColumnType("INTEGER"); + + b.Property("MinimumScore") + .HasColumnType("INTEGER"); + + b.Property("MinimumSeeders") + .HasColumnType("INTEGER"); + + b.Property("MinimumSize") + .HasColumnType("INTEGER"); + + b.Property("MustContain") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("MustContain"); + + b.Property("MustNotContain") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("MustNotContain"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PreferNewerReleases") + .HasColumnType("INTEGER"); + + b.Property("PreferredFormats") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("PreferredFormats"); + + b.Property("PreferredLanguages") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("PreferredLanguages"); + + b.PrimitiveCollection("PreferredWords") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Qualities") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Qualities"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("QualityProfiles"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsDefault") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("RootFolders", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.SeriesCacheEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CatalogBooks") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastFetchedAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("SeriesNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesAsin", "Region"); + + b.HasIndex("SeriesNameNormalized", "Region") + .IsUnique(); + + b.ToTable("SeriesCacheEntries"); + }); + + modelBuilder.Entity("Listenarr.Domain.Configuration.ApiConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BaseUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Headers") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("HeadersJson"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ParametersJson"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RateLimitPerMinute") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ApiConfigurations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Configuration.ApplicationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowedFileExtensions") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AudnexusApiUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CompletedFileAction") + .HasColumnType("INTEGER"); + + b.Property("DefaultSearchLanguage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DefaultSearchRegion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DiscordApplicationId") + .HasColumnType("TEXT"); + + b.Property("DiscordBotAvatar") + .HasColumnType("TEXT"); + + b.Property("DiscordBotEnabled") + .HasColumnType("INTEGER"); + + b.Property("DiscordBotToken") + .HasColumnType("TEXT"); + + b.Property("DiscordBotUsername") + .HasColumnType("TEXT"); + + b.Property("DiscordChannelId") + .HasColumnType("TEXT"); + + b.Property("DiscordCommandGroupName") + .HasColumnType("TEXT"); + + b.Property("DiscordCommandSubcommandName") + .HasColumnType("TEXT"); + + b.Property("DiscordGuildId") + .HasColumnType("TEXT"); + + b.Property("DownloadCompletionStabilitySeconds") + .HasColumnType("INTEGER"); + + b.Property("EnableAmazonSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableAudibleSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverArtDownload") + .HasColumnType("INTEGER"); + + b.Property("EnableMetadataProcessing") + .HasColumnType("INTEGER"); + + b.Property("EnableNotifications") + .HasColumnType("INTEGER"); + + b.Property("EnableOpenLibrarySearch") + .HasColumnType("INTEGER"); + + b.Property("EnabledNotificationTriggers") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExtractArchives") + .HasColumnType("INTEGER"); + + b.Property("FailedDownloadAutoSearch") + .HasColumnType("INTEGER"); + + b.Property("FailedDownloadHandlingEnabled") + .HasColumnType("INTEGER"); + + b.Property("FileNamingPattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FolderNamingPattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HistoryRetentionDays") + .HasColumnType("INTEGER"); + + b.Property("ImportBlacklistExtensions") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MaxConcurrentDownloads") + .HasColumnType("INTEGER"); + + b.Property("MissingSourceMaxRetries") + .HasColumnType("INTEGER"); + + b.Property("MissingSourceRetryInitialDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("MultiFileNamingPattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutputPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("ProwlarrApiKeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("ProwlarrPort") + .HasColumnType("INTEGER"); + + b.Property("ProwlarrTagFilter") + .HasColumnType("TEXT"); + + b.Property("ProwlarrUrl") + .HasColumnType("TEXT"); + + b.Property("ShowCompletedExternalDownloads") + .HasColumnType("INTEGER"); + + b.Property("UnmatchedScanConcurrency") + .HasColumnType("INTEGER"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("WebhookUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Webhooks") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ApplicationSettings"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.Download", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveAudiobookDeduplicationKey") + .HasColumnType("INTEGER"); + + b.Property("Album") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Artist") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DownloadPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DownloadedSize") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("ExpectedFileSize") + .HasColumnType("INTEGER"); + + b.Property("FinalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HistoryId") + .HasColumnType("INTEGER"); + + b.Property("ImportAttempts") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("ImportBlockMessages") + .HasColumnType("TEXT"); + + b.Property("ImportBlockReason") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastImportedAt") + .HasColumnType("TEXT"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Metadata"); + + b.Property("OriginalUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Progress") + .HasColumnType("TEXT"); + + b.Property("Publisher") + .HasColumnType("TEXT"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("Series") + .HasColumnType("TEXT"); + + b.Property("SeriesNumber") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TotalSize") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ActiveAudiobookDeduplicationKey") + .IsUnique() + .HasFilter("\"ActiveAudiobookDeduplicationKey\" IS NOT NULL"); + + b.HasIndex("CompletedAt"); + + b.HasIndex("DownloadClientId"); + + b.HasIndex("Status"); + + b.ToTable("Downloads"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.DownloadClientConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Password") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("RemoveCompletedDownloads") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("SettingsJson"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UseSSL") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DownloadClientConfigurations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.DownloadProcessingJob", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveDeduplicationKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT"); + + b.Property("DownloadId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("JobData") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("JobData"); + + b.Property("JobType") + .HasColumnType("INTEGER"); + + b.Property("MaxRetries") + .HasColumnType("INTEGER"); + + b.Property("NextRetryAt") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("ProcessingLog") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("SourcePath") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ActiveDeduplicationKey") + .IsUnique() + .HasFilter("\"ActiveDeduplicationKey\" IS NOT NULL"); + + b.HasIndex("Status"); + + b.HasIndex("DownloadId", "Status"); + + b.ToTable("DownloadProcessingJobs"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.RemotePathMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RemotePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("RemotePathMappings"); + }); + + modelBuilder.Entity("Listenarr.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Listenarr.Domain.Identity.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastAccessed") + .HasColumnType("TEXT"); + + b.Property("RememberMe") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Listenarr.Domain.Search.Indexer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalSettings") + .HasColumnType("TEXT"); + + b.Property("AnimeCategories") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("Categories") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnableAnimeStandardSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableAutomaticSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableInteractiveSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableRss") + .HasColumnType("INTEGER"); + + b.Property("Implementation") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastTestError") + .HasColumnType("TEXT"); + + b.Property("LastTestSuccessful") + .HasColumnType("INTEGER"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("MaximumSize") + .HasColumnType("INTEGER"); + + b.Property("MinimumAge") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Retention") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Indexers"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.QualityProfile", "QualityProfile") + .WithMany() + .HasForeignKey("QualityProfileId"); + + b.Navigation("QualityProfile"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookExternalIdentifier", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.Audiobook", null) + .WithMany("ExternalIdentifiers") + .HasForeignKey("AudiobookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookFile", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.Audiobook", "Audiobook") + .WithMany("Files") + .HasForeignKey("AudiobookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Audiobook"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookSeriesMembership", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.Audiobook", "Audiobook") + .WithMany("SeriesMemberships") + .HasForeignKey("AudiobookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Audiobook"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => + { + b.Navigation("ExternalIdentifiers"); + + b.Navigation("Files"); + + b.Navigation("SeriesMemberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260702180000_AddReplaceStubTargetToMoveJobs.cs b/listenarr.infrastructure/Persistence/Migrations/20260702180000_AddReplaceStubTargetToMoveJobs.cs new file mode 100644 index 000000000..7db0f4206 --- /dev/null +++ b/listenarr.infrastructure/Persistence/Migrations/20260702180000_AddReplaceStubTargetToMoveJobs.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Listenarr.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddReplaceStubTargetToMoveJobs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ReplaceStubTarget", + table: "MoveJobs", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ReplaceStubTarget", + table: "MoveJobs"); + } + } +} diff --git a/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs b/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs index d00df3bd5..2206e3c54 100644 --- a/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs +++ b/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs @@ -651,6 +651,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Error") .HasColumnType("TEXT"); + b.Property("ReplaceStubTarget") + .HasColumnType("INTEGER"); + b.Property("RequestedPath") .HasColumnType("TEXT"); diff --git a/listenarr.infrastructure/Persistence/Repositories/EfMoveJobRepository.cs b/listenarr.infrastructure/Persistence/Repositories/EfMoveJobRepository.cs index 50ad624e4..4d379080e 100644 --- a/listenarr.infrastructure/Persistence/Repositories/EfMoveJobRepository.cs +++ b/listenarr.infrastructure/Persistence/Repositories/EfMoveJobRepository.cs @@ -41,6 +41,13 @@ public async Task> GetByStatusAsync(IEnumerable statuses, .ToListAsync(ct); } + public async Task> GetAllAsync(CancellationToken ct = default) + { + return await _db.MoveJobs + .AsNoTracking() + .ToListAsync(ct); + } + public async Task AddAsync(MoveJob job, CancellationToken ct = default) { _db.MoveJobs.Add(job); diff --git a/tests/Builders/ServiceCollectionBuilder.cs b/tests/Builders/ServiceCollectionBuilder.cs index 08545d50a..6a9ba12b4 100644 --- a/tests/Builders/ServiceCollectionBuilder.cs +++ b/tests/Builders/ServiceCollectionBuilder.cs @@ -10,6 +10,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection.Extensions; +using Listenarr.Application.Audiobooks.Organizing; namespace Listenarr.Tests.Builders { @@ -201,6 +202,9 @@ private ServiceCollection BuildServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/tests/Features/Api/Features/Library/LibraryController_OrganizeTests.cs b/tests/Features/Api/Features/Library/LibraryController_OrganizeTests.cs new file mode 100644 index 000000000..a40bd5ac2 --- /dev/null +++ b/tests/Features/Api/Features/Library/LibraryController_OrganizeTests.cs @@ -0,0 +1,744 @@ +/* + * 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 . + */ +using Microsoft.AspNetCore.Mvc; +using Moq; +using Xunit; +using Listenarr.Api.Features.Library; +using Listenarr.Tests.Common; +using Listenarr.Tests.Builders; + +namespace Listenarr.Tests.Features.Api.Features.Library +{ + /// + /// Tests for the organize-library preview and apply endpoints. The + /// preview buckets every audiobook into already_canonical / will_move / + /// collision / invalid_target; apply queues a per-book move for each + /// confirmed id through MoveQueueService. + /// + public class LibraryController_OrganizeTests : BaseTests + { + private const string Root = "/audiobooks"; + private readonly Mock imageCacheServiceMock = new Mock(); + + public override async Task InitializeAsync() + { + _services.AddSingleton(imageCacheServiceMock.Object); + Init(); + await SeedSettingsAndRootAsync(); + } + + private async Task SeedSettingsAndRootAsync() + { + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithFolderNamingPattern("{Author}/{Title}") + .WithOutputPath(Root) + .Build()); + await _rootFolderRepository.AddAsync(new RootFolderBuilder() + .WithName("Library") + .WithPath(Root) + .WithIsDefault() + .Build()); + } + + /// + /// Attach a single tracked AudiobookFile to so + /// it survives GetOrganizePreview's fileless-row skip filter (rows + /// with zero tracked files are excluded from the preview entirely because + /// they have nothing on disk to move). Tests that exercise bucket + /// classification — already_canonical, will_move, collision, + /// invalid_target — call this so the row reaches the bucketing logic. + /// + private Task AttachFileAsync(Audiobook audiobook, string? path = null) + { + return _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() + .WithAudiobook(audiobook) + .WithPath(path ?? $"{audiobook.BasePath ?? Root}/dummy.m4b") + .Build()); + } + + [Fact] + public async Task Preview_NoLibrary_ReturnsEmptyBuckets() + { + var controller = _provider.GetRequiredService(); + var actionResult = await controller.GetOrganizePreview(CancellationToken.None) as OkObjectResult; + Assert.NotNull(actionResult); + var preview = Assert.IsType(actionResult!.Value); + Assert.Empty(preview.Rows); + Assert.Equal(0, preview.WillMoveCount); + Assert.Equal(0, preview.CollisionCount); + } + + [Fact] + public async Task Preview_RowAtCanonicalPath_IsAlreadyCanonical() + { + var canonicalPath = $"{Root}/Author A/Book One"; + var ab = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Book One", + Authors = new List { "Author A" }, + BasePath = canonicalPath, + }); + await AttachFileAsync(ab); + + var preview = await GetPreviewAsync(); + var row = Assert.Single(preview.Rows); + Assert.Equal(OrganizePreviewStatus.AlreadyCanonical, row.Status); + Assert.Equal(1, preview.AlreadyCanonicalCount); + Assert.Equal(0, preview.WillMoveCount); + } + + [Fact] + public async Task Preview_RowWithDifferentPath_IsWillMove() + { + var ab = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Book Two", + Authors = new List { "Author B" }, + BasePath = $"{Root}/Misplaced", + }); + await AttachFileAsync(ab); + + var preview = await GetPreviewAsync(); + var row = Assert.Single(preview.Rows); + Assert.Equal(OrganizePreviewStatus.WillMove, row.Status); + Assert.Equal($"{Root}/Author B/Book Two", row.TargetPath); + Assert.Equal(1, preview.WillMoveCount); + } + + [Fact] + public async Task Preview_TwoRowsTargetingSameFolder_AreCollision() + { + // Same {Author}/{Title} → same target path; different ASINs so the + // existing dedup tool wouldn't catch them. + var ab1 = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Same Title", + Authors = new List { "Same Author" }, + Asin = "B00COLL0001", + BasePath = $"{Root}/Old Place A", + }); + var ab2 = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Same Title", + Authors = new List { "Same Author" }, + Asin = "B00COLL0002", + BasePath = $"{Root}/Old Place B", + }); + await AttachFileAsync(ab1); + await AttachFileAsync(ab2); + + var preview = await GetPreviewAsync(); + Assert.Equal(2, preview.Rows.Count); + Assert.All(preview.Rows, r => Assert.Equal(OrganizePreviewStatus.Collision, r.Status)); + var key = preview.Rows[0].CollisionKey; + Assert.False(string.IsNullOrEmpty(key)); + Assert.All(preview.Rows, r => Assert.Equal(key, r.CollisionKey)); + Assert.Equal(2, preview.CollisionCount); + Assert.Equal(0, preview.WillMoveCount); + } + + [Fact] + public async Task Preview_RowWithNoFilesAndNoBasePath_IsSkipped() + { + // Monitored-but-not-downloaded record: tracked in the library, + // but nothing on disk and no destination set. Including it in + // will_move would cause an "Source path does not exist" failure + // when the user applies the default selection. + await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Wishlisted Book", + Authors = new List { "Author M" }, + BasePath = null, + }); + + var preview = await GetPreviewAsync(); + Assert.Empty(preview.Rows); + Assert.Equal(0, preview.WillMoveCount); + Assert.Equal(0, preview.AlreadyCanonicalCount); + Assert.Equal(0, preview.CollisionCount); + Assert.Equal(0, preview.InvalidTargetCount); + } + + [Fact] + public async Task Preview_RowWithNoFilesButBasePathStamped_IsSkipped() + { + // Live-data variant: an earlier ingestion path stamped some + // wishlist records' BasePath at the library root ("/audiobooks") + // even though no files ever arrived. The narrow filter — empty + // BasePath AND zero files — let these through; they appeared in + // will_move, got default-selected, and each failed the move + // queue's source-path-exists check. The loosened filter drops + // every fileless row regardless of BasePath value. + await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Wishlisted Book", + Authors = new List { "Author N" }, + BasePath = Root, // i.e. "/audiobooks" — stamped, but no file ever landed + }); + + var preview = await GetPreviewAsync(); + Assert.Empty(preview.Rows); + Assert.Equal(0, preview.WillMoveCount); + } + + [Fact] + public async Task Preview_MissingAuthor_IsInvalidTarget() + { + var ab = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Orphan Book", + Authors = new List(), + BasePath = $"{Root}/Somewhere", + }); + await AttachFileAsync(ab); + + var preview = await GetPreviewAsync(); + var row = Assert.Single(preview.Rows); + Assert.Equal(OrganizePreviewStatus.InvalidTarget, row.Status); + Assert.Equal("Missing author", row.Reason); + Assert.Equal(OrganizeInvalidReasonCode.MissingAuthor, row.ReasonCode); + Assert.Null(row.TargetPath); + Assert.Equal(1, preview.InvalidTargetCount); + } + + [Fact] + public async Task Preview_MissingTitle_IsInvalidTarget() + { + var ab = await _audiobookRepository.AddAsync(new Audiobook + { + Title = null, + Authors = new List { "Author C" }, + BasePath = $"{Root}/Somewhere", + }); + await AttachFileAsync(ab); + + var preview = await GetPreviewAsync(); + var row = Assert.Single(preview.Rows); + Assert.Equal(OrganizePreviewStatus.InvalidTarget, row.Status); + Assert.Equal("Missing title", row.Reason); + Assert.Equal(OrganizeInvalidReasonCode.MissingTitle, row.ReasonCode); + } + + [Fact] + public async Task Apply_EmptyBody_ReturnsBadRequest() + { + var controller = _provider.GetRequiredService(); + var result = await controller.ApplyOrganize(new OrganizeLibraryApplyRequest(), CancellationToken.None); + Assert.IsType(result); + } + + [Fact] + public async Task Apply_MissingIds_ReturnsBadRequestWithoutQueuing() + { + var ab = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Existing Book", + Authors = new List { "Real Author" }, + BasePath = $"{Root}/Somewhere", + }); + + var controller = _provider.GetRequiredService(); + var result = await controller.ApplyOrganize(new OrganizeLibraryApplyRequest + { + AudiobookIds = new List { ab.Id, 9999 }, + }, CancellationToken.None); + + Assert.IsType(result); + var jobs = await _moveJobRepository.GetByStatusAsync(new[] { "Queued", "Processing" }); + Assert.Empty(jobs); + } + + [Fact] + public async Task Apply_QueuesMovesForWillMoveRows() + { + var ab1 = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Move Me", + Authors = new List { "Author X" }, + BasePath = $"{Root}/Misplaced", + }); + var ab2 = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Move Me Too", + Authors = new List { "Author Y" }, + BasePath = $"{Root}/Other", + }); + + var controller = _provider.GetRequiredService(); + var actionResult = await controller.ApplyOrganize(new OrganizeLibraryApplyRequest + { + AudiobookIds = new List { ab1.Id, ab2.Id }, + }, CancellationToken.None) as OkObjectResult; + + Assert.NotNull(actionResult); + var result = Assert.IsType(actionResult!.Value); + Assert.Equal(2, result.Queued); + Assert.Equal(0, result.Skipped); + Assert.Equal(0, result.FailedToQueue); + Assert.Equal(2, result.QueuedJobs.Count); + Assert.Contains(result.QueuedJobs, j => j.AudiobookId == ab1.Id && j.TargetPath == $"{Root}/Author X/Move Me"); + Assert.Contains(result.QueuedJobs, j => j.AudiobookId == ab2.Id && j.TargetPath == $"{Root}/Author Y/Move Me Too"); + + var jobs = await _moveJobRepository.GetByStatusAsync(new[] { "Queued", "Processing" }); + Assert.Equal(2, jobs.Count); + Assert.Contains(jobs, j => j.AudiobookId == ab1.Id && j.RequestedPath == $"{Root}/Author X/Move Me"); + Assert.Contains(jobs, j => j.AudiobookId == ab2.Id && j.RequestedPath == $"{Root}/Author Y/Move Me Too"); + } + + [Fact] + public async Task Apply_SkipsRowsAlreadyAtCanonicalPath() + { + // User confirmed an id that — between preview and apply — + // got moved to its target by something else. Apply should + // skip with a warning, not enqueue a no-op. + var ab = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Already Done", + Authors = new List { "Author Z" }, + BasePath = $"{Root}/Author Z/Already Done", + }); + + var controller = _provider.GetRequiredService(); + var actionResult = await controller.ApplyOrganize(new OrganizeLibraryApplyRequest + { + AudiobookIds = new List { ab.Id }, + }, CancellationToken.None) as OkObjectResult; + + Assert.NotNull(actionResult); + var result = Assert.IsType(actionResult!.Value); + Assert.Equal(0, result.Queued); + Assert.Equal(1, result.Skipped); + Assert.Single(result.SkippedDetails, s => s.AudiobookId == ab.Id); + + var jobs = await _moveJobRepository.GetByStatusAsync(new[] { "Queued", "Processing" }); + Assert.Empty(jobs); + } + + [Fact] + public async Task Apply_SkipsCollidingIdsInSameRequest() + { + var ab1 = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Conflict", + Authors = new List { "Author Q" }, + BasePath = $"{Root}/Old A", + }); + var ab2 = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Conflict", + Authors = new List { "Author Q" }, + BasePath = $"{Root}/Old B", + }); + + var controller = _provider.GetRequiredService(); + var actionResult = await controller.ApplyOrganize(new OrganizeLibraryApplyRequest + { + AudiobookIds = new List { ab1.Id, ab2.Id }, + }, CancellationToken.None) as OkObjectResult; + + Assert.NotNull(actionResult); + var result = Assert.IsType(actionResult!.Value); + Assert.Equal(0, result.Queued); + Assert.Equal(2, result.Skipped); + Assert.NotEmpty(result.Warnings); + + var jobs = await _moveJobRepository.GetByStatusAsync(new[] { "Queued", "Processing" }); + Assert.Empty(jobs); + } + + [Fact] + public async Task Preview_TargetDirectoryOccupied_IsInvalidTarget() + { + // Real temp directory tree so the preview can actually stat the target. + using var tmp = new TempDirectory(); + var root = tmp.Path; + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithFolderNamingPattern("{Author}/{Title}") + .WithOutputPath(root) + .Build()); + // Replace the base-seed root (path = "/audiobooks") with one + // pointing at the temp directory so the preview's filesystem + // checks actually look at our test fixture. + foreach (var existing in await _rootFolderRepository.GetAllAsync()) + { + await _rootFolderRepository.RemoveAsync(existing.Id); + } + await _rootFolderRepository.AddAsync(new RootFolderBuilder() + .WithName("Library") + .WithPath(root) + .WithIsDefault() + .Build()); + + // Audiobook's current path is //Misplaced; target computes to + // //Author X/Occupied. Pre-populate the target with a foreign + // AUDIO file so it exists and holds protected content — apply would + // refuse, so the preview must surface this row as invalid_target + // rather than will_move. (A non-audio occupant is the replaceable- + // stub case, covered separately below.) + var currentPath = Path.Combine(root, "Misplaced"); + Directory.CreateDirectory(currentPath); + var targetPath = Path.Combine(root, "Author X", "Occupied"); + Directory.CreateDirectory(targetPath); + await File.WriteAllTextAsync(Path.Combine(targetPath, "foreign.m4b"), "stranger danger"); + + var ab = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Occupied", + Authors = new List { "Author X" }, + BasePath = currentPath, + }); + await AttachFileAsync(ab, $"{currentPath}/dummy.m4b"); + + var preview = await GetPreviewAsync(); + var row = Assert.Single(preview.Rows); + Assert.Equal(OrganizePreviewStatus.InvalidTarget, row.Status); + Assert.Contains("already exists", row.Reason ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.Equal(OrganizeInvalidReasonCode.TargetExists, row.ReasonCode); + // TargetPath is surfaced on the invalid row so the UI can show the + // occupied canonical path the operator needs to resolve. + Assert.Contains("Occupied", row.TargetPath ?? string.Empty); + Assert.Equal(0, preview.WillMoveCount); + Assert.Equal(1, preview.InvalidTargetCount); + } + + [Fact(DisplayName = "Preview: target occupied by metadata-only leftovers nothing references → will_move with replace flag")] + public async Task Preview_TargetOccupiedByUnreferencedStub_IsWillMoveWithReplaceFlag() + { + using var tmp = new TempDirectory(); + var root = tmp.Path; + await UseTempRootAsync(root); + + var currentPath = Path.Combine(root, "Misplaced"); + Directory.CreateDirectory(currentPath); + var targetPath = Path.Combine(root, "Author X", "Occupied"); + Directory.CreateDirectory(targetPath); + await File.WriteAllTextAsync(Path.Combine(targetPath, "Occupied - Author X.opf"), "stale metadata"); + await File.WriteAllTextAsync(Path.Combine(targetPath, "cover.jpg"), "stale cover"); + + var ab = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Occupied", + Authors = new List { "Author X" }, + BasePath = currentPath, + }); + await AttachFileAsync(ab, $"{currentPath}/dummy.m4b"); + + var preview = await GetPreviewAsync(); + var row = Assert.Single(preview.Rows); + Assert.Equal(OrganizePreviewStatus.WillMove, row.Status); + Assert.True(row.ReplacesStubTarget); + Assert.Equal(1, preview.WillMoveCount); + Assert.Equal(0, preview.InvalidTargetCount); + } + + [Fact(DisplayName = "Preview: stub target referenced by another record's BasePath → stays invalid_target")] + public async Task Preview_StubTargetReferencedByOtherBasePath_IsInvalidTarget() + { + using var tmp = new TempDirectory(); + var root = tmp.Path; + await UseTempRootAsync(root); + + var currentPath = Path.Combine(root, "Misplaced"); + Directory.CreateDirectory(currentPath); + var targetPath = Path.Combine(root, "Author X", "Occupied"); + Directory.CreateDirectory(targetPath); + await File.WriteAllTextAsync(Path.Combine(targetPath, "cover.jpg"), "stale cover"); + + var ab = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Occupied", + Authors = new List { "Author X" }, + BasePath = currentPath, + }); + await AttachFileAsync(ab, $"{currentPath}/dummy.m4b"); + + // Another record claims the target folder as its BasePath — even + // though the folder holds no audio, replacing it would orphan that + // record's anchor, so the row must stay invalid. + var other = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Claimant", + Authors = new List { "Author Y" }, + BasePath = targetPath, + }); + await AttachFileAsync(other, $"{targetPath}/claimant.m4b"); + // The claimant's tracked file is DB-only (not on disk) so the + // target still LOOKS like a stub on disk — the DB check is what + // must catch it. + + var preview = await GetPreviewAsync(); + var row = Assert.Single(preview.Rows, r => r.Id == ab.Id); + Assert.Equal(OrganizePreviewStatus.InvalidTarget, row.Status); + Assert.Equal(OrganizeInvalidReasonCode.TargetExists, row.ReasonCode); + } + + [Fact(DisplayName = "Apply: replaceable stub target queues the move (not skipped)")] + public async Task Apply_StubOccupiedTarget_QueuesMove() + { + using var tmp = new TempDirectory(); + var root = tmp.Path; + await UseTempRootAsync(root); + + var currentPath = Path.Combine(root, "Misplaced"); + Directory.CreateDirectory(currentPath); + var targetPath = Path.Combine(root, "Author X", "Occupied"); + Directory.CreateDirectory(targetPath); + await File.WriteAllTextAsync(Path.Combine(targetPath, "leftover.opf"), "stale"); + + var ab = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Occupied", + Authors = new List { "Author X" }, + BasePath = currentPath, + }); + await AttachFileAsync(ab, $"{currentPath}/dummy.m4b"); + + var result = await ApplyAsync(new[] { ab.Id }); + Assert.Equal(1, result.Queued); + Assert.Equal(0, result.Skipped); + + var jobs = await _moveJobRepository.GetByStatusAsync(new[] { "Queued", "Processing" }); + var job = Assert.Single(jobs, j => j.AudiobookId == ab.Id); + Assert.True(job.ReplaceStubTarget); + } + + [Fact(DisplayName = "Apply: target occupied by audio is skipped with a reason instead of queuing a doomed job")] + public async Task Apply_AudioOccupiedTarget_SkipsWithReason() + { + using var tmp = new TempDirectory(); + var root = tmp.Path; + await UseTempRootAsync(root); + + var currentPath = Path.Combine(root, "Misplaced"); + Directory.CreateDirectory(currentPath); + var targetPath = Path.Combine(root, "Author X", "Occupied"); + Directory.CreateDirectory(targetPath); + await File.WriteAllTextAsync(Path.Combine(targetPath, "protected.m4b"), "real audio"); + + var ab = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Occupied", + Authors = new List { "Author X" }, + BasePath = currentPath, + }); + await AttachFileAsync(ab, $"{currentPath}/dummy.m4b"); + + var result = await ApplyAsync(new[] { ab.Id }); + Assert.Equal(0, result.Queued); + Assert.Equal(1, result.Skipped); + Assert.Contains(result.SkippedDetails, d => d.AudiobookId == ab.Id + && (d.Reason ?? string.Empty).Contains("already exists", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Re-point the seeded root folder + settings at a real temp directory so + /// the preview's on-disk flatten-feasibility check (added with the + /// one-click flatten) actually stats the fixture. + /// + private async Task UseTempRootAsync(string root) + { + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithFolderNamingPattern("{Author}/{Title}") + .WithOutputPath(root) + .Build()); + foreach (var existing in await _rootFolderRepository.GetAllAsync()) + { + await _rootFolderRepository.RemoveAsync(existing.Id); + } + await _rootFolderRepository.AddAsync(new RootFolderBuilder() + .WithName("Library") + .WithPath(root) + .WithIsDefault() + .Build()); + } + + [Fact] + public async Task Preview_TargetIsAncestorOfSource_OfferableFlatten() + { + // Source //Author Y/Title/Narrator nests one level below its + // canonical target //Author Y/Title. The target holds nothing + // but the Narrator subtree, so the flatten is feasible and the + // preview marks the row CanFlatten. + using var tmp = new TempDirectory(); + var root = tmp.Path; + await UseTempRootAsync(root); + + var source = Path.Combine(root, "Author Y", "Title", "Narrator"); + Directory.CreateDirectory(source); + await File.WriteAllTextAsync(Path.Combine(source, "book.m4b"), "audio"); + + var ab = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Title", + Authors = new List { "Author Y" }, + BasePath = source, + }); + await AttachFileAsync(ab, Path.Combine(source, "book.m4b")); + + var preview = await GetPreviewAsync(); + var row = Assert.Single(preview.Rows); + Assert.Equal(OrganizePreviewStatus.InvalidTarget, row.Status); + Assert.Equal(OrganizeInvalidReasonCode.TargetAncestor, row.ReasonCode); + Assert.Contains("ancestor", row.Reason ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.Equal(Path.Combine(root, "Author Y", "Title"), row.TargetPath); + Assert.True(row.CanFlatten); + Assert.Equal(1, preview.InvalidTargetCount); + } + + [Fact] + public async Task Preview_TargetAncestor_TargetHasForeignFiles_NotFlattenable() + { + // Same nesting, but the canonical target also holds a foreign file + // directly — flattening would merge into populated content, so the + // executor would refuse. The preview must not offer the flatten. + using var tmp = new TempDirectory(); + var root = tmp.Path; + await UseTempRootAsync(root); + + var target = Path.Combine(root, "Author Y", "Title"); + var source = Path.Combine(target, "Narrator"); + Directory.CreateDirectory(source); + await File.WriteAllTextAsync(Path.Combine(source, "book.m4b"), "audio"); + await File.WriteAllTextAsync(Path.Combine(target, "stray.txt"), "foreign"); + + var ab = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Title", + Authors = new List { "Author Y" }, + BasePath = source, + }); + await AttachFileAsync(ab, Path.Combine(source, "book.m4b")); + + var preview = await GetPreviewAsync(); + var row = Assert.Single(preview.Rows); + Assert.Equal(OrganizeInvalidReasonCode.TargetAncestor, row.ReasonCode); + Assert.False(row.CanFlatten); + Assert.Equal(1, preview.InvalidTargetCount); + } + + [Fact] + public async Task Preview_TargetAncestor_SourceMissingOnDisk_IsSourceMissing() + { + // The record points at a nested path that no longer exists on disk + // (an orphaned record). It must surface as its own source_missing + // case — not a flatten-able nested row — and never offer the flatten. + using var tmp = new TempDirectory(); + var root = tmp.Path; + await UseTempRootAsync(root); + + // Note: the nested source directory is deliberately NOT created. + var source = Path.Combine(root, "Author Y", "Title", "Narrator"); + + var ab = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Title", + Authors = new List { "Author Y" }, + BasePath = source, + }); + await AttachFileAsync(ab, Path.Combine(source, "book.m4b")); + + var preview = await GetPreviewAsync(); + var row = Assert.Single(preview.Rows); + Assert.Equal(OrganizePreviewStatus.InvalidTarget, row.Status); + Assert.Equal(OrganizeInvalidReasonCode.SourceMissing, row.ReasonCode); + Assert.False(row.CanFlatten); + Assert.Equal(1, preview.InvalidTargetCount); + } + + [Fact] + public async Task Preview_TargetEqualsCurrentAndExists_IsAlreadyCanonical() + { + // Regression guard: the new target-occupied check must NOT fire + // for the already_canonical case, where the target on disk is + // populated with the audiobook's OWN files. + using var tmp = new TempDirectory(); + var root = tmp.Path; + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithFolderNamingPattern("{Author}/{Title}") + .WithOutputPath(root) + .Build()); + // Replace the base-seed root (path = "/audiobooks") with one + // pointing at the temp directory so the preview's filesystem + // checks actually look at our test fixture. + foreach (var existing in await _rootFolderRepository.GetAllAsync()) + { + await _rootFolderRepository.RemoveAsync(existing.Id); + } + await _rootFolderRepository.AddAsync(new RootFolderBuilder() + .WithName("Library") + .WithPath(root) + .WithIsDefault() + .Build()); + + var canonical = Path.Combine(root, "Author Z", "Settled"); + Directory.CreateDirectory(canonical); + await File.WriteAllTextAsync(Path.Combine(canonical, "book.m4b"), "real audiobook content"); + + var ab = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Settled", + Authors = new List { "Author Z" }, + BasePath = canonical, + }); + await AttachFileAsync(ab, $"{canonical}/book.m4b"); + + var preview = await GetPreviewAsync(); + var row = Assert.Single(preview.Rows); + Assert.Equal(OrganizePreviewStatus.AlreadyCanonical, row.Status); + Assert.Equal(1, preview.AlreadyCanonicalCount); + Assert.Equal(0, preview.InvalidTargetCount); + } + + private async Task GetPreviewAsync() + { + var controller = _provider.GetRequiredService(); + var actionResult = await controller.GetOrganizePreview(CancellationToken.None) as OkObjectResult; + Assert.NotNull(actionResult); + return Assert.IsType(actionResult!.Value); + } + + private async Task ApplyAsync(IEnumerable ids) + { + var controller = _provider.GetRequiredService(); + var actionResult = await controller.ApplyOrganize(new OrganizeLibraryApplyRequest + { + AudiobookIds = ids.ToList(), + }, CancellationToken.None) as OkObjectResult; + Assert.NotNull(actionResult); + return Assert.IsType(actionResult!.Value); + } + + private sealed class TempDirectory : IDisposable + { + public string Path { get; } + public TempDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"listenarr-organize-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + public void Dispose() + { + try { Directory.Delete(Path, recursive: true); } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + /* Best effort — test cleanup. */ + } + } + } + } +} diff --git a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorTests.cs b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorTests.cs index 5d2e53f27..c146d28c1 100644 --- a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorTests.cs @@ -90,13 +90,82 @@ public async Task ProcessJobAsync_ReplayedNoOpJob_RemainsCompleted() Assert.True(File.Exists(Path.Join(src, "book.m4b"))); } + [Fact] + public async Task ProcessJobAsync_StubTarget_ReplacedWhenFlagSet() + { + // Target holds only leftover metadata (no audio) and the organize + // flow queued the job with ReplaceStubTarget — the stub is deleted + // and replaced instead of failing the move. + var src = FileService.GetTempDirectory("move-stub-src"); + await FileService.GetFileAsync(src, "book.m4b", "audio"); + var dst = FileService.GetTempDirectory("move-stub-dst"); + await FileService.GetFileAsync(dst, "Title - Author.opf", "stale-metadata"); + await FileService.GetFileAsync(dst, "cover.jpg", "stale-cover"); + var audiobook = await _audiobookRepository.AddAsync(new Audiobook { Title = "Stub Replace", BasePath = src }); + var (queue, job) = await CreateQueuedMoveJobAsync(audiobook, dst, src, replaceStubTarget: true); + + var processor = _provider.GetRequiredService(); + await processor.ProcessJobAsync(job, CancellationToken.None); + + var updatedJob = await queue.GetJobAsync(job.Id); + Assert.Equal("Completed", updatedJob!.Status); + Assert.True(File.Exists(Path.Join(dst, "book.m4b"))); + Assert.False(File.Exists(Path.Join(dst, "Title - Author.opf"))); + Assert.False(File.Exists(Path.Join(dst, "cover.jpg"))); + Assert.False(Directory.Exists(src)); + } + + [Fact] + public async Task ProcessJobAsync_StubTarget_RefusedWithoutFlag() + { + var src = FileService.GetTempDirectory("move-stub-noflag-src"); + await FileService.GetFileAsync(src, "book.m4b", "audio"); + var dst = FileService.GetTempDirectory("move-stub-noflag-dst"); + await FileService.GetFileAsync(dst, "Title - Author.opf", "stale-metadata"); + var audiobook = await _audiobookRepository.AddAsync(new Audiobook { Title = "Stub No Flag", BasePath = src }); + var (queue, job) = await CreateQueuedMoveJobAsync(audiobook, dst, src); + + var processor = _provider.GetRequiredService(); + await processor.ProcessJobAsync(job, CancellationToken.None); + + var updatedJob = await queue.GetJobAsync(job.Id); + Assert.Equal("Failed", updatedJob!.Status); + Assert.True(File.Exists(Path.Join(src, "book.m4b"))); + Assert.True(File.Exists(Path.Join(dst, "Title - Author.opf"))); + } + + [Fact] + public async Task ProcessJobAsync_AudioInTarget_RefusedDespiteFlag() + { + // Re-verification at execute time: if the target holds audio (even + // nested), the replace flag must not destroy it. + var src = FileService.GetTempDirectory("move-stub-audio-src"); + await FileService.GetFileAsync(src, "book.m4b", "fresh-audio"); + var dst = FileService.GetTempDirectory("move-stub-audio-dst"); + await FileService.GetFileAsync(dst, "cover.jpg", "cover"); + var nested = Path.Join(dst, "Sub"); + Directory.CreateDirectory(nested); + await FileService.GetFileAsync(nested, "real-book.mp3", "protected-audio"); + var audiobook = await _audiobookRepository.AddAsync(new Audiobook { Title = "Stub Audio Guard", BasePath = src }); + var (queue, job) = await CreateQueuedMoveJobAsync(audiobook, dst, src, replaceStubTarget: true); + + var processor = _provider.GetRequiredService(); + await processor.ProcessJobAsync(job, CancellationToken.None); + + var updatedJob = await queue.GetJobAsync(job.Id); + Assert.Equal("Failed", updatedJob!.Status); + Assert.True(File.Exists(Path.Join(src, "book.m4b"))); + Assert.True(File.Exists(Path.Join(nested, "real-book.mp3"))); + } + private async Task<(IMoveQueueService Queue, MoveJob Job)> CreateQueuedMoveJobAsync( Audiobook audiobook, string requestedPath, - string sourcePath) + string sourcePath, + bool replaceStubTarget = false) { var queue = _provider.GetRequiredService(); - var jobId = await queue.EnqueueMoveAsync(audiobook.Id, requestedPath, sourcePath); + var jobId = await queue.EnqueueMoveAsync(audiobook.Id, requestedPath, sourcePath, replaceStubTarget); var job = await queue.GetJobAsync(jobId); Assert.NotNull(job); return (queue, job!); diff --git a/tests/Features/Infrastructure/Library/Organizing/OrganizeFilesystemTests.cs b/tests/Features/Infrastructure/Library/Organizing/OrganizeFilesystemTests.cs new file mode 100644 index 000000000..1120b108a --- /dev/null +++ b/tests/Features/Infrastructure/Library/Organizing/OrganizeFilesystemTests.cs @@ -0,0 +1,153 @@ +/* + * 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 . + */ +using Listenarr.Application.Audiobooks.Organizing; +using Listenarr.Infrastructure.Library.Organizing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Listenarr.Tests.Features.Infrastructure.Library.Organizing +{ + [Trait("Name", "OrganizeFilesystemTests")] + [Trait("Category", "Organize")] + public class OrganizeFilesystemTests : IDisposable + { + private readonly string _root; + private readonly OrganizeFilesystem _fs = new(NullLogger.Instance); + + public OrganizeFilesystemTests() + { + _root = Path.Combine(Path.GetTempPath(), "lna-organize-fs-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + public void Dispose() + { + try { if (Directory.Exists(_root)) Directory.Delete(_root, true); } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + } + + private string MakeDir(params string[] segments) + { + var path = Path.Combine(new[] { _root }.Concat(segments).ToArray()); + Directory.CreateDirectory(path); + return path; + } + + private static string WriteFile(string dir, string name, string content) + { + var path = Path.Combine(dir, name); + File.WriteAllText(path, content); + return path; + } + + [Fact(DisplayName = "IsMetadataStubDirectory: metadata-only true (even nested), audio false, missing false, empty true")] + public void IsMetadataStubDirectory_Classification() + { + var stub = MakeDir("stub"); + WriteFile(stub, "cover.jpg", "img"); + WriteFile(MakeDir("stub", "nested"), "book.opf", "meta"); + Assert.True(_fs.IsMetadataStubDirectory(stub)); + + var withAudio = MakeDir("with-audio"); + WriteFile(withAudio, "cover.jpg", "img"); + WriteFile(MakeDir("with-audio", "nested"), "book.mp3", "audio"); + Assert.False(_fs.IsMetadataStubDirectory(withAudio)); + + Assert.False(_fs.IsMetadataStubDirectory(Path.Combine(_root, "does-not-exist"))); + + var empty = MakeDir("empty"); + Assert.True(_fs.IsMetadataStubDirectory(empty)); + } + + [Fact(DisplayName = "EvaluateFlatten: nested wrapper with only source's files → Ok with file count")] + public void EvaluateFlatten_NestedWrapper_Ok() + { + // dest/Title holds nothing but the nested source subtree. + var dest = MakeDir("Author", "Title"); + var source = MakeDir("Author", "Title", "Narrator", "Title"); + WriteFile(source, "book.m4b", "audio"); + WriteFile(source, "cover.jpg", "img"); + + var (feasibility, count) = _fs.EvaluateFlatten(source, dest); + + Assert.Equal(FlattenFeasibility.Ok, feasibility); + Assert.Equal(2, count); + } + + [Fact(DisplayName = "EvaluateFlatten: dest holds a foreign file → TargetHasForeignFiles")] + public void EvaluateFlatten_ForeignFile_Refused() + { + var dest = MakeDir("Author", "Title"); + WriteFile(dest, "unrelated.mp3", "someone else's audio"); + var source = MakeDir("Author", "Title", "Nested"); + WriteFile(source, "book.m4b", "audio"); + + var (feasibility, _) = _fs.EvaluateFlatten(source, dest); + + Assert.Equal(FlattenFeasibility.TargetHasForeignFiles, feasibility); + } + + [Fact(DisplayName = "EvaluateFlatten: dest not an ancestor → NotAncestor; missing source → SourceMissing")] + public void EvaluateFlatten_NonFlattenShapes_Refused() + { + var a = MakeDir("A"); + var b = MakeDir("B"); + WriteFile(a, "book.m4b", "audio"); + Assert.Equal(FlattenFeasibility.NotAncestor, + _fs.EvaluateFlatten(a, b).Feasibility); + + Assert.Equal(FlattenFeasibility.SourceMissing, + _fs.EvaluateFlatten(Path.Combine(_root, "gone"), a).Feasibility); + } + + [Fact(DisplayName = "ExecuteFlatten: collapses the wrapper; files land at dest, wrapper gone")] + public void ExecuteFlatten_CollapsesWrapper() + { + var dest = MakeDir("Author", "Title"); + var source = MakeDir("Author", "Title", "Narrator", "Title"); + WriteFile(source, "book.m4b", "audio"); + var extras = MakeDir("Author", "Title", "Narrator", "Title", "extras"); + WriteFile(extras, "notes.txt", "notes"); + + var outcome = _fs.ExecuteFlatten(source, dest, Guid.NewGuid()); + + Assert.True(outcome.Success, outcome.ErrorMessage); + Assert.Equal(2, outcome.FilesMoved); + Assert.True(File.Exists(Path.Combine(dest, "book.m4b"))); + Assert.True(File.Exists(Path.Combine(dest, "extras", "notes.txt"))); + Assert.False(Directory.Exists(Path.Combine(dest, "Narrator"))); + } + + [Fact(DisplayName = "ExecuteFlatten: refuses when dest holds foreign files — nothing moved or deleted")] + public void ExecuteFlatten_ForeignFiles_NothingTouched() + { + var dest = MakeDir("Author", "Title"); + var foreign = WriteFile(dest, "keep-me.mp3", "protected"); + var source = MakeDir("Author", "Title", "Nested"); + var nestedFile = WriteFile(source, "book.m4b", "audio"); + + var outcome = _fs.ExecuteFlatten(source, dest, Guid.NewGuid()); + + Assert.False(outcome.Success); + Assert.True(File.Exists(foreign)); + Assert.True(File.Exists(nestedFile)); + } + } +}