Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
414 changes: 414 additions & 0 deletions fe/src/components/domain/organize/MoveQueueStatusBanner.vue

Large diffs are not rendered by default.

1,203 changes: 1,203 additions & 0 deletions fe/src/components/domain/organize/OrganizeLibraryModal.vue

Large diffs are not rendered by default.

114 changes: 114 additions & 0 deletions fe/src/components/settings/LibraryMaintenanceSection.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<!--
Listenarr - Audiobook Management System
Copyright (C) 2024-2026 Listenarr Contributors

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

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 <https://www.gnu.org/licenses/>.
-->
<template>
<div class="settings-section">
<h3><PhWrench /> Library Maintenance</h3>

<!-- Live move-queue status: in-flight moves, queue depth, recent failures. -->
<MoveQueueStatusBanner />

<div class="maintenance-action">
<div class="maintenance-action-text">
<strong>Organize library</strong>
<small>
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.
</small>
</div>
<button type="button" class="action-button" @click="showOrganizeModal = true">
<PhFolderOpen />
Organize library…
</button>
</div>

<OrganizeLibraryModal :visible="showOrganizeModal" @close="showOrganizeModal = false" />
</div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { PhWrench, PhFolderOpen } from '@phosphor-icons/vue'
import OrganizeLibraryModal from '@/components/domain/organize/OrganizeLibraryModal.vue'
import MoveQueueStatusBanner from '@/components/domain/organize/MoveQueueStatusBanner.vue'

const showOrganizeModal = ref(false)
</script>

<style scoped>
.settings-section {
margin-top: 2rem;
}

.settings-section h3 {
display: flex;
align-items: center;
gap: 0.5rem;
color: #fff;
font-size: 1.15rem;
font-weight: 500;
margin: 0 0 1rem;
}

.maintenance-action {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding: 1rem 1.25rem;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
margin-top: 1rem;
}

.maintenance-action-text {
display: flex;
flex-direction: column;
gap: 0.3rem;
}

.maintenance-action-text strong {
color: #fff;
}

.maintenance-action-text small {
color: #8a93a0;
max-width: 46rem;
line-height: 1.5;
}

.action-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.1rem;
background: rgba(var(--brand-rgb), 0.1);
border: 1px solid var(--brand-500);
border-radius: 6px;
color: var(--brand-500);
font-size: 0.95rem;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}

.action-button:hover {
background: rgba(var(--brand-rgb), 0.2);
}
</style>
41 changes: 41 additions & 0 deletions fe/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -1238,6 +1242,43 @@ class ApiService {
})
}

async getOrganizeLibraryPreview(): Promise<OrganizeLibraryPreview> {
return this.request<OrganizeLibraryPreview>(`/library/organize/preview`, {
method: 'GET',
})
}

async applyOrganizeLibrary(audiobookIds: number[]): Promise<OrganizeLibraryApplyResult> {
return this.request<OrganizeLibraryApplyResult>(`/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<OrganizeFlattenResult> {
return this.request<OrganizeFlattenResult>(`/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<MoveQueueSummary> {
return this.request<MoveQueueSummary>(
`/library/move/summary?recentLimit=${encodeURIComponent(String(recentLimit))}`,
{ method: 'GET' },
)
}

async removeFromLibrary(
id: number,
options?: { deleteFiles?: boolean; deleteFolder?: boolean },
Expand Down
127 changes: 127 additions & 0 deletions fe/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}
2 changes: 2 additions & 0 deletions fe/src/views/settings/GeneralSettingsTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
@update:settings="(val) => Object.assign(localSettings, val)"
@update:apiKey="(val) => emit('update:apiKey', val)"
></AuthenticationSection>
<LibraryMaintenanceSection />
</div>
<!-- settings-form -->
</div>
Expand All @@ -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
Expand Down
75 changes: 75 additions & 0 deletions listenarr.api/Features/Library/LibraryController.Organize.cs
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

using Microsoft.AspNetCore.Mvc;

namespace Listenarr.Api.Features.Library
{
public partial class LibraryController
{
/// <summary>
/// Aggregate view of the background move queue (counts + in-flight +
/// recent tails) for the maintenance banner.
/// </summary>
/// <param name="recentLimit">How many recent completed/failed jobs to include (clamped to [0, 200]).</param>
/// <param name="ct">Cancellation token bound to the request.</param>
[HttpGet("move/summary")]
public async Task<IActionResult> GetMoveQueueSummary([FromQuery] int recentLimit = 25, CancellationToken ct = default)
{
return await _moveSummaryWorkflow.SummaryAsync(recentLimit, ct);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="ct">Cancellation token bound to the request.</param>
[HttpGet("organize/preview")]
public async Task<IActionResult> GetOrganizePreview(CancellationToken ct)
{
return await _organizeSweepWorkflow.PreviewAsync(ct);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="request">Audiobook ids confirmed in the preview UI.</param>
/// <param name="ct">Cancellation token bound to the request.</param>
[HttpPost("organize/apply")]
public async Task<IActionResult> ApplyOrganize([FromBody] OrganizeLibraryApplyRequest request, CancellationToken ct)
{
return await _organizeSweepWorkflow.ApplyAsync(request, ct);
}

/// <summary>
/// Collapse a single "nested one level too deep" preview row into its
/// canonical parent folder. Strongly guarded — only empty wrapper
/// directories are ever collapsed.
/// </summary>
/// <param name="request">The audiobook id of the nested row.</param>
/// <param name="ct">Cancellation token bound to the request.</param>
[HttpPost("organize/flatten")]
public async Task<IActionResult> FlattenOrganize([FromBody] OrganizeFlattenRequest request, CancellationToken ct)
{
return await _organizeSweepWorkflow.FlattenAsync(request, ct);
}
}
}
Loading