+ Each audiobook below has been compared against its canonical
+ {Author}/{Title} path under the configured
+ Folder Naming Pattern. Confirm the selection then click Apply to
+ queue per-book moves. Collision and
+ Invalid target rows are surfaced for your awareness — they must be
+ resolved by hand (run the duplicates tool, or fix missing metadata) before they can be
+ organized.
+
+ These audiobooks compute to the same canonical folder. Organizing them would
+ clobber each other on disk. Resolve via the duplicates tool (same ASIN) or by
+ fixing the metadata difference (different ASIN but identical
+ {Author}/{Title}).
+
+ These rows can't be safely organized as-is. They're grouped by problem below, each
+ with how to resolve it. Open any record in a new tab to fix it, then re-run the
+ preview.
+
+ Automatic flatten isn't available here — the canonical folder already holds
+ other files. Move this book's files up a level by hand, then re-run the
+ preview.
+
+
+
+
+
+
+
+ Move {{ row.fileCount }} file{{ row.fileCount === 1 ? '' : 's' }} up into
+ the canonical folder and remove the empty subfolder?
+
+
+
+
+
+
+
+
+
+
+
+ Nothing to organize — every audiobook is already at its canonical folder.
+
+ Organize library
+
+ Preview every audiobook against the folder your naming pattern computes for it, then queue
+ background moves for the ones you confirm. Read-only until you apply.
+
+
+
+
+
+
+
+
+
+
+
+
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