diff --git a/fe/src/__tests__/AudiobookDetailView.spec.ts b/fe/src/__tests__/AudiobookDetailView.spec.ts
index 703fd1dc6..7df1caf14 100644
--- a/fe/src/__tests__/AudiobookDetailView.spec.ts
+++ b/fe/src/__tests__/AudiobookDetailView.spec.ts
@@ -23,7 +23,6 @@ import { useLibraryStore } from '@/stores/library'
import { useScanNotificationsStore } from '@/stores/scanNotifications'
import { useFilesystemReadinessStore } from '@/stores/filesystemReadiness'
import { apiService, ensureImageCached } from '@/services/api'
-import { signalRService } from '@/services/signalr'
import AudiobookDetailViewCmp from '@/views/library/AudiobookDetailView.vue'
const routerPushMock = vi.fn()
// Mock useRoute to provide params for the detail view
@@ -38,6 +37,7 @@ vi.mock('@/services/api', () => ({
getImageUrl: vi.fn((url: string) => url || 'https://via.placeholder.com/300x450?text=No+Image'),
getQualityProfiles: vi.fn(async () => []),
getLibrary: vi.fn(async () => []),
+ getAudiobook: vi.fn(async () => undefined),
scanAudiobook: vi.fn(),
getWeakStorageMissingFiles: vi.fn(async () => ({ items: [] })),
confirmWeakStorageMissingFiles: vi.fn(),
@@ -329,10 +329,7 @@ describe('AudiobookDetailView image recache behavior', () => {
const wrapper = mount(AudiobookDetailViewCmp, { global: { plugins: [pinia] } })
await new Promise((resolve) => setTimeout(resolve, 10))
- const scanCallback = vi.mocked(signalRService.onScanJobUpdate).mock.calls[0]?.[0] as
- | ((job: { audiobookId: number; status: string }) => void)
- | undefined
- expect(scanCallback).toBeDefined()
+ const scanNotificationsStore = useScanNotificationsStore()
let resolveOlder!: (value: {
scanToken: string
@@ -349,8 +346,19 @@ describe('AudiobookDetailView image recache behavior', () => {
.mockImplementationOnce(() => older)
.mockImplementationOnce(() => newer)
- scanCallback!({ audiobookId: 5, status: 'Completed' })
- scanCallback!({ audiobookId: 5, status: 'Completed' })
+ scanNotificationsStore.registerManualScan('older-scan', 5)
+ scanNotificationsStore.applyUpdate({
+ jobId: 'older-scan',
+ audiobookId: 5,
+ status: 'Completed',
+ })
+ await new Promise((resolve) => setTimeout(resolve, 2))
+ scanNotificationsStore.registerManualScan('newer-scan', 5)
+ scanNotificationsStore.applyUpdate({
+ jobId: 'newer-scan',
+ audiobookId: 5,
+ status: 'Completed',
+ })
resolveNewer({
scanToken: 'new-token',
diff --git a/fe/src/__tests__/UnmatchedFilesModal.spec.ts b/fe/src/__tests__/UnmatchedFilesModal.spec.ts
index 2d9c1498a..6abbea09a 100644
--- a/fe/src/__tests__/UnmatchedFilesModal.spec.ts
+++ b/fe/src/__tests__/UnmatchedFilesModal.spec.ts
@@ -111,4 +111,113 @@ describe('UnmatchedFilesModal filesystem readiness', () => {
expect(document.body.querySelector('add-library-modal-stub')).toBeNull()
wrapper.unmount()
})
+
+ it('shows cached partial-scan warnings even when no unmatched items were found', async () => {
+ const pinia = createPinia()
+ setActivePinia(pinia)
+ useFilesystemReadinessStore().readiness = {
+ isReady: true,
+ status: 'ready',
+ databaseConnected: true,
+ migrationsCurrent: true,
+ errorCode: null,
+ filesystemReady: true,
+ filesystemStatus: 'Ready',
+ filesystemPhase: null,
+ filesystemErrorCode: null,
+ filesystemErrorMessage: null,
+ }
+ vi.mocked(apiService.getSavedUnmatchedFiles).mockResolvedValueOnce({
+ items: [],
+ lastScannedAt: new Date().toISOString(),
+ warnings: ['One path could not be read and was skipped.'],
+ })
+ const rootFolder = {
+ id: 7,
+ name: 'Library',
+ path: 'C:\\library',
+ isDefault: true,
+ } as unknown as RootFolder
+
+ const wrapper = mount(UnmatchedFilesModal, {
+ props: { isOpen: false, rootFolder },
+ attachTo: document.body,
+ global: { plugins: [pinia], stubs: { AddLibraryModal: true } },
+ })
+ await wrapper.setProps({ isOpen: true })
+ await flushPromises()
+
+ expect(document.body.textContent).toContain('One path could not be read and was skipped.')
+ expect(document.body.textContent).toContain('All files are in your library')
+ wrapper.unmount()
+ })
+
+ it('polls a scan to completion when the SignalR terminal event is missed', async () => {
+ vi.useFakeTimers()
+ try {
+ const pinia = createPinia()
+ setActivePinia(pinia)
+ useFilesystemReadinessStore().readiness = {
+ isReady: true,
+ status: 'ready',
+ databaseConnected: true,
+ migrationsCurrent: true,
+ errorCode: null,
+ filesystemReady: true,
+ filesystemStatus: 'Ready',
+ filesystemPhase: null,
+ filesystemErrorCode: null,
+ filesystemErrorMessage: null,
+ }
+ vi.mocked(apiService.getSavedUnmatchedFiles).mockResolvedValueOnce({
+ items: [],
+ lastScannedAt: undefined,
+ warnings: [],
+ })
+ vi.mocked(apiService.scanUnmatchedFiles).mockResolvedValueOnce({ jobId: 'scan-job-7' })
+ vi.mocked(apiService.getUnmatchedResults)
+ .mockResolvedValueOnce({
+ jobId: 'scan-job-7',
+ status: 'Processing',
+ items: [],
+ warnings: [],
+ })
+ .mockResolvedValue({
+ jobId: 'scan-job-7',
+ status: 'Completed',
+ items: [],
+ warnings: ['One path could not be read and was skipped.'],
+ })
+ const rootFolder = {
+ id: 7,
+ name: 'Library',
+ path: 'C:\\library',
+ isDefault: true,
+ } as unknown as RootFolder
+
+ const wrapper = mount(UnmatchedFilesModal, {
+ props: { isOpen: false, rootFolder },
+ attachTo: document.body,
+ global: { plugins: [pinia], stubs: { AddLibraryModal: true } },
+ })
+ await wrapper.setProps({ isOpen: true })
+ await flushPromises()
+ const scan = Array.from(document.body.querySelectorAll('button')).find(
+ (button) => button.textContent?.trim() === 'Scan',
+ )
+ expect(scan).toBeTruthy()
+ scan!.click()
+ await flushPromises()
+ expect(document.body.textContent).toContain('Scanning')
+
+ await vi.advanceTimersByTimeAsync(2500)
+ await flushPromises()
+
+ expect(document.body.textContent).toContain('All files are in your library')
+ expect(document.body.textContent).toContain('One path could not be read and was skipped.')
+ wrapper.unmount()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
})
diff --git a/fe/src/__tests__/libraryImport.store.spec.ts b/fe/src/__tests__/libraryImport.store.spec.ts
index e1739dd7c..1483f6da7 100644
--- a/fe/src/__tests__/libraryImport.store.spec.ts
+++ b/fe/src/__tests__/libraryImport.store.spec.ts
@@ -348,8 +348,10 @@ describe('library import store', () => {
getUnmatchedResults.mockImplementation(async (jobId: string) => {
expect(jobId).toBe('own-job')
return {
+ jobId,
status: 'Completed',
error: null,
+ warnings: ['One path could not be read and was skipped.'],
items: [
{
fullPath: 'C:\\incoming\\Book A.mp3',
@@ -373,6 +375,7 @@ describe('library import store', () => {
expect(getUnmatchedResults).toHaveBeenCalledWith('own-job')
expect(Object.keys(store.items)).toEqual(['C:\\incoming\\Book A.mp3'])
expect(store.scanStatus).toBe('done')
+ expect(store.scanWarnings).toEqual(['One path could not be read and was skipped.'])
})
it('prefers detected title and author for automatic matching before folder fallback', async () => {
diff --git a/fe/src/components/feedback/UnmatchedFilesModal.vue b/fe/src/components/feedback/UnmatchedFilesModal.vue
index 9e14ce3a7..95c755611 100644
--- a/fe/src/components/feedback/UnmatchedFilesModal.vue
+++ b/fe/src/components/feedback/UnmatchedFilesModal.vue
@@ -49,6 +49,12 @@
+
All files are in your library
@@ -231,6 +237,7 @@ type Phase = 'empty' | 'scanning' | 'results' | 'error'
const phase = ref
('empty')
const items = ref([])
const errorMessage = ref('')
+const scanWarnings = ref([])
const lastScannedAt = ref(null)
const addingItem = ref(null)
const bulkAdding = ref(false)
@@ -274,6 +281,16 @@ const fileActionLabel = computed(() =>
let jobId = ''
let offSignalR: (() => void) | null = null
+let pollInterval: ReturnType | null = null
+
+function stopScanTracking() {
+ offSignalR?.()
+ offSignalR = null
+ if (pollInterval) {
+ clearInterval(pollInterval)
+ pollInterval = null
+ }
+}
// On open: load cached results — no auto-scan
watch(
@@ -288,7 +305,8 @@ watch(
try {
const saved = await apiService.getSavedUnmatchedFiles(props.rootFolder.id)
- if (saved.items.length > 0) {
+ scanWarnings.value = saved.warnings ?? []
+ if (saved.items.length > 0 || saved.lastScannedAt) {
items.value = saved.items
lastScannedAt.value = saved.lastScannedAt ?? null
phase.value = 'results'
@@ -311,10 +329,26 @@ async function startScan() {
phase.value = 'scanning'
items.value = []
errorMessage.value = ''
+ scanWarnings.value = []
jobId = ''
+ stopScanTracking()
+
+ function applyCompletedScan(
+ response: Awaited>,
+ ) {
+ items.value = response.items
+ scanWarnings.value = response.warnings ?? []
+ lastScannedAt.value = new Date().toISOString()
+ phase.value = 'results'
+ stopScanTracking()
+ }
+
+ async function completeScan(completedJobId: string) {
+ applyCompletedScan(await apiService.getUnmatchedResults(completedJobId))
+ }
+
// Subscribe to SignalR before triggering the scan
- offSignalR?.()
offSignalR = signalRService.onUnmatchedScanComplete(async (payload) => {
if (payload.jobId !== jobId) return
if (payload.error) {
@@ -323,13 +357,11 @@ async function startScan() {
return
}
try {
- const response = await apiService.getUnmatchedResults(payload.jobId)
- items.value = response.items
- lastScannedAt.value = new Date().toISOString()
- phase.value = 'results'
+ await completeScan(payload.jobId)
} catch (e) {
phase.value = 'error'
errorMessage.value = (e as Error)?.message || 'Failed to fetch results'
+ stopScanTracking()
}
})
@@ -339,29 +371,41 @@ async function startScan() {
// Poll once immediately — handles fast scans that complete before SignalR fires
const check = await apiService.getUnmatchedResults(jobId)
if (check.status === 'Completed') {
- items.value = check.items
- lastScannedAt.value = new Date().toISOString()
- phase.value = 'results'
+ applyCompletedScan(check)
} else if (check.status === 'Failed') {
phase.value = 'error'
errorMessage.value = check.error || 'Scan failed'
+ stopScanTracking()
+ } else {
+ pollInterval = setInterval(async () => {
+ if (!jobId || phase.value !== 'scanning') return
+ try {
+ const poll = await apiService.getUnmatchedResults(jobId)
+ if (poll.status === 'Completed') {
+ applyCompletedScan(poll)
+ } else if (poll.status === 'Failed') {
+ phase.value = 'error'
+ errorMessage.value = poll.error || 'Scan failed'
+ stopScanTracking()
+ }
+ } catch {
+ // Ignore transient polling errors; SignalR or a later poll can still complete the scan.
+ }
+ }, 2500)
}
- // Otherwise SignalR will deliver the completion event
} catch (e) {
phase.value = 'error'
errorMessage.value = (e as Error)?.message || 'Failed to start scan'
- offSignalR?.()
- offSignalR = null
+ stopScanTracking()
}
}
onUnmounted(() => {
- offSignalR?.()
+ stopScanTracking()
})
function close() {
- offSignalR?.()
- offSignalR = null
+ stopScanTracking()
emit('close')
}
@@ -563,6 +607,21 @@ async function addAllWithAsin() {
color: #f03e3e;
}
+.scan-warnings {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ margin-bottom: 1rem;
+}
+
+.scan-warning {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.35rem;
+ color: #f59e0b;
+ font-size: 0.85rem;
+}
+
.error-icon {
width: 40px;
height: 40px;
diff --git a/fe/src/stores/libraryImport.ts b/fe/src/stores/libraryImport.ts
index f41d7763c..06b0d68e4 100644
--- a/fe/src/stores/libraryImport.ts
+++ b/fe/src/stores/libraryImport.ts
@@ -138,6 +138,7 @@ export const useLibraryImportStore = defineStore('libraryImport', () => {
const rootFolderId = ref(null)
const scanStatus = ref<'idle' | 'scanning' | 'done' | 'error'>('idle')
const scanError = ref(null)
+ const scanWarnings = ref([])
const lastScannedAt = ref(null)
const action = ref<'none' | 'move' | 'hardlink/copy'>('none')
const monitor = ref<'none' | 'all'>('all')
@@ -157,9 +158,11 @@ export const useLibraryImportStore = defineStore('libraryImport', () => {
async function initFromRootFolder(id: number) {
rootFolderId.value = id
scanStatus.value = 'idle'
+ scanWarnings.value = []
try {
const saved = await apiService.getSavedUnmatchedFiles(id)
if (saved.lastScannedAt) lastScannedAt.value = saved.lastScannedAt
+ scanWarnings.value = saved.warnings ?? []
const persisted = _loadPersistedMatches(id)
const newItems: Record = {}
for (const item of saved.items) {
@@ -196,6 +199,7 @@ export const useLibraryImportStore = defineStore('libraryImport', () => {
rootFolderId.value = id
scanStatus.value = 'scanning'
scanError.value = null
+ scanWarnings.value = []
try {
localStorage.removeItem(_storageKey(id))
} catch {
@@ -222,6 +226,7 @@ export const useLibraryImportStore = defineStore('libraryImport', () => {
try {
const response = await apiService.getUnmatchedResults(completedJobId)
_populateFromItems(response.items)
+ scanWarnings.value = response.warnings ?? []
_persistMatches()
lastScannedAt.value = new Date().toISOString()
scanStatus.value = 'done'
@@ -585,6 +590,7 @@ export const useLibraryImportStore = defineStore('libraryImport', () => {
rootFolderId,
scanStatus,
scanError,
+ scanWarnings,
lastScannedAt,
action,
monitor,
diff --git a/fe/src/types/index.ts b/fe/src/types/index.ts
index 2497f206a..0519fe3bf 100644
--- a/fe/src/types/index.ts
+++ b/fe/src/types/index.ts
@@ -1214,11 +1214,13 @@ export interface UnmatchedFilesResponse {
jobId: string
status: 'Queued' | 'Processing' | 'Completed' | 'Failed'
error?: string
+ warnings?: string[]
items: UnmatchedFileItem[]
}
export interface SavedUnmatchedResponse {
lastScannedAt?: string
+ warnings?: string[]
items: UnmatchedFileItem[]
}
diff --git a/fe/src/views/library/AudiobookDetailView.vue b/fe/src/views/library/AudiobookDetailView.vue
index 9de8b6951..7d92c7570 100644
--- a/fe/src/views/library/AudiobookDetailView.vue
+++ b/fe/src/views/library/AudiobookDetailView.vue
@@ -1260,9 +1260,6 @@ onMounted(async () => {
if (!audiobook.value) return
if (String(job.audiobookId) !== String(audiobook.value.id)) return
scanNotificationsStore.applyUpdate(job)
- if (job.status.toLowerCase() === 'completed') {
- void loadWeakStorageMissingFiles()
- }
})
// subscribe to AudiobookUpdate messages and merge detail when this audiobook is updated (e.g., after a move)
@@ -1326,6 +1323,54 @@ watch(
},
)
+let lastHandledTerminalScanJobId: string | null = null
+watch(
+ () =>
+ [
+ trackedScanJob.value?.jobId,
+ trackedScanJob.value?.status,
+ trackedScanJob.value?.error,
+ ] as const,
+ async ([jobId, status, scanError]) => {
+ if (!jobId || !status) return
+ const normalizedStatus = status.toLowerCase()
+ if (normalizedStatus !== 'completed' && normalizedStatus !== 'failed') return
+ if (lastHandledTerminalScanJobId === jobId) return
+ lastHandledTerminalScanJobId = jobId
+
+ if (normalizedStatus === 'completed') {
+ await refreshAudiobookAfterScan()
+ return
+ }
+
+ const toast = useToast()
+ toast.error('Scan failed', scanError || 'The audiobook scan did not complete successfully.')
+ },
+ { flush: 'post' },
+)
+
+async function refreshAudiobookAfterScan() {
+ const id = audiobook.value?.id ?? parseInt(route.params.id as string)
+ try {
+ let refreshed: Audiobook | null = null
+ if (typeof apiService.getAudiobook === 'function') {
+ refreshed = await apiService.getAudiobook(id)
+ } else {
+ await libraryStore.fetchLibrary()
+ refreshed = libraryStore.audiobooks.find((candidate) => candidate.id === id) ?? null
+ }
+
+ if (refreshed) {
+ audiobook.value = refreshed
+ await afterLoad()
+ }
+ } catch (err) {
+ logger.debug('Unable to refresh audiobook after scan completion', err)
+ }
+
+ await loadWeakStorageMissingFiles()
+}
+
async function loadAudiobook() {
loading.value = true
error.value = null
diff --git a/fe/src/views/library/LibraryImportView.vue b/fe/src/views/library/LibraryImportView.vue
index b79716372..5828bbff7 100644
--- a/fe/src/views/library/LibraryImportView.vue
+++ b/fe/src/views/library/LibraryImportView.vue
@@ -78,6 +78,13 @@
+
+
Scanning for unmatched audio files...
@@ -563,6 +570,21 @@ async function refreshRootFolders(newFolder: RootFolder) {
color: #ef4444;
}
+.scan-warnings {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ margin: 0.75rem 0;
+}
+
+.scan-warning {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.35rem;
+ font-size: 0.85rem;
+ color: #f59e0b;
+}
+
.state-panel {
display: flex;
flex-direction: column;
diff --git a/listenarr.api/Features/Library/FileRegistrationRecoveryController.cs b/listenarr.api/Features/Library/FileRegistrationRecoveryController.cs
new file mode 100644
index 000000000..201a88239
--- /dev/null
+++ b/listenarr.api/Features/Library/FileRegistrationRecoveryController.cs
@@ -0,0 +1,53 @@
+using Microsoft.AspNetCore.Mvc;
+
+namespace Listenarr.Api.Features.Library;
+
+[ApiController]
+[Route("api/v{version:apiVersion}/file-registration-recovery")]
+[Tags("Library")]
+public sealed class FileRegistrationRecoveryController(
+ IFileRegistrationRecoveryService recoveryService,
+ IFilesystemMutationCoordinator mutationCoordinator) : ControllerBase
+{
+ [HttpPost("{operationId:guid}/retry")]
+ public async Task
Retry(
+ Guid operationId,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var status = await mutationCoordinator.ExecuteExclusiveAsync(
+ token => recoveryService.RetryAsync(operationId, token),
+ cancellationToken);
+ return status.CanRetry
+ || status.Disposition
+ == FileRegistrationRecoveryDisposition.RequiresOperatorAttention
+ ? Conflict(status)
+ : Ok(status);
+ }
+ catch (KeyNotFoundException)
+ {
+ return NotFound(new
+ {
+ code = "registration_recovery_not_found",
+ message = "File-registration recovery operation not found."
+ });
+ }
+ catch (ArgumentException)
+ {
+ return BadRequest(new
+ {
+ code = "registration_recovery_invalid",
+ message = "The file-registration recovery operation is invalid."
+ });
+ }
+ catch (InvalidOperationException)
+ {
+ return Conflict(new
+ {
+ code = "registration_recovery_not_retryable",
+ message = "The requested operation is not eligible for file-registration recovery."
+ });
+ }
+ }
+}
diff --git a/listenarr.api/Features/Library/LibraryDeleteWorkflow.cs b/listenarr.api/Features/Library/LibraryDeleteWorkflow.cs
index ec34bc4b6..261a7ea6a 100644
--- a/listenarr.api/Features/Library/LibraryDeleteWorkflow.cs
+++ b/listenarr.api/Features/Library/LibraryDeleteWorkflow.cs
@@ -339,8 +339,11 @@ await _deletionIntentStore.MarkCompletedAsync(
private static bool HasUnverifiedTrackedDeleteSource(Audiobook audiobook) =>
audiobook.Files?.Any(file =>
!string.IsNullOrWhiteSpace(file.Path)
- && file.PathIdentityState == PathIdentityState.Valid
- && string.IsNullOrWhiteSpace(file.PhysicalObjectIdentity)) == true;
+ && (PhysicalObjectIdentitySafety.IsKnownWeak(
+ file.PhysicalObjectIdentity)
+ || (file.PathIdentityState == PathIdentityState.Valid
+ && string.IsNullOrWhiteSpace(
+ file.PhysicalObjectIdentity)))) == true;
private async Task GetManagedStorageMutationBlockAsync(
Audiobook audiobook,
diff --git a/listenarr.api/Features/Library/RootFoldersController.Mapping.cs b/listenarr.api/Features/Library/RootFoldersController.Mapping.cs
index be590193c..b9912de64 100644
--- a/listenarr.api/Features/Library/RootFoldersController.Mapping.cs
+++ b/listenarr.api/Features/Library/RootFoldersController.Mapping.cs
@@ -1,9 +1,31 @@
using Listenarr.Domain.Common;
+using Microsoft.AspNetCore.Mvc;
namespace Listenarr.Api.Features.Library;
public partial class RootFoldersController
{
+ private ConflictObjectResult RegistrationRecoveryConflict(
+ FileRegistrationRecoveryBlocker blocker)
+ {
+ var canRetry = blocker.Recoverability is
+ FileRegistrationRecoveryDisposition.AutomaticRecovery
+ or FileRegistrationRecoveryDisposition.WaitingForOwnerRetry;
+ return Conflict(new
+ {
+ message = blocker.PublicReason,
+ code = "registration_recovery_pending",
+ operationId = blocker.OperationId,
+ audiobookId = blocker.AudiobookId,
+ journalState = blocker.JournalState.ToString(),
+ ownerKind = blocker.OwnerKind,
+ recoverability = blocker.Recoverability.ToString(),
+ canRetry,
+ canAbandon = false,
+ retryOperationId = canRetry ? blocker.OperationId : (Guid?)null
+ });
+ }
+
private async Task MapAsync(RootFolder root)
{
RootFolderPathChangeResult? active = null;
diff --git a/listenarr.api/Features/Library/RootFoldersController.cs b/listenarr.api/Features/Library/RootFoldersController.cs
index e1a345895..f1342996d 100644
--- a/listenarr.api/Features/Library/RootFoldersController.cs
+++ b/listenarr.api/Features/Library/RootFoldersController.cs
@@ -338,6 +338,18 @@ public async Task ConfirmCurrentFolder(
message = "The root folder confirmation request is invalid."
});
}
+ catch (PlatformNotSupportedException)
+ {
+ return Conflict(new
+ {
+ message = "The current storage does not expose the durable physical identity required to confirm this folder for filesystem mutation.",
+ code = "root_folder_identity_unsupported"
+ });
+ }
+ catch (RootFolderRecoveryBlockedException exception)
+ {
+ return RegistrationRecoveryConflict(exception.Blocker);
+ }
catch (InvalidOperationException)
{
return Conflict(new
@@ -422,6 +434,7 @@ public IActionResult GetUnmatchedResults(Guid jobId)
jobId = job.Id.ToString(),
status = job.Status,
error = UnmatchedScanPublicError.FromInternal(job.Error),
+ warnings = job.Warnings,
items = job.Results ?? new List()
});
}
@@ -468,11 +481,17 @@ public async Task GetSavedUnmatched(int id)
return Ok(new
{
lastScannedAt = job.CompletedAt,
+ warnings = job.Warnings,
items = filtered
});
}
- return Ok(new { lastScannedAt = (DateTime?)null, items = new List() });
+ return Ok(new
+ {
+ lastScannedAt = (DateTime?)null,
+ warnings = new List(),
+ items = new List()
+ });
}
}
diff --git a/listenarr.application/Audiobooks/Contracts/IAudiobookFileService.cs b/listenarr.application/Audiobooks/Contracts/IAudiobookFileService.cs
index 8d48dd1ae..00053397c 100644
--- a/listenarr.application/Audiobooks/Contracts/IAudiobookFileService.cs
+++ b/listenarr.application/Audiobooks/Contracts/IAudiobookFileService.cs
@@ -100,6 +100,16 @@ Task RefreshPhysicalGenerationAsync(
string? source = "scan",
CancellationToken cancellationToken = default);
+ ///
+ /// Refresh metadata for an already-owned path using a live read lease.
+ /// Does not enroll, replace, or clear path or physical-generation identity.
+ ///
+ Task RefreshMetadataAsync(
+ Audiobook audiobook,
+ int fileId,
+ IAudiobookFileRegistrationLease registrationLease,
+ CancellationToken cancellationToken = default);
+
Task RollbackPhysicalGenerationClaimAsync(
Audiobook audiobook,
int fileId,
diff --git a/listenarr.application/Audiobooks/Contracts/IFileRegistrationRecovery.cs b/listenarr.application/Audiobooks/Contracts/IFileRegistrationRecovery.cs
index 6e82977a7..0cf2d29c3 100644
--- a/listenarr.application/Audiobooks/Contracts/IFileRegistrationRecovery.cs
+++ b/listenarr.application/Audiobooks/Contracts/IFileRegistrationRecovery.cs
@@ -3,8 +3,29 @@
namespace Listenarr.Application.Audiobooks.Contracts;
///
-/// Reports whether a committed file-registration move still owns source-cleanup state
-/// for an audiobook.
+/// Describes how a nonterminal file-registration publication can progress.
+///
+public enum FileRegistrationRecoveryDisposition
+{
+ Cleared,
+ AutomaticRecovery,
+ WaitingForOwnerRetry,
+ RequiresOperatorAttention
+}
+
+public sealed record FileRegistrationRecoveryBlocker(
+ Guid OperationId,
+ FileMutationJournalState JournalState,
+ FileAction Action,
+ int? AudiobookId,
+ string OwnerKind,
+ bool SourceTouchesBoundary,
+ bool DestinationTouchesBoundary,
+ FileRegistrationRecoveryDisposition Recoverability,
+ string PublicReason);
+
+///
+/// Reports file-registration publications that still own recovery state.
///
public interface IFileRegistrationRecoveryProbe
{
@@ -16,6 +37,11 @@ Task HasBlockingBoundaryAsync(
string boundaryPath,
FileSystemPathSemantics semantics,
CancellationToken cancellationToken = default);
+
+ Task> GetBlockingBoundaryAsync(
+ string boundaryPath,
+ FileSystemPathSemantics semantics,
+ CancellationToken cancellationToken = default);
}
public sealed record FileRegistrationRecoveryReceipt(
@@ -24,6 +50,15 @@ public sealed record FileRegistrationRecoveryReceipt(
string SourcePath,
string DestinationPath);
+public sealed record FileRegistrationRecoveryStatus(
+ Guid OperationId,
+ FileMutationJournalState JournalState,
+ int? AudiobookId,
+ FileRegistrationRecoveryDisposition Disposition,
+ bool CanRetry,
+ bool CanAbandon,
+ string PublicReason);
+
///
/// Reconciles committed file-registration moves whose published destination is already
/// owned by an audiobook but whose original source retirement is still incomplete.
@@ -39,6 +74,10 @@ Task ReconcileAudiobookAsync(
int audiobookId,
CancellationToken cancellationToken = default);
+ Task RetryAsync(
+ Guid operationId,
+ CancellationToken cancellationToken = default);
+
Task>
ReconcileAudiobookWithReceiptsAsync(
int audiobookId,
diff --git a/listenarr.application/Audiobooks/Contracts/IRootFolderStorageConfirmationService.cs b/listenarr.application/Audiobooks/Contracts/IRootFolderStorageConfirmationService.cs
index 8739def5b..81dc934ee 100644
--- a/listenarr.application/Audiobooks/Contracts/IRootFolderStorageConfirmationService.cs
+++ b/listenarr.application/Audiobooks/Contracts/IRootFolderStorageConfirmationService.cs
@@ -1,5 +1,12 @@
namespace Listenarr.Application.Audiobooks.Contracts;
+public sealed class RootFolderRecoveryBlockedException(
+ FileRegistrationRecoveryBlocker blocker)
+ : InvalidOperationException(blocker.PublicReason)
+{
+ public FileRegistrationRecoveryBlocker Blocker { get; } = blocker;
+}
+
public interface IRootFolderStorageConfirmationService
{
Task ConfirmCurrentFolderAsync(
diff --git a/listenarr.application/Audiobooks/Contracts/IVerifiedFileRenameTransactionCoordinator.cs b/listenarr.application/Audiobooks/Contracts/IVerifiedFileRenameTransactionCoordinator.cs
new file mode 100644
index 000000000..80248be6f
--- /dev/null
+++ b/listenarr.application/Audiobooks/Contracts/IVerifiedFileRenameTransactionCoordinator.cs
@@ -0,0 +1,94 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace Listenarr.Application.Audiobooks.Contracts;
+
+public readonly record struct VerifiedFileRenameBatchMember(
+ int AudiobookFileId,
+ string SourcePath,
+ string DestinationPath);
+
+public readonly record struct VerifiedFileRenameBatchManifest(
+ int ExpectedMemberCount,
+ string ManifestSha256)
+{
+ public static VerifiedFileRenameBatchManifest Create(
+ IEnumerable members)
+ {
+ ArgumentNullException.ThrowIfNull(members);
+ var normalized = members
+ .Select(member => new VerifiedFileRenameBatchMember(
+ member.AudiobookFileId,
+ Path.GetFullPath(member.SourcePath),
+ Path.GetFullPath(member.DestinationPath)))
+ .Distinct()
+ .OrderBy(member => member.AudiobookFileId)
+ .ThenBy(member => member.SourcePath, StringComparer.Ordinal)
+ .ThenBy(member => member.DestinationPath, StringComparer.Ordinal)
+ .ToArray();
+ if (normalized.Length == 0)
+ {
+ throw new ArgumentException(
+ "A verified file-rename batch requires at least one member.",
+ nameof(members));
+ }
+
+ var payload = string.Join(
+ '\0',
+ normalized.Select(member =>
+ $"{member.AudiobookFileId}\0{member.SourcePath}\0{member.DestinationPath}"));
+ return new VerifiedFileRenameBatchManifest(
+ normalized.Length,
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))));
+ }
+
+ public void Validate()
+ {
+ if (ExpectedMemberCount <= 0)
+ {
+ throw new InvalidOperationException(
+ "A verified file-rename batch must contain at least one member.");
+ }
+ if (ManifestSha256.Length != 64 || !ManifestSha256.All(Uri.IsHexDigit))
+ {
+ throw new InvalidOperationException(
+ "A verified file-rename manifest must contain a SHA-256 digest.");
+ }
+ }
+}
+
+public sealed record VerifiedFileRenamePreparationResult(
+ bool Success,
+ IVerifiedFileRenameLease? Lease = null,
+ string? Error = null);
+
+public enum VerifiedFileRenameRetirementOutcome
+{
+ Completed,
+ SourceRetained,
+ NeedsAttention
+}
+
+public interface IVerifiedFileRenameLease : IAsyncDisposable
+{
+ Guid OperationId { get; }
+
+ Task RollBackAsync(CancellationToken cancellationToken = default);
+
+ Task CompleteSourceRetirementAsync(
+ CancellationToken cancellationToken = default);
+}
+
+public interface IVerifiedFileRenameTransactionCoordinator
+{
+ Task PrepareAsync(
+ string source,
+ string destination,
+ Guid operationId,
+ Guid batchId,
+ VerifiedFileRenameBatchManifest batchManifest,
+ int audiobookId,
+ int audiobookFileId,
+ FilePublicationSourceProof sourceProof,
+ CancellationToken cancellationToken = default);
+}
diff --git a/listenarr.application/Audiobooks/Contracts/PhysicalObjectIdentitySafety.cs b/listenarr.application/Audiobooks/Contracts/PhysicalObjectIdentitySafety.cs
new file mode 100644
index 000000000..75e3cfcce
--- /dev/null
+++ b/listenarr.application/Audiobooks/Contracts/PhysicalObjectIdentitySafety.cs
@@ -0,0 +1,57 @@
+namespace Listenarr.Application.Audiobooks.Contracts;
+
+public static class PhysicalObjectIdentitySafety
+{
+ public static bool IsKnownWeak(string? identity)
+ {
+ if (string.IsNullOrWhiteSpace(identity)
+ || !identity.StartsWith("linux:", StringComparison.Ordinal)
+ && !identity.StartsWith(
+ "linux-generation:",
+ StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ var parts = identity.Split(':');
+ if (parts.Length == 6
+ && string.Equals(parts[0], "linux", StringComparison.Ordinal)
+ && IsFixedHex(parts[1], 8)
+ && IsFixedHex(parts[2], 8)
+ && IsFixedHex(parts[3], 16)
+ && IsFixedHex(parts[4], 16)
+ && IsFixedHex(parts[5], 8))
+ {
+ return true;
+ }
+
+ var suffixIndex = parts[0] switch
+ {
+ "linux-generation" when parts.Length >= 6
+ && IsFixedHex(parts[1], 8)
+ && IsFixedHex(parts[2], 8)
+ && IsFixedHex(parts[3], 16) => 4,
+ "linux" when parts.Length >= 8
+ && IsFixedHex(parts[1], 8)
+ && IsFixedHex(parts[2], 8)
+ && IsFixedHex(parts[3], 16)
+ && IsFixedHex(parts[4], 16)
+ && IsFixedHex(parts[5], 8) => 6,
+ _ => -1
+ };
+
+ return suffixIndex >= 0
+ && parts.Length == suffixIndex + 3
+ && string.Equals(parts[suffixIndex], "fh", StringComparison.Ordinal)
+ && string.Equals(
+ parts[suffixIndex + 1],
+ "00000081",
+ StringComparison.OrdinalIgnoreCase)
+ && parts[suffixIndex + 2].Length > 0
+ && parts[suffixIndex + 2].Length % 2 == 0
+ && parts[suffixIndex + 2].All(Uri.IsHexDigit);
+ }
+
+ private static bool IsFixedHex(string value, int length) =>
+ value.Length == length && value.All(Uri.IsHexDigit);
+}
diff --git a/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookFileRepository.cs b/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookFileRepository.cs
index 5f7983aec..0cc429b26 100644
--- a/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookFileRepository.cs
+++ b/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookFileRepository.cs
@@ -28,6 +28,34 @@ public sealed record AudiobookFilePathReferenceSnapshot(
int AudiobookId,
string? Path);
+ public sealed record AudiobookFilePhysicalGenerationSnapshot(
+ long? Size,
+ double? DurationSeconds,
+ string? Format,
+ string? Container,
+ string? Codec,
+ int? Bitrate,
+ int? SampleRate,
+ int? Channels,
+ string? Source,
+ string? PhysicalObjectIdentity,
+ int PhysicalIdentityVersion,
+ DateTime? PhysicalIdentityObservedAtUtc);
+
+ public sealed record AudiobookFileMetadataRefreshSnapshot(
+ int FileId,
+ int AudiobookId,
+ AudiobookFilePathState PathState,
+ string? PhysicalObjectIdentity,
+ int PhysicalIdentityVersion,
+ DateTime? PhysicalIdentityObservedAtUtc,
+ string? BasePath)
+ {
+ public static AudiobookFileMetadataRefreshSnapshot Capture(AudiobookFile file, string? basePath) =>
+ new(file.Id, file.AudiobookId, file.CapturePathState(), file.PhysicalObjectIdentity,
+ file.PhysicalIdentityVersion, file.PhysicalIdentityObservedAtUtc, basePath);
+ }
+
public interface IAudiobookFileRepository
{
Task GetByIdAsync(int id, CancellationToken ct = default);
@@ -47,6 +75,14 @@ Task CheckOwnershipAsync(
AudiobookFilePathIdentity identity,
CancellationToken ct = default);
Task UpdateAsync(AudiobookFile file, CancellationToken ct = default);
+ ///
+ /// Update metadata only if the complete tracked ownership snapshot and base path still match.
+ /// Identity and ownership fields are never written by this operation.
+ ///
+ Task RefreshMetadataAsync(
+ AudiobookFileMetadataRefreshSnapshot expectedFile,
+ AudioMetadata metadata,
+ CancellationToken ct = default);
Task ReplacePhysicalGenerationAsync(
int fileId,
int audiobookId,
@@ -62,6 +98,21 @@ Task ReplacePhysicalGenerationWithBasePathAsync(
AudiobookFile replacement,
AudiobookBasePathMutation basePathMutation,
CancellationToken ct = default);
+ Task RestorePhysicalGenerationAsync(
+ int fileId,
+ int audiobookId,
+ string? expectedPath,
+ string? expectedPhysicalObjectIdentity,
+ AudiobookFilePhysicalGenerationSnapshot predecessor,
+ CancellationToken ct = default);
+ Task RestorePhysicalGenerationWithBasePathAsync(
+ int fileId,
+ int audiobookId,
+ string? expectedPath,
+ string? expectedPhysicalObjectIdentity,
+ AudiobookFilePhysicalGenerationSnapshot predecessor,
+ AudiobookBasePathMutation basePathMutation,
+ CancellationToken ct = default);
Task DeletePhysicalGenerationAsync(
int fileId,
int audiobookId,
diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataRefresh.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataRefresh.cs
new file mode 100644
index 000000000..53c96328a
--- /dev/null
+++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataRefresh.cs
@@ -0,0 +1,110 @@
+using Listenarr.Domain.Common;
+
+namespace Listenarr.Application.Audiobooks.Files;
+
+public partial class AudiobookFileService
+{
+ public Task RefreshMetadataAsync(
+ Audiobook audiobook,
+ int fileId,
+ IAudiobookFileRegistrationLease registrationLease,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(audiobook);
+ ArgumentNullException.ThrowIfNull(registrationLease);
+ ArgumentException.ThrowIfNullOrWhiteSpace(registrationLease.PublicPath);
+ ArgumentException.ThrowIfNullOrWhiteSpace(registrationLease.MetadataPath);
+ if (fileId <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(fileId));
+ }
+
+ return filesystemMutationCoordinator.ExecuteExclusiveAsync(
+ globalToken => audiobookOperationCoordinator.ExecuteExclusiveAsync(
+ audiobook.Id,
+ token => RefreshMetadataCoreAsync(audiobook.Id, fileId, registrationLease, token),
+ globalToken),
+ cancellationToken);
+ }
+
+ private async Task RefreshMetadataCoreAsync(
+ int audiobookId,
+ int fileId,
+ IAudiobookFileRegistrationLease lease,
+ CancellationToken cancellationToken)
+ {
+ await moveQueueService.EnsureFilesystemMutationAllowedAsync(audiobookId, cancellationToken);
+ var audiobook = await audiobookRepository.GetByIdSnapshotAsync(audiobookId, cancellationToken);
+ var currentFile = await audiobookFileRepository.GetByIdAsync(fileId, cancellationToken);
+ if (audiobook == null
+ || currentFile == null
+ || currentFile.AudiobookId != audiobookId
+ || currentFile.PathIdentityState != PathIdentityState.Valid)
+ {
+ return false;
+ }
+
+ // Capture immutable expected state before extraction; the persistence port
+ // compares it atomically and writes metadata fields only.
+ var expectedFile = AudiobookFileMetadataRefreshSnapshot.Capture(currentFile, audiobook.BasePath);
+ if (!await CanRefreshOwnedMetadataAsync(audiobook, expectedFile, lease, cancellationToken))
+ {
+ return false;
+ }
+
+ // Operation-local cache identity prevents a path-only read from inheriting
+ // cached metadata for a previously visible object at the same pathname.
+ var metadata = await ExtractMetadataAsync(
+ lease.MetadataPath,
+ $"metadata-read:{Guid.NewGuid():N}",
+ lease.PublicPath);
+ cancellationToken.ThrowIfCancellationRequested();
+ if (metadata == null
+ || !await CanRefreshOwnedMetadataAsync(audiobook, expectedFile, lease, cancellationToken))
+ {
+ return false;
+ }
+
+ return await audiobookFileRepository.RefreshMetadataAsync(
+ expectedFile, metadata, cancellationToken);
+ }
+
+ private async Task CanRefreshOwnedMetadataAsync(
+ Audiobook audiobook,
+ AudiobookFileMetadataRefreshSnapshot expectedFile,
+ IAudiobookFileRegistrationLease lease,
+ CancellationToken cancellationToken)
+ {
+ var authorization = await ResolveAuthorizedClaimPathAsync(
+ audiobook, lease.PublicPath, cancellationToken);
+ if (authorization.Path == null)
+ {
+ return false;
+ }
+
+ var identity = await filePathIdentityResolver.ResolveAsync(
+ audiobook, authorization.Path, cancellationToken);
+ var storedIdentity = await filePathIdentityResolver.ResolveAsync(
+ audiobook, expectedFile.PathState.StoredPath!, cancellationToken);
+ if (identity.State != PathIdentityState.Valid
+ || storedIdentity.State != PathIdentityState.Valid
+ || string.IsNullOrWhiteSpace(identity.OwnershipKey)
+ || identity.OwnershipKey != storedIdentity.OwnershipKey
+ || identity.OwnershipKey != expectedFile.PathState.OwnershipKey
+ || identity.LookupKey != expectedFile.PathState.LookupKey
+ || identity.CanonicalPath != expectedFile.PathState.CanonicalPath
+ || identity.Syntax != expectedFile.PathState.Syntax
+ || identity.CaseSensitivity != expectedFile.PathState.CaseSensitivity
+ || identity.RequestedMode != expectedFile.PathState.RequestedMode
+ || identity.BoundaryPath != expectedFile.PathState.BoundaryPath
+ || identity.Version != expectedFile.PathState.Version)
+ {
+ return false;
+ }
+
+ var ownership = await audiobookFileRepository.CheckOwnershipAsync(
+ audiobook.Id, expectedFile.FileId, identity, cancellationToken);
+ return ownership.Outcome == AudiobookFileOwnershipCheckOutcome.Available
+ && lease.MatchesCurrentPublication();
+ }
+}
diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs
index 3dad98b99..5c12d3f3a 100644
--- a/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs
+++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs
@@ -203,7 +203,7 @@ private async Task RefreshPhysicalGenerationCoreAsync(
expectedPhysicalObjectIdentity)
&& !registrationLease.MatchesPhysicalObjectIdentity(
expectedPhysicalObjectIdentity));
- var predecessor = ClonePhysicalGeneration(currentFile);
+ var predecessor = CapturePhysicalGeneration(currentFile);
if (!registrationLease.MatchesCurrentPublication())
{
@@ -240,14 +240,14 @@ private async Task RefreshPhysicalGenerationCoreAsync(
}
var reverted = basePathMutation == null
- ? await audiobookFileRepository.ReplacePhysicalGenerationAsync(
+ ? await audiobookFileRepository.RestorePhysicalGenerationAsync(
currentFile.Id,
currentFile.AudiobookId,
currentFile.Path,
registrationLease.PhysicalObjectIdentity,
predecessor,
CancellationToken.None)
- : await audiobookFileRepository.ReplacePhysicalGenerationWithBasePathAsync(
+ : await audiobookFileRepository.RestorePhysicalGenerationWithBasePathAsync(
currentFile.Id,
currentFile.AudiobookId,
currentFile.Path,
@@ -413,27 +413,19 @@ await Task.Delay(
return false;
}
- private static AudiobookFile ClonePhysicalGeneration(AudiobookFile source)
- {
- var clone = AudiobookFile.CreateUnresolved(source.Path);
- clone.AudiobookId = source.AudiobookId;
- clone.Size = source.Size;
- clone.DurationSeconds = source.DurationSeconds;
- clone.Format = source.Format;
- clone.Container = source.Container;
- clone.Codec = source.Codec;
- clone.Bitrate = source.Bitrate;
- clone.SampleRate = source.SampleRate;
- clone.Channels = source.Channels;
- clone.Source = source.Source;
- if (!string.IsNullOrWhiteSpace(source.PhysicalObjectIdentity)
- && source.PhysicalIdentityObservedAtUtc.HasValue)
- {
- clone.ApplyPhysicalObjectIdentity(
- source.PhysicalObjectIdentity,
- source.PhysicalIdentityObservedAtUtc.Value);
- }
-
- return clone;
- }
+ private static AudiobookFilePhysicalGenerationSnapshot
+ CapturePhysicalGeneration(AudiobookFile source) =>
+ new(
+ source.Size,
+ source.DurationSeconds,
+ source.Format,
+ source.Container,
+ source.Codec,
+ source.Bitrate,
+ source.SampleRate,
+ source.Channels,
+ source.Source,
+ source.PhysicalObjectIdentity,
+ source.PhysicalIdentityVersion,
+ source.PhysicalIdentityObservedAtUtc);
}
diff --git a/listenarr.application/Audiobooks/Jobs/UnmatchedScanQueueService.cs b/listenarr.application/Audiobooks/Jobs/UnmatchedScanQueueService.cs
index f2e60f324..e85ee3b8a 100644
--- a/listenarr.application/Audiobooks/Jobs/UnmatchedScanQueueService.cs
+++ b/listenarr.application/Audiobooks/Jobs/UnmatchedScanQueueService.cs
@@ -59,13 +59,19 @@ public class UnmatchedScanJob
public string Status { get; set; } = "Queued";
public string? Error { get; set; }
public List? Results { get; set; }
+ public List Warnings { get; set; } = new();
}
public interface IUnmatchedScanQueueService
{
Task EnqueueAsync(string rootFolderPath);
bool TryGetJob(Guid id, out UnmatchedScanJob? job);
- void UpdateJob(Guid id, string status, List? results = null, string? error = null);
+ void UpdateJob(
+ Guid id,
+ string status,
+ List? results = null,
+ string? error = null,
+ List? warnings = null);
bool TryGetLastJobForPath(string rootFolderPath, out UnmatchedScanJob? job);
ChannelReader Reader { get; }
}
@@ -129,15 +135,24 @@ public async Task EnqueueAsync(string rootFolderPath)
public bool TryGetJob(Guid id, out UnmatchedScanJob? job) => _jobs.TryGetValue(id, out job);
- public void UpdateJob(Guid id, string status, List? results = null, string? error = null)
+ public void UpdateJob(
+ Guid id,
+ string status,
+ List? results = null,
+ string? error = null,
+ List? warnings = null)
{
if (!_jobs.TryGetValue(id, out var job)) return;
job.Status = status;
job.Error = error;
if (results != null) job.Results = results;
- if (status == "Completed")
+ if (warnings != null) job.Warnings = warnings;
+ if (status is "Completed" or "Failed")
{
job.CompletedAt = DateTime.UtcNow;
+ }
+ if (status == "Completed")
+ {
_lastJobByPath[job.RootFolderPath] = id;
}
_jobs[id] = job;
diff --git a/listenarr.application/Audiobooks/Renaming/RenameModels.cs b/listenarr.application/Audiobooks/Renaming/RenameModels.cs
index 6270f0edb..7008d151b 100644
--- a/listenarr.application/Audiobooks/Renaming/RenameModels.cs
+++ b/listenarr.application/Audiobooks/Renaming/RenameModels.cs
@@ -92,6 +92,7 @@ public class FileRenameResultItem
public bool Success { get; set; }
internal Guid? OperationId { get; set; }
internal Guid? RollbackOperationId { get; set; }
+ internal IVerifiedFileRenameLease? VerifiedRenameLease { get; set; }
public bool RolledBack { get; set; }
public string? Error { get; set; }
}
diff --git a/listenarr.application/Audiobooks/Renaming/RenameService.Execution.cs b/listenarr.application/Audiobooks/Renaming/RenameService.Execution.cs
index 1990bde4d..73b97483f 100644
--- a/listenarr.application/Audiobooks/Renaming/RenameService.Execution.cs
+++ b/listenarr.application/Audiobooks/Renaming/RenameService.Execution.cs
@@ -10,6 +10,7 @@ private async Task ExecuteFileRenameAsync(
FileRenameOperation fileOperation,
IReadOnlyCollection allowedRoots,
FileSystemPathSemantics semantics,
+ RenameExecutionPlan executionPlan,
CancellationToken cancellationToken)
{
var source = NormalizePath(fileOperation.CurrentPath);
@@ -98,7 +99,8 @@ private async Task ExecuteFileRenameAsync(
}
var targetDirectory = Path.GetDirectoryName(destination);
- if (!string.IsNullOrWhiteSpace(targetDirectory))
+ if (!string.IsNullOrWhiteSpace(targetDirectory)
+ && !executionPlan.UseVerifiedProtocol)
{
await EnsureOwnedRenameHierarchyAsync(
targetDirectory,
@@ -118,7 +120,40 @@ await EnsureOwnedRenameHierarchyAsync(
var operationId = Guid.NewGuid();
item.OperationId = operationId;
bool moved;
- if (databaseFile != null)
+ if (executionPlan.UseVerifiedProtocol)
+ {
+ var sourceProof = FindSourceProof(
+ executionPlan,
+ fileOperation.FileId);
+ if (!sourceProof.HasValue
+ || !executionPlan.BatchManifest.HasValue)
+ {
+ item.Error =
+ "Verified organize source proof is unavailable.";
+ return item;
+ }
+
+ var preparation = await _verifiedFileRenameTransactionCoordinator
+ .PrepareAsync(
+ source,
+ destination,
+ operationId,
+ executionPlan.BatchId,
+ executionPlan.BatchManifest.Value,
+ audiobook.Id,
+ databaseFile?.Id ?? 0,
+ sourceProof.Value,
+ cancellationToken);
+ moved = preparation.Success && preparation.Lease != null;
+ item.VerifiedRenameLease = preparation.Lease;
+ if (!moved)
+ {
+ item.Error = preparation.Error
+ ?? "Verified file organize operation failed.";
+ return item;
+ }
+ }
+ else if (databaseFile != null)
{
if (string.IsNullOrWhiteSpace(
databaseFile.PhysicalObjectIdentity))
@@ -158,6 +193,11 @@ await EnsureOwnedRenameHierarchyAsync(
if (databaseFile != null)
{
databaseFile.ApplyPathIdentity(destination, destinationIdentity);
+ if (executionPlan.UseVerifiedProtocol
+ && !PathsEqual(source, destination, semantics))
+ {
+ databaseFile.ClearPhysicalObjectIdentity();
+ }
}
else if (fileOperation.FileId == 0
&& !string.IsNullOrWhiteSpace(audiobook.FilePath))
diff --git a/listenarr.application/Audiobooks/Renaming/RenameService.Rollback.cs b/listenarr.application/Audiobooks/Renaming/RenameService.Rollback.cs
index 39d51d5c9..3068e7cff 100644
--- a/listenarr.application/Audiobooks/Renaming/RenameService.Rollback.cs
+++ b/listenarr.application/Audiobooks/Renaming/RenameService.Rollback.cs
@@ -53,83 +53,100 @@ private async Task RollBackFileRenamesAsync(
{
if (!PathsEqual(item.PreviousPath, item.NewPath, semantics))
{
- if (!_fileSystem.FileExists(item.NewPath))
+ if (item.VerifiedRenameLease != null)
{
- rollbackSucceeded = false;
- item.Error = "Rollback failed because the moved file could not be found.";
- continue;
- }
-
- if (!_fileSystem.TryValidateMutationTarget(
- item.NewPath,
- allowedRoots,
- out var rollbackSource,
- out _)
- || !_fileSystem.TryValidateMutationTarget(
- item.PreviousPath,
- allowedRoots,
- out var rollbackDestination,
- out _))
- {
- rollbackSucceeded = false;
- item.Error = "Rollback paths could not be resolved safely within the allowed library roots.";
- continue;
- }
-
- var parent = Path.GetDirectoryName(rollbackDestination);
- if (!string.IsNullOrWhiteSpace(parent))
- {
- await EnsureOwnedRenameHierarchyAsync(
- parent,
- allowedRoots,
- semantics,
- audiobook.Id,
- Guid.NewGuid(),
- cancellationToken);
- }
-
- // Compensation is also owner-bound and startup-discoverable. A fresh ID
- // keeps completed compensation history from colliding with a later retry.
- var rollbackOperationId = Guid.NewGuid();
- item.RollbackOperationId = rollbackOperationId;
- bool moved;
- if (item.FileId == 0)
- {
- moved = await _fileMover.PerformActionOn(
- FileAction.Move,
- rollbackSource,
- rollbackDestination,
- rollbackOperationId,
- audiobook.Id,
- audiobookFileId: 0);
+ var verifiedRollback = await item.VerifiedRenameLease
+ .RollBackAsync(cancellationToken);
+ await item.VerifiedRenameLease.DisposeAsync();
+ item.VerifiedRenameLease = null;
+ if (!verifiedRollback)
+ {
+ rollbackSucceeded = false;
+ item.Error =
+ "Verified organize rollback could not restore the source safely.";
+ continue;
+ }
}
else
{
- var trackedFile = audiobook.Files?.FirstOrDefault(
- candidate => candidate.Id == item.FileId);
- if (string.IsNullOrWhiteSpace(
- trackedFile?.PhysicalObjectIdentity))
+ if (!_fileSystem.FileExists(item.NewPath))
{
rollbackSucceeded = false;
- item.Error =
- "Rollback could not prove the tracked file generation.";
+ item.Error = "Rollback failed because the moved file could not be found.";
continue;
}
- moved = await _fileMover
- .MoveFilePreservingPhysicalIdentityAsync(
+ if (!_fileSystem.TryValidateMutationTarget(
+ item.NewPath,
+ allowedRoots,
+ out var rollbackSource,
+ out _)
+ || !_fileSystem.TryValidateMutationTarget(
+ item.PreviousPath,
+ allowedRoots,
+ out var rollbackDestination,
+ out _))
+ {
+ rollbackSucceeded = false;
+ item.Error = "Rollback paths could not be resolved safely within the allowed library roots.";
+ continue;
+ }
+
+ var parent = Path.GetDirectoryName(rollbackDestination);
+ if (!string.IsNullOrWhiteSpace(parent))
+ {
+ await EnsureOwnedRenameHierarchyAsync(
+ parent,
+ allowedRoots,
+ semantics,
+ audiobook.Id,
+ Guid.NewGuid(),
+ cancellationToken);
+ }
+
+ // Compensation is also owner-bound and startup-discoverable. A fresh ID
+ // keeps completed compensation history from colliding with a later retry.
+ var rollbackOperationId = Guid.NewGuid();
+ item.RollbackOperationId = rollbackOperationId;
+ bool moved;
+ if (item.FileId == 0)
+ {
+ moved = await _fileMover.PerformActionOn(
+ FileAction.Move,
rollbackSource,
rollbackDestination,
- trackedFile.PhysicalObjectIdentity,
rollbackOperationId,
audiobook.Id,
- item.FileId);
- }
- if (!moved)
- {
- rollbackSucceeded = false;
- item.Error = "Rollback file move failed.";
- continue;
+ audiobookFileId: 0);
+ }
+ else
+ {
+ var trackedFile = audiobook.Files?.FirstOrDefault(
+ candidate => candidate.Id == item.FileId);
+ if (string.IsNullOrWhiteSpace(
+ trackedFile?.PhysicalObjectIdentity))
+ {
+ rollbackSucceeded = false;
+ item.Error =
+ "Rollback could not prove the tracked file generation.";
+ continue;
+ }
+
+ moved = await _fileMover
+ .MoveFilePreservingPhysicalIdentityAsync(
+ rollbackSource,
+ rollbackDestination,
+ trackedFile.PhysicalObjectIdentity,
+ rollbackOperationId,
+ audiobook.Id,
+ item.FileId);
+ }
+ if (!moved)
+ {
+ rollbackSucceeded = false;
+ item.Error = "Rollback file move failed.";
+ continue;
+ }
}
}
diff --git a/listenarr.application/Audiobooks/Renaming/RenameService.VerifiedExecution.cs b/listenarr.application/Audiobooks/Renaming/RenameService.VerifiedExecution.cs
new file mode 100644
index 000000000..206a134c9
--- /dev/null
+++ b/listenarr.application/Audiobooks/Renaming/RenameService.VerifiedExecution.cs
@@ -0,0 +1,191 @@
+using Listenarr.Domain.Common;
+using Microsoft.Extensions.Logging;
+
+namespace Listenarr.Application.Audiobooks.Renaming;
+
+public partial class RenameService
+{
+ private sealed record RenameExecutionPlan(
+ bool UseVerifiedProtocol,
+ Guid BatchId,
+ VerifiedFileRenameBatchManifest? BatchManifest,
+ IReadOnlyDictionary SourceProofs)
+ {
+ public static RenameExecutionPlan Durable(
+ IReadOnlyDictionary sourceProofs) =>
+ new(false, Guid.Empty, null, sourceProofs);
+ }
+
+ private sealed record RenameExecutionPlanningResult(
+ RenameExecutionPlan? Plan,
+ string? Error = null);
+
+ private async Task BuildRenameExecutionPlanAsync(
+ Audiobook audiobook,
+ RenameOperation operation,
+ FileSystemPathSemantics semantics,
+ CancellationToken cancellationToken)
+ {
+ var changed = (operation.FileRenames ?? [])
+ .Where(file => !PathsEqual(
+ NormalizePath(file.CurrentPath),
+ NormalizePath(file.NewPath),
+ semantics))
+ .ToArray();
+ if (changed.Length == 0)
+ {
+ return new RenameExecutionPlanningResult(
+ RenameExecutionPlan.Durable(
+ new Dictionary()));
+ }
+
+ var proofs = new Dictionary();
+ var members = new List(changed.Length);
+ var allMembersHaveDurableAuthority = true;
+ foreach (var fileOperation in changed)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var source = ResolveTrackedSourcePath(
+ audiobook,
+ fileOperation,
+ semantics,
+ out var databaseFile,
+ out var trackedPathError);
+ if (trackedPathError != null)
+ {
+ return new RenameExecutionPlanningResult(null, trackedPathError);
+ }
+
+ var destination = NormalizePath(fileOperation.NewPath);
+ var capability = await _filePublicationSourceCapability.CheckAsync(
+ source,
+ cancellationToken);
+ if (!capability.IsSupported || !capability.SourceProof.HasValue)
+ {
+ return new RenameExecutionPlanningResult(
+ null,
+ capability.Reason
+ ?? "The organize source cannot be verified safely.");
+ }
+
+ var proof = capability.SourceProof.Value;
+ proof.Validate();
+ proofs.Add(fileOperation.FileId, proof);
+ members.Add(new VerifiedFileRenameBatchMember(
+ fileOperation.FileId,
+ source,
+ destination));
+ allMembersHaveDurableAuthority &=
+ proof.HasDurablePhysicalObjectIdentity
+ && (databaseFile == null
+ || !string.IsNullOrWhiteSpace(
+ databaseFile.PhysicalObjectIdentity)
+ && !PhysicalObjectIdentitySafety.IsKnownWeak(
+ databaseFile.PhysicalObjectIdentity));
+ }
+
+ if (allMembersHaveDurableAuthority)
+ {
+ return new RenameExecutionPlanningResult(
+ RenameExecutionPlan.Durable(proofs));
+ }
+
+ var manifest = VerifiedFileRenameBatchManifest.Create(members);
+ manifest.Validate();
+ return new RenameExecutionPlanningResult(
+ new RenameExecutionPlan(
+ true,
+ Guid.NewGuid(),
+ manifest,
+ proofs));
+ }
+
+ private static FilePublicationSourceProof? FindSourceProof(
+ RenameExecutionPlan executionPlan,
+ int fileId) =>
+ executionPlan.SourceProofs.TryGetValue(fileId, out var proof)
+ ? proof
+ : null;
+
+ private static async Task DisposeVerifiedRenameLeasesAsync(
+ IEnumerable items)
+ {
+ foreach (var item in items)
+ {
+ if (item.VerifiedRenameLease == null)
+ {
+ continue;
+ }
+
+ await item.VerifiedRenameLease.DisposeAsync();
+ item.VerifiedRenameLease = null;
+ }
+ }
+
+ private async Task CompleteVerifiedRenameSourceRetirementAsync(
+ IEnumerable items,
+ CancellationToken cancellationToken)
+ {
+ var itemList = items.ToList();
+ var requiresAttention = false;
+ try
+ {
+ foreach (var item in itemList)
+ {
+ var lease = item.VerifiedRenameLease;
+ if (lease == null)
+ {
+ continue;
+ }
+
+ try
+ {
+ var outcome = await lease.CompleteSourceRetirementAsync(
+ cancellationToken);
+ switch (outcome)
+ {
+ case VerifiedFileRenameRetirementOutcome.Completed:
+ break;
+ case VerifiedFileRenameRetirementOutcome.SourceRetained:
+ _logger.LogWarning(
+ "Verified organize operation {OperationId} committed owner metadata but retained the old source for file {FileId}",
+ lease.OperationId,
+ item.FileId);
+ break;
+ case VerifiedFileRenameRetirementOutcome.NeedsAttention:
+ requiresAttention = true;
+ item.Success = false;
+ item.Error =
+ "The organized destination changed after owner metadata committed and requires repair.";
+ _logger.LogError(
+ "Verified organize operation {OperationId} requires repair after owner metadata committed for file {FileId}",
+ lease.OperationId,
+ item.FileId);
+ break;
+ default:
+ throw new InvalidOperationException(
+ $"Unknown verified organize retirement outcome '{outcome}'.");
+ }
+ }
+ finally
+ {
+ await lease.DisposeAsync();
+ item.VerifiedRenameLease = null;
+ }
+
+ if (requiresAttention)
+ {
+ break;
+ }
+ }
+
+ return !requiresAttention;
+ }
+ finally
+ {
+ // The batch may stop on NeedsAttention or an unexpected lease failure.
+ // Never leave later pinned handles alive after ExecuteRenameAsync returns.
+ await DisposeVerifiedRenameLeasesAsync(itemList);
+ }
+ }
+}
diff --git a/listenarr.application/Audiobooks/Renaming/RenameService.cs b/listenarr.application/Audiobooks/Renaming/RenameService.cs
index 0ae1bae47..8fd6b8bfc 100644
--- a/listenarr.application/Audiobooks/Renaming/RenameService.cs
+++ b/listenarr.application/Audiobooks/Renaming/RenameService.cs
@@ -40,6 +40,8 @@ public partial class RenameService : IRenameService
private readonly IMoveQueueService _moveQueueService;
private readonly ILibraryDirectoryOwnershipStore _directoryOwnershipStore;
private readonly IFileRenameCommitStore _fileRenameCommitStore;
+ private readonly IFilePublicationSourceCapability _filePublicationSourceCapability;
+ private readonly IVerifiedFileRenameTransactionCoordinator _verifiedFileRenameTransactionCoordinator;
public RenameService(
IConfigurationService configService,
@@ -56,6 +58,8 @@ public RenameService(
IMoveQueueService moveQueueService,
ILibraryDirectoryOwnershipStore directoryOwnershipStore,
IFileRenameCommitStore fileRenameCommitStore,
+ IFilePublicationSourceCapability filePublicationSourceCapability,
+ IVerifiedFileRenameTransactionCoordinator verifiedFileRenameTransactionCoordinator,
IRootFolderService? rootFolderService = null,
IHistoryRepository? historyRepository = null)
{
@@ -75,6 +79,8 @@ public RenameService(
_moveQueueService = moveQueueService ?? throw new ArgumentNullException(nameof(moveQueueService));
_directoryOwnershipStore = directoryOwnershipStore ?? throw new ArgumentNullException(nameof(directoryOwnershipStore));
_fileRenameCommitStore = fileRenameCommitStore ?? throw new ArgumentNullException(nameof(fileRenameCommitStore));
+ _filePublicationSourceCapability = filePublicationSourceCapability ?? throw new ArgumentNullException(nameof(filePublicationSourceCapability));
+ _verifiedFileRenameTransactionCoordinator = verifiedFileRenameTransactionCoordinator ?? throw new ArgumentNullException(nameof(verifiedFileRenameTransactionCoordinator));
}
public async Task> PreviewRenameAsync(int[] audiobookIds, CancellationToken ct = default)
@@ -265,6 +271,23 @@ private async Task ExecuteSingleAsync(
return validationFailure;
}
+ var executionPlanning = await BuildRenameExecutionPlanAsync(
+ audiobook,
+ operation,
+ semantics,
+ ct);
+ if (executionPlanning.Plan == null)
+ {
+ return new RenameResult
+ {
+ AudiobookId = audiobook.Id,
+ Success = false,
+ Error = executionPlanning.Error
+ ?? "The organize source cannot be verified safely."
+ };
+ }
+ var executionPlan = executionPlanning.Plan;
+
// Honor cancellation through complete preflight. Once filesystem mutation
// can begin, complete or roll back to a stable persisted state.
var mutationToken = RequestCancellationBoundary.EnterNonCancelablePhase(ct);
@@ -277,6 +300,7 @@ private async Task ExecuteSingleAsync(
fileOperation,
allowedRoots,
semantics,
+ executionPlan,
mutationToken);
result.RenamedFiles.Add(fileResult);
if (!fileResult.Success)
@@ -368,6 +392,16 @@ await CommitRollbackStateAsync(
return result;
}
+ if (!await CompleteVerifiedRenameSourceRetirementAsync(
+ result.RenamedFiles,
+ CancellationToken.None))
+ {
+ result.Success = false;
+ result.Error =
+ "The organize operation committed its path metadata, but a verified destination changed during source retirement and requires repair.";
+ return result;
+ }
+
await AddHistoryAsync(audiobook, result);
}
diff --git a/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs b/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs
index ad802d9da..912a35e94 100644
--- a/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs
+++ b/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs
@@ -353,16 +353,21 @@ private async Task EnsureNoRegistrationRecoveryTouchesRootAsync(
string rootPath,
FileSystemPathSemantics semantics)
{
- if (_fileRegistrationRecoveryProbe == null
- || !await _fileRegistrationRecoveryProbe.HasBlockingBoundaryAsync(
- rootPath,
- semantics))
+ if (_fileRegistrationRecoveryProbe == null)
+ {
+ return;
+ }
+
+ var blockers = await _fileRegistrationRecoveryProbe
+ .GetBlockingBoundaryAsync(rootPath, semantics);
+ var blocker = blockers.FirstOrDefault();
+ if (blocker == null)
{
return;
}
throw new InvalidOperationException(
- "Root folder has unresolved file-registration recovery touching this path; complete that recovery before deleting or reassigning the root.");
+ $"Root folder has unresolved file-registration recovery {blocker.OperationId} in state {blocker.JournalState}; complete that recovery before deleting or reassigning the root.");
}
private async Task ResolveSemanticsAsync(
diff --git a/listenarr.application/Downloads/Contracts/CompatibilityBatchManifest.cs b/listenarr.application/Downloads/Contracts/CompatibilityBatchManifest.cs
new file mode 100644
index 000000000..14948b910
--- /dev/null
+++ b/listenarr.application/Downloads/Contracts/CompatibilityBatchManifest.cs
@@ -0,0 +1,58 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace Listenarr.Application.Downloads.Contracts;
+
+public readonly record struct CompatibilityBatchManifest(
+ int ExpectedMemberCount,
+ string SourceManifestSha256)
+{
+ public static CompatibilityBatchManifest Create(
+ IEnumerable sourcePaths)
+ {
+ ArgumentNullException.ThrowIfNull(sourcePaths);
+ var normalized = sourcePaths
+ .Where(path => !string.IsNullOrWhiteSpace(path))
+ .Select(Path.GetFullPath)
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(path => path, StringComparer.Ordinal)
+ .ToArray();
+ if (normalized.Length == 0)
+ {
+ throw new ArgumentException(
+ "A compatibility batch manifest requires at least one source path.",
+ nameof(sourcePaths));
+ }
+
+ var payload = Encoding.UTF8.GetBytes(string.Join('\0', normalized));
+ return new CompatibilityBatchManifest(
+ normalized.Length,
+ Convert.ToHexString(SHA256.HashData(payload)));
+ }
+
+ public bool Matches(IEnumerable sourcePaths)
+ {
+ var actual = Create(sourcePaths);
+ return actual.ExpectedMemberCount == ExpectedMemberCount
+ && string.Equals(
+ actual.SourceManifestSha256,
+ SourceManifestSha256,
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ public void Validate()
+ {
+ if (ExpectedMemberCount <= 0)
+ {
+ throw new InvalidOperationException(
+ "A compatibility batch manifest must contain at least one expected member.");
+ }
+ if (string.IsNullOrWhiteSpace(SourceManifestSha256)
+ || SourceManifestSha256.Length != 64
+ || !SourceManifestSha256.All(Uri.IsHexDigit))
+ {
+ throw new InvalidOperationException(
+ "A compatibility batch manifest SHA-256 must contain exactly 64 hexadecimal characters.");
+ }
+ }
+}
diff --git a/listenarr.application/Downloads/Contracts/IDownloadImportService.cs b/listenarr.application/Downloads/Contracts/IDownloadImportService.cs
index f95bcd211..a9dcede72 100644
--- a/listenarr.application/Downloads/Contracts/IDownloadImportService.cs
+++ b/listenarr.application/Downloads/Contracts/IDownloadImportService.cs
@@ -1,7 +1,9 @@
namespace Listenarr.Application.Downloads.Contracts
{
- public sealed record DownloadImportOptions(bool ForceArchiveExtraction = false);
+ public sealed record DownloadImportOptions(
+ bool ForceArchiveExtraction = false,
+ Guid? CompatibilityBatchId = null);
///
/// Download import responsible for processing a given download importation
diff --git a/listenarr.application/Downloads/Contracts/IFileMover.cs b/listenarr.application/Downloads/Contracts/IFileMover.cs
index ce289bd05..954883761 100644
--- a/listenarr.application/Downloads/Contracts/IFileMover.cs
+++ b/listenarr.application/Downloads/Contracts/IFileMover.cs
@@ -40,6 +40,15 @@ public sealed record FilePublicationPreparationResult(
&& RegistrationLease != null;
}
+ public enum UncommittedPublicationRollbackOutcome
+ {
+ RolledBack,
+ AlreadyTerminal,
+ OwnershipCommitted,
+ Pending,
+ NeedsAttention
+ }
+
///
/// Handles file manipulation within a destination hierarchy that has already
/// been established by the caller. Implementations must not create missing
@@ -158,5 +167,12 @@ Task CompletePreparedMoveAsync(
string destination,
IAudiobookFileRegistrationLease registrationLease,
Guid operationId);
+
+ ///
+ /// Compensates an anonymous, verified registration publication by removing
+ /// only its pinned target generation while retaining the exact source.
+ ///
+ Task
+ RollbackUncommittedRegistrationAsync(Guid operationId);
}
}
diff --git a/listenarr.application/Downloads/Contracts/IFilePublicationCapabilityResolver.cs b/listenarr.application/Downloads/Contracts/IFilePublicationCapabilityResolver.cs
index a2ca1abf6..9fd936a12 100644
--- a/listenarr.application/Downloads/Contracts/IFilePublicationCapabilityResolver.cs
+++ b/listenarr.application/Downloads/Contracts/IFilePublicationCapabilityResolver.cs
@@ -30,10 +30,25 @@ public sealed record FilePublicationPlan(
int? DestinationRootFolderId = null,
int? DestinationPolicyRevision = null,
int? SourceStorageContractRevision = null,
- int? DestinationStorageContractRevision = null)
+ int? DestinationStorageContractRevision = null,
+ int? ExpectedBatchMemberCount = null,
+ string? ExpectedBatchSourceManifestSha256 = null)
{
public bool IsAllowed => Mode != FilePublicationExecutionMode.Blocked;
+ public FilePublicationPlan WithCompatibilityBatchManifest(
+ CompatibilityBatchManifest manifest)
+ {
+ manifest.Validate();
+ return Mode == FilePublicationExecutionMode.CompatibilityCopyVerifiedCleanup
+ ? this with
+ {
+ ExpectedBatchMemberCount = manifest.ExpectedMemberCount,
+ ExpectedBatchSourceManifestSha256 = manifest.SourceManifestSha256
+ }
+ : this;
+ }
+
public static FilePublicationPlan Durable(FileAction action) =>
new(
action,
diff --git a/listenarr.application/Downloads/Import/DownloadImportService.Coordination.cs b/listenarr.application/Downloads/Import/DownloadImportService.Coordination.cs
index feae4ee81..a4a381cab 100644
--- a/listenarr.application/Downloads/Import/DownloadImportService.Coordination.cs
+++ b/listenarr.application/Downloads/Import/DownloadImportService.Coordination.cs
@@ -28,7 +28,8 @@ await moveQueueService.EnsureFilesystemMutationAllowedAsync(
token)
?? throw new InvalidOperationException(
$"Audiobook {audiobook.Id} no longer exists");
- var compatibilityBatchId = Guid.NewGuid();
+ var compatibilityBatchId = options?.CompatibilityBatchId
+ ?? Guid.NewGuid();
var results = await ImportDownloadFilesCoreAsync(
currentAudiobook,
files,
diff --git a/listenarr.application/Downloads/Import/DownloadImportService.DirectoryOwnership.cs b/listenarr.application/Downloads/Import/DownloadImportService.DirectoryOwnership.cs
index 119296feb..576de4f0c 100644
--- a/listenarr.application/Downloads/Import/DownloadImportService.DirectoryOwnership.cs
+++ b/listenarr.application/Downloads/Import/DownloadImportService.DirectoryOwnership.cs
@@ -5,19 +5,20 @@ namespace Listenarr.Application.Downloads.Import;
public partial class DownloadImportService
{
- private Task ResolvePublicationPlanAsync(
+ private async Task ResolvePublicationPlanAsync(
FileAction requestedAction,
string source,
string destination,
FilePublicationSourceProof sourceProof,
Guid compatibilityBatchId,
+ CompatibilityBatchManifest? compatibilityBatchManifest,
CancellationToken cancellationToken)
{
- return filePublicationCapabilityResolver == null
- ? Task.FromResult(sourceProof.HasDurablePhysicalObjectIdentity
+ var plan = filePublicationCapabilityResolver == null
+ ? sourceProof.HasDurablePhysicalObjectIdentity
? FilePublicationPlan.Durable(requestedAction)
- : FilePublicationPlan.Additive(requestedAction))
- : filePublicationCapabilityResolver.ResolveAsync(
+ : FilePublicationPlan.Additive(requestedAction)
+ : await filePublicationCapabilityResolver.ResolveAsync(
requestedAction,
source,
destination,
@@ -25,6 +26,12 @@ private Task ResolvePublicationPlanAsync(
cancellationToken,
compatibilityBatchId,
CompatibilityCleanupOwner.DownloadClient);
+ if (compatibilityBatchManifest.HasValue)
+ {
+ plan = plan.WithCompatibilityBatchManifest(
+ compatibilityBatchManifest.Value);
+ }
+ return plan;
}
private static ImportResult CreateBlockedImportResult(
@@ -112,6 +119,7 @@ private static ImportSourceDisposition ToImportSourceDisposition(
FilePublicationSourceProof expectedSourceProof,
int audiobookId,
Guid compatibilityBatchId,
+ CompatibilityBatchManifest? compatibilityBatchManifest,
CancellationToken cancellationToken)
{
expectedSourceProof.Validate();
@@ -127,6 +135,11 @@ private static ImportSourceDisposition ToImportSourceDisposition(
cancellationToken,
compatibilityBatchId,
CompatibilityCleanupOwner.DownloadClient);
+ if (compatibilityBatchManifest.HasValue)
+ {
+ publicationPlan = publicationPlan.WithCompatibilityBatchManifest(
+ compatibilityBatchManifest.Value);
+ }
if (!publicationPlan.IsAllowed)
{
logger.LogWarning(
diff --git a/listenarr.application/Downloads/Import/DownloadImportService.Quality.cs b/listenarr.application/Downloads/Import/DownloadImportService.Quality.cs
new file mode 100644
index 000000000..95f339d2f
--- /dev/null
+++ b/listenarr.application/Downloads/Import/DownloadImportService.Quality.cs
@@ -0,0 +1,49 @@
+namespace Listenarr.Application.Downloads.Import;
+
+public partial class DownloadImportService
+{
+ private static string? ResolveBestExistingQuality(
+ Audiobook audiobook,
+ QualityProfile? profile)
+ {
+ string? bestExisting = null;
+ if (audiobook.Files == null || audiobook.Files.Count == 0)
+ {
+ return bestExisting;
+ }
+
+ foreach (var file in audiobook.Files)
+ {
+ var quality = file.Format ?? string.Empty;
+ if (file.Bitrate.HasValue)
+ {
+ var kbps = file.Bitrate.Value / 1000;
+ if (kbps >= 320) quality = "MP3 320kbps";
+ else if (kbps >= 256) quality = "MP3 256kbps";
+ else if (kbps >= 192) quality = "MP3 192kbps";
+ else if (kbps >= 128) quality = "MP3 128kbps";
+ }
+
+ if (string.IsNullOrEmpty(quality) && !string.IsNullOrEmpty(file.Path))
+ {
+ quality = ImportQualityEvaluator.Determine(null, file.Path);
+ }
+
+ if (string.IsNullOrEmpty(bestExisting))
+ {
+ bestExisting = quality;
+ }
+ else if (!string.IsNullOrEmpty(quality)
+ && profile != null
+ && ImportQualityEvaluator.IsAcceptable(
+ quality,
+ bestExisting,
+ profile))
+ {
+ bestExisting = quality;
+ }
+ }
+
+ return bestExisting;
+ }
+}
diff --git a/listenarr.application/Downloads/Import/DownloadImportService.cs b/listenarr.application/Downloads/Import/DownloadImportService.cs
index 5296e87b8..3ebf064cb 100644
--- a/listenarr.application/Downloads/Import/DownloadImportService.cs
+++ b/listenarr.application/Downloads/Import/DownloadImportService.cs
@@ -140,30 +140,15 @@ private async Task> ImportDownloadFilesCoreAsync(
var orderedFiles = plannedAudioFiles.Select(p => p.FullPath)
.Concat(sourceFiles.Where(f => !planByPath.ContainsKey(f)))
.ToList();
+ var compatibilityBatchManifest =
+ completedFileAction == FileAction.Move && orderedFiles.Count > 0
+ ? CompatibilityBatchManifest.Create(orderedFiles)
+ : (CompatibilityBatchManifest?)null;
try
{
- string? bestExisting = null;
- QualityProfile? abProfile = audiobook.QualityProfile;
- if (audiobook.Files != null && audiobook.Files.Count != 0)
- {
- foreach (var f in audiobook.Files)
- {
- string q = string.Empty;
- if (!string.IsNullOrEmpty(f.Format)) q = f.Format;
- if (f.Bitrate.HasValue)
- {
- var kb = f.Bitrate.Value / 1000;
- if (kb >= 320) q = "MP3 320kbps";
- else if (kb >= 256) q = "MP3 256kbps";
- else if (kb >= 192) q = "MP3 192kbps";
- else if (kb >= 128) q = "MP3 128kbps";
- }
- if (string.IsNullOrEmpty(q) && !string.IsNullOrEmpty(f.Path)) q = ImportQualityEvaluator.Determine(null, f.Path);
- if (string.IsNullOrEmpty(bestExisting)) bestExisting = q;
- else if (!string.IsNullOrEmpty(q) && !string.IsNullOrEmpty(bestExisting) && abProfile != null && ImportQualityEvaluator.IsAcceptable(q, bestExisting, abProfile)) bestExisting = q;
- }
- }
+ var abProfile = audiobook.QualityProfile;
+ var bestExisting = ResolveBestExistingQuality(audiobook, abProfile);
foreach (var file in orderedFiles)
{
@@ -177,7 +162,9 @@ private async Task> ImportDownloadFilesCoreAsync(
var hasSuccessfulAudioImport = results.Any(r => r.Success && !string.IsNullOrWhiteSpace(r.FinalPath) && !string.IsNullOrWhiteSpace(r.SourcePath) && FileUtils.IsAudioFile(r.SourcePath!));
if (!hasSuccessfulAudioImport || string.IsNullOrWhiteSpace(audiobook.BasePath))
{
- results.Add(ImportResult.Skipped("No successful audio import in batch"));
+ results.Add(ImportResult.Skipped(
+ "No successful audio import in batch",
+ file));
logger.LogDebug("ImportFilesFromDirectory: Skipping companion file {File} because no successful audio import was recorded for the batch", file);
continue;
}
@@ -227,6 +214,7 @@ await PerformOwnedFileActionAsync(
sourceProof.Value,
audiobook.Id,
compatibilityBatchId,
+ compatibilityBatchManifest,
ct);
if (companionPublication == null)
{
@@ -282,7 +270,9 @@ await ResolvePublishableSourceProofAsync(
{
if (audiobook.Files != null && audiobook.Files.Count != 0 && !ImportQualityEvaluator.IsAcceptable(candidateQuality, bestExisting, abProfile))
{
- results.Add(ImportResult.Skipped($"candidate quality '{candidateQuality}' is not better than existing '{bestExisting}'"));
+ results.Add(ImportResult.Skipped(
+ $"candidate quality '{candidateQuality}' is not better than existing '{bestExisting}'",
+ file));
logger.LogInformation($"Skipping import of file {file} for audiobook {audiobook.Id} because candidate quality '{candidateQuality}' is not better than existing '{bestExisting}'");
continue;
}
@@ -435,6 +425,7 @@ AudiobookFileOwnershipCheckOutcome.Available or
destination,
sourceProof.Value,
compatibilityBatchId,
+ compatibilityBatchManifest,
ct);
if (!publicationPlan.IsAllowed)
{
diff --git a/listenarr.domain/Downloads/CompatibilityFilePublicationJournal.cs b/listenarr.domain/Downloads/CompatibilityFilePublicationJournal.cs
index 426525f08..16bd3f03e 100644
--- a/listenarr.domain/Downloads/CompatibilityFilePublicationJournal.cs
+++ b/listenarr.domain/Downloads/CompatibilityFilePublicationJournal.cs
@@ -61,6 +61,9 @@ public sealed class CompatibilityFilePublicationJournal
public int? DestinationRootFolderId { get; set; }
public int? DestinationPolicyRevision { get; set; }
public int? DestinationStorageContractRevision { get; set; }
+ public int? ExpectedBatchMemberCount { get; set; }
+ [MaxLength(64)]
+ public string? ExpectedBatchSourceManifestSha256 { get; set; }
[Required, MaxLength(4096)]
public string SourcePath { get; set; } = string.Empty;
[Required, MaxLength(4096)]
diff --git a/listenarr.domain/Downloads/FileMutationJournal.cs b/listenarr.domain/Downloads/FileMutationJournal.cs
index 47b10546c..1f2a8d7ba 100644
--- a/listenarr.domain/Downloads/FileMutationJournal.cs
+++ b/listenarr.domain/Downloads/FileMutationJournal.cs
@@ -37,7 +37,43 @@ public enum FileMutationJournalState
SourceDeleted,
Completed,
OwnerMetadataReconciled,
- NeedsAttention
+ NeedsAttention,
+ RollbackAuthorized,
+ RolledBack
+}
+
+public static class FileMutationJournalLifecycle
+{
+ public static bool IsRegistrationPublicationTerminal(
+ FileMutationJournalState state) =>
+ state is FileMutationJournalState.Completed
+ or FileMutationJournalState.RolledBack
+ or FileMutationJournalState.NeedsAttention;
+
+ public static bool IsRegistrationPublicationRecoverable(
+ FileMutationJournalState state) =>
+ state is FileMutationJournalState.Planned
+ or FileMutationJournalState.TargetIdentityPersisted
+ or FileMutationJournalState.TargetVerified
+ or FileMutationJournalState.RegistrationCommitted
+ or FileMutationJournalState.SourceDeletionAuthorized
+ or FileMutationJournalState.SourceDeleted
+ or FileMutationJournalState.RollbackAuthorized;
+
+ public static bool RequiresOperatorAttention(
+ FileMutationJournalState state) =>
+ state == FileMutationJournalState.NeedsAttention;
+
+ public static bool MayRetireSource(FileMutationJournalState state) =>
+ state is FileMutationJournalState.RegistrationCommitted
+ or FileMutationJournalState.SourceDeletionAuthorized
+ or FileMutationJournalState.SourceDeleted
+ or FileMutationJournalState.Completed;
+
+ public static bool ClearsRegistrationRecoveryBoundary(
+ FileMutationJournalState state) =>
+ state is FileMutationJournalState.Completed
+ or FileMutationJournalState.RolledBack;
}
///
diff --git a/listenarr.domain/Downloads/ImportResult.cs b/listenarr.domain/Downloads/ImportResult.cs
index 94a701971..8437f85e8 100644
--- a/listenarr.domain/Downloads/ImportResult.cs
+++ b/listenarr.domain/Downloads/ImportResult.cs
@@ -112,12 +112,18 @@ public static ImportResult Exception(Exception exception, string sourcePath = ""
};
}
- public static ImportResult Skipped(string message)
+ public static ImportResult Skipped(
+ string message,
+ string? sourcePath = null)
{
return new ImportResult
{
Success = true,
- Message = message
+ Message = message,
+ SourcePath = sourcePath,
+ SourceDisposition = string.IsNullOrWhiteSpace(sourcePath)
+ ? ImportSourceDisposition.Unchanged
+ : ImportSourceDisposition.Retained
};
}
}
diff --git a/listenarr.domain/Downloads/VerifiedFileRenameJournal.cs b/listenarr.domain/Downloads/VerifiedFileRenameJournal.cs
new file mode 100644
index 000000000..283041a25
--- /dev/null
+++ b/listenarr.domain/Downloads/VerifiedFileRenameJournal.cs
@@ -0,0 +1,81 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Listenarr.Domain.Downloads;
+
+public static class VerifiedFileRenameProtocol
+{
+ public const int Current = 1;
+}
+
+public enum VerifiedFileRenameState
+{
+ Planned,
+ TargetVerified,
+ OwnerMetadataReconciled,
+ SourceQuarantined,
+ SourceDeleted,
+ Completed,
+ CompletedSourceRetained,
+ RolledBack,
+ NeedsAttention
+}
+
+///
+/// Durable owner-bound organize transaction for storage where persistent physical
+/// generation identity is unavailable. Content hashes prove byte equality only;
+/// destructive source retirement is permitted only while the original process still
+/// holds the pinned source entry that was verified before publication.
+///
+public sealed class VerifiedFileRenameJournal
+{
+ [Key]
+ public Guid OperationId { get; set; }
+
+ public Guid BatchId { get; set; }
+
+ public int ProtocolVersion { get; set; } = VerifiedFileRenameProtocol.Current;
+
+ public int AudiobookId { get; set; }
+
+ public int AudiobookFileId { get; set; }
+
+ public int ExpectedBatchMemberCount { get; set; }
+
+ [Required, MaxLength(64)]
+ public string ExpectedBatchManifestSha256 { get; set; } = string.Empty;
+
+ [Required, MaxLength(4096)]
+ public string SourcePath { get; set; } = string.Empty;
+
+ [Required, MaxLength(4096)]
+ public string DestinationPath { get; set; } = string.Empty;
+
+ [Required, MaxLength(4096)]
+ public string StagingPath { get; set; } = string.Empty;
+
+ [Required, MaxLength(4096)]
+ public string RetirementPath { get; set; } = string.Empty;
+
+ public long SourceLength { get; set; }
+
+ [Required, MaxLength(64)]
+ public string SourceSha256 { get; set; } = string.Empty;
+
+ public int SourceRootFolderId { get; set; }
+
+ public int SourceStorageContractRevision { get; set; }
+
+ public int DestinationRootFolderId { get; set; }
+
+ public int DestinationStorageContractRevision { get; set; }
+
+ public VerifiedFileRenameState State { get; set; } =
+ VerifiedFileRenameState.Planned;
+
+ [MaxLength(2048)]
+ public string? Error { get; set; }
+
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs b/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs
index e61e7a548..ea53ad795 100644
--- a/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs
+++ b/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs
@@ -44,10 +44,14 @@ public static IServiceCollection AddLibraryServices(this IServiceCollection serv
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped(provider =>
provider.GetRequiredService());
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs b/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs
index f05afe9a0..0504539a9 100644
--- a/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs
+++ b/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs
@@ -17,6 +17,8 @@
*/
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
+using System.Security.Cryptography;
+using System.Text;
using System.Text.Json;
namespace Listenarr.Infrastructure.Downloads.Processing
@@ -277,12 +279,12 @@ await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, d
try
{
var downloadImportService = scope.ServiceProvider.GetRequiredService();
- var importOptions = isDirectDownload && string.Equals(
- download.GetMetadataString(DirectDownloadMetadataKeys.RequiresArchiveExtraction),
- bool.TrueString,
- StringComparison.OrdinalIgnoreCase)
- ? new DownloadImportOptions(ForceArchiveExtraction: true)
- : null;
+ var importOptions = new DownloadImportOptions(
+ ForceArchiveExtraction: isDirectDownload && string.Equals(
+ download.GetMetadataString(DirectDownloadMetadataKeys.RequiresArchiveExtraction),
+ bool.TrueString,
+ StringComparison.OrdinalIgnoreCase),
+ CompatibilityBatchId: ResolveCompatibilityBatchId(job.Id));
results = await downloadImportService.ImportDownloadFilesAsync(
audiobook,
files,
@@ -453,5 +455,17 @@ await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, d
correlationId, $"Unable to commit import finalization: {exception.Message}", cancellationToken);
}
}
+
+ private static Guid ResolveCompatibilityBatchId(string jobId)
+ {
+ if (Guid.TryParse(jobId, out var parsed))
+ {
+ return parsed;
+ }
+
+ var hash = SHA256.HashData(
+ Encoding.UTF8.GetBytes("download-import:" + jobId));
+ return new Guid(hash.AsSpan(0, 16));
+ }
}
}
diff --git a/listenarr.infrastructure/FileSystem/CompatibilityFilePublicationJournalStore.cs b/listenarr.infrastructure/FileSystem/CompatibilityFilePublicationJournalStore.cs
index cf766d27b..7f80aaa17 100644
--- a/listenarr.infrastructure/FileSystem/CompatibilityFilePublicationJournalStore.cs
+++ b/listenarr.infrastructure/FileSystem/CompatibilityFilePublicationJournalStore.cs
@@ -19,7 +19,9 @@ internal sealed record CompatibilityFilePublicationClaim(
int? DestinationRootFolderId = null,
int? DestinationPolicyRevision = null,
int? SourceStorageContractRevision = null,
- int? DestinationStorageContractRevision = null);
+ int? DestinationStorageContractRevision = null,
+ int? ExpectedBatchMemberCount = null,
+ string? ExpectedBatchSourceManifestSha256 = null);
internal sealed class CompatibilityFilePublicationJournalStore(
IDbContextFactory dbContextFactory,
@@ -50,6 +52,14 @@ public async Task GetOrCreateAsync(
var existing = await GetAsync(claim.OperationId, cancellationToken);
if (existing != null)
{
+ if (CanRebindLegacyRetainedAttempt(existing, claim))
+ {
+ return await RebindLegacyRetainedAttemptAsync(
+ existing.OperationId,
+ claim,
+ cancellationToken);
+ }
+
ValidateClaim(existing, claim);
return existing;
}
@@ -69,6 +79,8 @@ public async Task GetOrCreateAsync(
DestinationRootFolderId = claim.DestinationRootFolderId,
DestinationPolicyRevision = claim.DestinationPolicyRevision,
DestinationStorageContractRevision = claim.DestinationStorageContractRevision,
+ ExpectedBatchMemberCount = claim.ExpectedBatchMemberCount,
+ ExpectedBatchSourceManifestSha256 = claim.ExpectedBatchSourceManifestSha256,
SourcePath = Path.GetFullPath(claim.SourcePath),
DestinationPath = Path.GetFullPath(claim.DestinationPath),
SourceLength = claim.SourceLength,
@@ -190,6 +202,71 @@ next is CompatibilityFilePublicationState.Completed
};
}
+ private static bool CanRebindLegacyRetainedAttempt(
+ CompatibilityFilePublicationJournal journal,
+ CompatibilityFilePublicationClaim claim) =>
+ journal.ProtocolVersion == CompatibilityFilePublicationProtocol.Current
+ && journal.State == CompatibilityFilePublicationState.Completed
+ && journal.SourceDisposition == CompatibilitySourceDisposition.Retained
+ && journal.CleanupOwner != CompatibilityCleanupOwner.None
+ && journal.CleanupOwner == claim.CleanupOwner
+ && journal.ExpectedBatchMemberCount == null
+ && string.IsNullOrWhiteSpace(journal.ExpectedBatchSourceManifestSha256)
+ && string.IsNullOrWhiteSpace(journal.QuarantinePath)
+ && claim.ExpectedBatchMemberCount.HasValue
+ && !string.IsNullOrWhiteSpace(claim.ExpectedBatchSourceManifestSha256)
+ && journal.RequestedAction == claim.RequestedAction
+ && string.Equals(
+ journal.SourcePath,
+ Path.GetFullPath(claim.SourcePath),
+ StringComparison.Ordinal)
+ && string.Equals(
+ journal.DestinationPath,
+ Path.GetFullPath(claim.DestinationPath),
+ StringComparison.Ordinal)
+ && journal.SourceLength == claim.SourceLength
+ && string.Equals(
+ journal.SourceSha256,
+ claim.SourceSha256,
+ StringComparison.OrdinalIgnoreCase)
+ && journal.IsCompanionFile == claim.IsCompanionFile
+ && journal.SourceRootFolderId == claim.SourceRootFolderId
+ && journal.DestinationRootFolderId == claim.DestinationRootFolderId;
+
+ private async Task
+ RebindLegacyRetainedAttemptAsync(
+ Guid operationId,
+ CompatibilityFilePublicationClaim claim,
+ CancellationToken cancellationToken)
+ {
+ await using var context = await dbContextFactory.CreateDbContextAsync(
+ cancellationToken);
+ var journal = await context.CompatibilityFilePublicationJournals
+ .SingleAsync(
+ candidate => candidate.OperationId == operationId,
+ cancellationToken);
+ if (!CanRebindLegacyRetainedAttempt(journal, claim))
+ {
+ ValidateClaim(journal, claim);
+ return journal;
+ }
+
+ journal.BatchId = claim.BatchId;
+ journal.SourcePolicyRevision = claim.SourcePolicyRevision;
+ journal.SourceStorageContractRevision = claim.SourceStorageContractRevision;
+ journal.DestinationPolicyRevision = claim.DestinationPolicyRevision;
+ journal.DestinationStorageContractRevision =
+ claim.DestinationStorageContractRevision;
+ journal.ExpectedBatchMemberCount = claim.ExpectedBatchMemberCount;
+ journal.ExpectedBatchSourceManifestSha256 =
+ claim.ExpectedBatchSourceManifestSha256;
+ journal.State = CompatibilityFilePublicationState.RegistrationCommitted;
+ journal.Error = null;
+ journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime;
+ await context.SaveChangesAsync(cancellationToken);
+ return journal;
+ }
+
private static void ValidateClaim(
CompatibilityFilePublicationJournal journal,
CompatibilityFilePublicationClaim claim)
@@ -216,7 +293,12 @@ CompatibilityFilePublicationProtocol.RetainOnly or
|| journal.SourceStorageContractRevision != claim.SourceStorageContractRevision
|| journal.DestinationRootFolderId != claim.DestinationRootFolderId
|| journal.DestinationPolicyRevision != claim.DestinationPolicyRevision
- || journal.DestinationStorageContractRevision != claim.DestinationStorageContractRevision))
+ || journal.DestinationStorageContractRevision != claim.DestinationStorageContractRevision
+ || journal.ExpectedBatchMemberCount != claim.ExpectedBatchMemberCount
+ || !string.Equals(
+ journal.ExpectedBatchSourceManifestSha256,
+ claim.ExpectedBatchSourceManifestSha256,
+ StringComparison.OrdinalIgnoreCase)))
|| !string.Equals(
journal.SourceSha256,
claim.SourceSha256,
diff --git a/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.BatchManifest.cs b/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.BatchManifest.cs
new file mode 100644
index 000000000..022cbb0f8
--- /dev/null
+++ b/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.BatchManifest.cs
@@ -0,0 +1,84 @@
+using Listenarr.Infrastructure.Persistence;
+
+namespace Listenarr.Infrastructure.FileSystem;
+
+public sealed partial class CompatibilitySourceCleanupCoordinator
+{
+ private static bool HasPersistedBatchManifest(
+ IReadOnlyCollection journals) =>
+ journals.Count > 0
+ && journals.All(journal =>
+ journal.ExpectedBatchMemberCount.HasValue
+ && !string.IsNullOrWhiteSpace(
+ journal.ExpectedBatchSourceManifestSha256));
+
+ private static bool BatchManifestMatches(
+ IReadOnlyCollection journals)
+ {
+ var manifestJournals = journals
+ .Where(journal =>
+ journal.ExpectedBatchMemberCount.HasValue
+ || !string.IsNullOrWhiteSpace(
+ journal.ExpectedBatchSourceManifestSha256))
+ .ToList();
+ if (manifestJournals.Count == 0)
+ {
+ // Released verified-cleanup journals predate persisted batch manifests.
+ // Same-process completion remains compatible; startup recovery keeps
+ // those older batches retain-only because it cannot prove completeness.
+ return true;
+ }
+ if (manifestJournals.Count != journals.Count)
+ {
+ return false;
+ }
+
+ var first = manifestJournals[0];
+ if (!first.ExpectedBatchMemberCount.HasValue
+ || string.IsNullOrWhiteSpace(
+ first.ExpectedBatchSourceManifestSha256))
+ {
+ return false;
+ }
+
+ var manifest = new CompatibilityBatchManifest(
+ first.ExpectedBatchMemberCount.Value,
+ first.ExpectedBatchSourceManifestSha256);
+ try
+ {
+ manifest.Validate();
+ }
+ catch (InvalidOperationException)
+ {
+ return false;
+ }
+
+ if (manifestJournals.Any(journal =>
+ journal.ExpectedBatchMemberCount != manifest.ExpectedMemberCount
+ || !string.Equals(
+ journal.ExpectedBatchSourceManifestSha256,
+ manifest.SourceManifestSha256,
+ StringComparison.OrdinalIgnoreCase)))
+ {
+ return false;
+ }
+
+ return manifest.Matches(journals.Select(journal => journal.SourcePath));
+ }
+
+ private async Task RetainBatchAsync(
+ ListenArrDbContext context,
+ IReadOnlyCollection journals,
+ CancellationToken cancellationToken)
+ {
+ foreach (var journal in journals.Where(journal =>
+ journal.State == CompatibilityFilePublicationState.RegistrationCommitted))
+ {
+ journal.SourceDisposition = CompatibilitySourceDisposition.Retained;
+ journal.State = CompatibilityFilePublicationState.Completed;
+ journal.Error = null;
+ journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime;
+ }
+ await context.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.QuarantineCleanup.cs b/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.QuarantineCleanup.cs
new file mode 100644
index 000000000..3c7bb97b9
--- /dev/null
+++ b/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.QuarantineCleanup.cs
@@ -0,0 +1,41 @@
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+
+namespace Listenarr.Infrastructure.FileSystem;
+
+public sealed partial class CompatibilitySourceCleanupCoordinator
+{
+ private void TryRemoveEmptyOwnedQuarantine(string path, Guid batchId)
+ {
+ try
+ {
+ var markerPath = Path.Join(path, OwnershipMarkerName);
+ var expectedMarker = JsonSerializer.Serialize(new
+ {
+ ProtocolVersion = CompatibilityFilePublicationProtocol.Current,
+ BatchId = batchId
+ });
+ if (!File.Exists(markerPath)
+ || !string.Equals(
+ File.ReadAllText(markerPath),
+ expectedMarker,
+ StringComparison.Ordinal)
+ || Directory.EnumerateFileSystemEntries(path)
+ .Any(entry => !string.Equals(entry, markerPath, StringComparison.Ordinal)))
+ {
+ return;
+ }
+
+ File.Delete(markerPath);
+ Directory.Delete(path, recursive: false);
+ }
+ catch (Exception exception) when (exception is not (
+ OutOfMemoryException or StackOverflowException))
+ {
+ logger.LogDebug(
+ exception,
+ "Could not remove empty compatibility quarantine {QuarantinePath}",
+ path);
+ }
+ }
+}
diff --git a/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.cs b/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.cs
index 5f06aa8fb..da1cda4e0 100644
--- a/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.cs
+++ b/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.cs
@@ -1,4 +1,3 @@
-using System.Text.Json;
using Listenarr.Domain.Audiobooks.Enumerations;
using Listenarr.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -43,12 +42,52 @@ public async Task CompleteBatchAsync(
CompatibilityBatchCleanupDisposition.NotApplicable);
}
+ if (journals.All(journal =>
+ journal.State == CompatibilityFilePublicationState.Completed))
+ {
+ if (journals.All(journal =>
+ journal.ProtocolVersion == CompatibilityFilePublicationProtocol.Current
+ && journal.RequestedAction == FileAction.Move
+ && journal.CleanupOwner == CompatibilityCleanupOwner.DownloadClient
+ && journal.SourceDisposition
+ == CompatibilitySourceDisposition.DeferredToDownloadClient)
+ && HasPersistedBatchManifest(journals)
+ && BatchManifestMatches(journals)
+ && await PoliciesStillAuthorizeAsync(
+ context,
+ journals,
+ cancellationToken)
+ && journals.All(journal => ContentMatches(
+ journal.DestinationPath,
+ journal.TargetLength ?? journal.SourceLength,
+ journal.TargetSha256 ?? journal.SourceSha256)))
+ {
+ return new CompatibilityBatchCleanupResult(
+ CompatibilityBatchCleanupDisposition.DeferredToDownloadClient,
+ RetainedCount: journals.Count);
+ }
+
+ if (journals.All(journal =>
+ journal.SourceDisposition
+ == CompatibilitySourceDisposition.RetiredByListenarr))
+ {
+ return new CompatibilityBatchCleanupResult(
+ CompatibilityBatchCleanupDisposition.RetiredByListenarr,
+ RemovedCount: journals.Count);
+ }
+
+ return new CompatibilityBatchCleanupResult(
+ CompatibilityBatchCleanupDisposition.Retained,
+ RetainedCount: journals.Count);
+ }
+
if (!batchSucceeded
|| journals.Any(journal =>
journal.ProtocolVersion != CompatibilityFilePublicationProtocol.Current
|| journal.State != CompatibilityFilePublicationState.RegistrationCommitted
|| journal.RequestedAction != FileAction.Move
|| journal.CleanupOwner == CompatibilityCleanupOwner.None)
+ || !BatchManifestMatches(journals)
|| !await PoliciesStillAuthorizeAsync(context, journals, cancellationToken)
|| journals.Any(journal => !ContentMatches(
journal.DestinationPath,
@@ -436,54 +475,4 @@ CompatibilityFilePublicationState.RegistrationCommitted or
}
}
- private void TryRemoveEmptyOwnedQuarantine(string path, Guid batchId)
- {
- try
- {
- var markerPath = Path.Join(path, OwnershipMarkerName);
- var expectedMarker = JsonSerializer.Serialize(new
- {
- ProtocolVersion = CompatibilityFilePublicationProtocol.Current,
- BatchId = batchId
- });
- if (!File.Exists(markerPath)
- || !string.Equals(
- File.ReadAllText(markerPath),
- expectedMarker,
- StringComparison.Ordinal)
- || Directory.EnumerateFileSystemEntries(path)
- .Any(entry => !string.Equals(entry, markerPath, StringComparison.Ordinal)))
- {
- return;
- }
-
- File.Delete(markerPath);
- Directory.Delete(path, recursive: false);
- }
- catch (Exception exception) when (exception is not (
- OutOfMemoryException or StackOverflowException))
- {
- logger.LogDebug(
- exception,
- "Could not remove empty compatibility quarantine {QuarantinePath}",
- path);
- }
- }
-
- private async Task RetainBatchAsync(
- ListenArrDbContext context,
- IReadOnlyCollection journals,
- CancellationToken cancellationToken)
- {
- foreach (var journal in journals.Where(journal =>
- journal.State == CompatibilityFilePublicationState.RegistrationCommitted))
- {
- journal.SourceDisposition = CompatibilitySourceDisposition.Retained;
- journal.State = CompatibilityFilePublicationState.Completed;
- journal.Error = null;
- journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime;
- }
- await context.SaveChangesAsync(cancellationToken);
- }
-
}
diff --git a/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs b/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs
index 8fb40b1d6..ee756e0c7 100644
--- a/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs
+++ b/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs
@@ -7,13 +7,20 @@ internal sealed class DirectoryObjectIdentityResolver(
Func?
nativeIdentityResolver = null,
Func>?
- nativeIdentityCandidatesResolver = null) : IDirectoryObjectIdentityResolver
+ nativeIdentityCandidatesResolver = null,
+ Func>?
+ legacyWeakIdentityCandidatesResolver = null) : IDirectoryObjectIdentityResolver
{
private readonly Func>
_nativeIdentityCandidatesResolver = nativeIdentityCandidatesResolver
?? (nativeIdentityResolver == null
? static anchor => anchor.GetDirectoryObjectIdentityCandidates()
: anchor => [nativeIdentityResolver(anchor)]);
+ private readonly Func>
+ _legacyWeakIdentityCandidatesResolver = legacyWeakIdentityCandidatesResolver
+ ?? (nativeIdentityResolver == null && nativeIdentityCandidatesResolver == null
+ ? static anchor => anchor.GetLegacyWeakDirectoryObjectIdentityCandidates()
+ : static _ => Array.Empty());
public Task ResolveAsync(
string path,
@@ -21,10 +28,15 @@ public Task ResolveAsync(
ResolvePinnedAsync(
path,
cancellationToken,
- nativeIdentities => new DirectoryObjectIdentityResolution(
- ManagedDirectoryIdentity.CurrentVersion,
- ManagedDirectoryIdentity.CreateMarkerless(nativeIdentities[0]),
- null));
+ anchor =>
+ {
+ var nativeIdentities = _nativeIdentityCandidatesResolver(anchor);
+ EnsureDurableCandidateAvailable(nativeIdentities);
+ return new DirectoryObjectIdentityResolution(
+ ManagedDirectoryIdentity.CurrentVersion,
+ ManagedDirectoryIdentity.CreateMarkerless(nativeIdentities[0]),
+ null);
+ });
public Task ResolveExistingAsync(
string path,
@@ -44,8 +56,22 @@ public Task ResolveExistingAsync(
return ResolvePinnedAsync(
path,
cancellationToken,
- nativeIdentities =>
+ anchor =>
{
+ var legacyWeakIdentities = _legacyWeakIdentityCandidatesResolver(anchor);
+ if (legacyWeakIdentities.Any(nativeIdentity =>
+ ManagedDirectoryIdentity.MatchesNativeIdentity(
+ expectedVersion,
+ expectedValue,
+ nativeIdentity)))
+ {
+ return DirectoryObjectIdentityResolution.Unavailable(
+ "The persisted Linux directory identity uses the generic FILEID_INO64_GEN handle, which does not prove a durable filesystem generation.",
+ DirectoryObjectIdentityFailureKind.LegacyWeakIdentity);
+ }
+
+ var nativeIdentities = _nativeIdentityCandidatesResolver(anchor);
+ EnsureDurableCandidateAvailable(nativeIdentities);
if (nativeIdentities.Any(nativeIdentity =>
ManagedDirectoryIdentity.MatchesNativeIdentity(
expectedVersion,
@@ -77,7 +103,7 @@ public Task ResolveExistingAsync(
private Task ResolvePinnedAsync(
string path,
CancellationToken cancellationToken,
- Func, DirectoryObjectIdentityResolution> resolve)
+ Func resolve)
{
cancellationToken.ThrowIfCancellationRequested();
ArgumentException.ThrowIfNullOrWhiteSpace(path);
@@ -97,12 +123,7 @@ private Task ResolvePinnedAsync(
try
{
using var anchor = PinnedDirectoryCreation.OpenPinnedBoundary(canonicalPath);
- var nativeIdentities = _nativeIdentityCandidatesResolver(anchor);
- if (nativeIdentities.Count == 0)
- {
- throw new PlatformNotSupportedException(
- "The filesystem did not expose a durable directory identity candidate.");
- }
+ var resolution = resolve(anchor);
if (!anchor.VisiblePathMatches())
{
return Task.FromResult(
@@ -111,7 +132,7 @@ private Task ResolvePinnedAsync(
DirectoryObjectIdentityFailureKind.IdentityUnstable));
}
- return Task.FromResult(resolve(nativeIdentities));
+ return Task.FromResult(resolution);
}
catch (Exception exception) when (exception is
IOException or UnauthorizedAccessException or Win32Exception
@@ -124,6 +145,16 @@ IOException or UnauthorizedAccessException or Win32Exception
}
}
+ private static void EnsureDurableCandidateAvailable(
+ IReadOnlyList nativeIdentities)
+ {
+ if (nativeIdentities.Count == 0)
+ {
+ throw new PlatformNotSupportedException(
+ "The filesystem did not expose a durable directory identity candidate.");
+ }
+ }
+
private static bool MatchesLegacyLinuxBirthTimeIdentity(
int expectedVersion,
string expectedValue,
diff --git a/listenarr.infrastructure/FileSystem/FileMover.CompatibilityRegistration.cs b/listenarr.infrastructure/FileSystem/FileMover.CompatibilityRegistration.cs
index abcb9a2ab..2fb67f1f6 100644
--- a/listenarr.infrastructure/FileSystem/FileMover.CompatibilityRegistration.cs
+++ b/listenarr.infrastructure/FileSystem/FileMover.CompatibilityRegistration.cs
@@ -102,7 +102,9 @@ private async Task
plan.DestinationRootFolderId,
plan.DestinationPolicyRevision,
plan.SourceStorageContractRevision,
- plan.DestinationStorageContractRevision),
+ plan.DestinationStorageContractRevision,
+ plan.ExpectedBatchMemberCount,
+ plan.ExpectedBatchSourceManifestSha256),
cancellationToken);
if (journal.State == CompatibilityFilePublicationState.NeedsAttention)
{
diff --git a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.Rollback.cs b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.Rollback.cs
new file mode 100644
index 000000000..ee0b38e40
--- /dev/null
+++ b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.Rollback.cs
@@ -0,0 +1,235 @@
+using Listenarr.Domain.Audiobooks.Enumerations;
+
+namespace Listenarr.Infrastructure.FileSystem;
+
+public partial class FileMover
+{
+ public async Task
+ RollbackUncommittedRegistrationAsync(Guid operationId)
+ {
+ if (operationId == Guid.Empty)
+ {
+ throw new ArgumentException(
+ "A registration rollback requires a non-empty operation ID.",
+ nameof(operationId));
+ }
+ if (_fileMutationJournalStore == null)
+ {
+ return UncommittedPublicationRollbackOutcome.Pending;
+ }
+
+ var cancellationToken = CancellationToken.None;
+ var journal = await _fileMutationJournalStore.GetAsync(
+ operationId,
+ cancellationToken);
+ if (journal == null)
+ {
+ return UncommittedPublicationRollbackOutcome.Pending;
+ }
+ if (FileMutationJournalLifecycle.IsRegistrationPublicationTerminal(
+ journal.State))
+ {
+ return UncommittedPublicationRollbackOutcome.AlreadyTerminal;
+ }
+ if (journal.AudiobookId.HasValue
+ || journal.AudiobookFileId.HasValue
+ || journal.Action is not (
+ FileAction.Move or FileAction.Copy or FileAction.HardlinkCopy)
+ || journal.State is not (
+ FileMutationJournalState.Planned
+ or FileMutationJournalState.TargetIdentityPersisted
+ or FileMutationJournalState.TargetVerified
+ or FileMutationJournalState.RollbackAuthorized))
+ {
+ return journal.AudiobookId.HasValue
+ ? UncommittedPublicationRollbackOutcome.OwnershipCommitted
+ : UncommittedPublicationRollbackOutcome.Pending;
+ }
+
+ using var gate = await TryAcquireFileMoveGateAsync(
+ journal.SourcePath,
+ journal.DestinationPath,
+ allowExistingAliasForRecovery: true);
+ if (gate == null)
+ {
+ return UncommittedPublicationRollbackOutcome.Pending;
+ }
+ if (!await JournalPathsMatchGateAsync(journal, gate))
+ {
+ await MarkMarkerlessRegistrationNeedsAttentionAsync(
+ journal,
+ "The uncommitted registration paths no longer match their durable journal.",
+ cancellationToken);
+ return UncommittedPublicationRollbackOutcome.NeedsAttention;
+ }
+ if (!JournalParentGenerationsMatchGate(journal, gate))
+ {
+ await MarkMarkerlessRegistrationNeedsAttentionAsync(
+ journal,
+ "An uncommitted registration parent directory changed physical generation.",
+ cancellationToken);
+ return UncommittedPublicationRollbackOutcome.NeedsAttention;
+ }
+
+ var sourceOutcome = gate.SourceParent.TryOpenExistingFileWithOutcome(
+ gate.SourceName,
+ requireDeleteAccess: false,
+ out var sourceEntry);
+ using (sourceEntry)
+ {
+ if (sourceOutcome == PinnedFileOpenOutcome.Unavailable)
+ {
+ return UncommittedPublicationRollbackOutcome.Pending;
+ }
+
+ var targetOutcome =
+ gate.DestinationParent.TryOpenExistingFileForStableDeleteWithOutcome(
+ gate.DestinationName,
+ out var targetEntry);
+ using (targetEntry)
+ {
+ if (targetOutcome == PinnedFileOpenOutcome.Unavailable)
+ {
+ return UncommittedPublicationRollbackOutcome.Pending;
+ }
+ if (sourceOutcome != PinnedFileOpenOutcome.Opened
+ || !await MatchesMarkerlessSourceProofAsync(
+ sourceEntry!,
+ journal,
+ cancellationToken))
+ {
+ await MarkMarkerlessRegistrationNeedsAttentionAsync(
+ journal,
+ "The uncommitted registration source is missing or no longer matches its durable proof; the target was preserved.",
+ cancellationToken);
+ return UncommittedPublicationRollbackOutcome.NeedsAttention;
+ }
+
+ if (targetOutcome == PinnedFileOpenOutcome.NotFound)
+ {
+ try
+ {
+ await _fileMutationJournalStore.AdvanceAsync(
+ journal.OperationId,
+ FileMutationJournalState.RolledBack,
+ journal.TargetPhysicalObjectIdentity,
+ audiobookId: null,
+ error: "The uncommitted registration target was already absent; the exact source remains intact.",
+ cancellationToken);
+ }
+ catch (InvalidOperationException)
+ {
+ var current = await _fileMutationJournalStore.GetAsync(
+ journal.OperationId,
+ cancellationToken);
+ if (current?.AudiobookId.HasValue == true)
+ {
+ return UncommittedPublicationRollbackOutcome.OwnershipCommitted;
+ }
+ if (current != null
+ && FileMutationJournalLifecycle
+ .ClearsRegistrationRecoveryBoundary(current.State))
+ {
+ return UncommittedPublicationRollbackOutcome.AlreadyTerminal;
+ }
+ if (current?.State == FileMutationJournalState.NeedsAttention)
+ {
+ return UncommittedPublicationRollbackOutcome.NeedsAttention;
+ }
+ throw;
+ }
+ return UncommittedPublicationRollbackOutcome.RolledBack;
+ }
+ if (journal.State == FileMutationJournalState.Planned)
+ {
+ await MarkMarkerlessRegistrationNeedsAttentionAsync(
+ journal,
+ "A target exists but this planned registration never persisted authority over its generation; it was preserved.",
+ cancellationToken);
+ return UncommittedPublicationRollbackOutcome.NeedsAttention;
+ }
+ if (!TargetMatchesMarkerlessJournal(targetEntry!, journal)
+ || (journal.State == FileMutationJournalState.TargetVerified
+ && !await MatchesMarkerlessTargetContentAsync(
+ targetEntry!,
+ journal,
+ cancellationToken)))
+ {
+ await MarkMarkerlessRegistrationNeedsAttentionAsync(
+ journal,
+ "The uncommitted registration target changed generation or content; it was preserved.",
+ cancellationToken);
+ return UncommittedPublicationRollbackOutcome.NeedsAttention;
+ }
+
+ if (journal.State is FileMutationJournalState.TargetIdentityPersisted
+ or FileMutationJournalState.TargetVerified)
+ {
+ try
+ {
+ journal = await _fileMutationJournalStore.AdvanceAsync(
+ journal.OperationId,
+ FileMutationJournalState.RollbackAuthorized,
+ journal.TargetPhysicalObjectIdentity,
+ audiobookId: null,
+ error: "Startup recovery proved an exact source and unowned target and authorized compensation.",
+ cancellationToken);
+ }
+ catch (InvalidOperationException)
+ {
+ var current = await _fileMutationJournalStore.GetAsync(
+ journal.OperationId,
+ cancellationToken);
+ if (current?.AudiobookId.HasValue == true)
+ {
+ return UncommittedPublicationRollbackOutcome.OwnershipCommitted;
+ }
+ if (current != null
+ && FileMutationJournalLifecycle
+ .ClearsRegistrationRecoveryBoundary(current.State))
+ {
+ return UncommittedPublicationRollbackOutcome.AlreadyTerminal;
+ }
+ if (current?.State == FileMutationJournalState.NeedsAttention)
+ {
+ return UncommittedPublicationRollbackOutcome.NeedsAttention;
+ }
+ throw;
+ }
+ }
+ if (journal.State != FileMutationJournalState.RollbackAuthorized
+ || journal.AudiobookId.HasValue)
+ {
+ return journal.AudiobookId.HasValue
+ ? UncommittedPublicationRollbackOutcome.OwnershipCommitted
+ : UncommittedPublicationRollbackOutcome.Pending;
+ }
+
+ targetEntry!.Delete(immediateWindows: true);
+ gate.DestinationParent.FlushDirectoryEntry();
+ var sourceVisibility = sourceEntry!.ProbeVisiblePathMatch();
+ if (sourceVisibility == RegistrationPublicationMatchOutcome.Unavailable)
+ {
+ return UncommittedPublicationRollbackOutcome.Pending;
+ }
+ if (sourceVisibility == RegistrationPublicationMatchOutcome.Mismatch)
+ {
+ await MarkMarkerlessRegistrationNeedsAttentionAsync(
+ journal,
+ "The registration source changed after exact target compensation.",
+ cancellationToken);
+ return UncommittedPublicationRollbackOutcome.NeedsAttention;
+ }
+
+ await _fileMutationJournalStore.AdvanceAsync(
+ journal.OperationId,
+ FileMutationJournalState.RolledBack,
+ journal.TargetPhysicalObjectIdentity,
+ audiobookId: null,
+ error: "Startup recovery removed the exact unowned publication target and retained its source.",
+ cancellationToken);
+ return UncommittedPublicationRollbackOutcome.RolledBack;
+ }
+ }
+ }
+}
diff --git a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.cs b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.cs
index 4fbb1e025..5ca7d7714 100644
--- a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.cs
+++ b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.cs
@@ -151,7 +151,9 @@ await MarkMarkerlessRegistrationNeedsAttentionAsync(
}
}
- if (journal.State == FileMutationJournalState.NeedsAttention)
+ if (journal.State == FileMutationJournalState.NeedsAttention
+ || journal.State == FileMutationJournalState.RollbackAuthorized
+ || journal.State == FileMutationJournalState.RolledBack)
{
return new MarkerlessRegistrationPreparation(true, null);
}
@@ -199,7 +201,8 @@ await MarkMarkerlessRegistrationNeedsAttentionAsync(
return new MarkerlessRegistrationPreparation(true, null);
}
}
- else if (journal.State >= FileMutationJournalState.TargetVerified)
+ else if (FileMutationJournalLifecycle.IsRegistrationPublicationRecoverable(
+ journal.State))
{
if (!await MarkerlessRegistrationTargetMatchesAsync(
gate,
@@ -273,7 +276,9 @@ private bool CommitMarkerlessRegistration(
throw new InvalidOperationException(
"The markerless registration identity changed before commit.");
}
- if (journal.State == FileMutationJournalState.NeedsAttention)
+ if (journal.State == FileMutationJournalState.NeedsAttention
+ || journal.State == FileMutationJournalState.RollbackAuthorized
+ || journal.State == FileMutationJournalState.RolledBack)
{
throw new InvalidOperationException(
"A markerless registration requiring attention cannot be committed.");
@@ -291,7 +296,11 @@ private bool CommitMarkerlessRegistration(
"The registration destination changed before its journal commit.");
return false;
}
- if (journal.State < FileMutationJournalState.TargetVerified)
+ if (journal.State != FileMutationJournalState.TargetVerified
+ && journal.State != FileMutationJournalState.RegistrationCommitted
+ && journal.State != FileMutationJournalState.SourceDeletionAuthorized
+ && journal.State != FileMutationJournalState.SourceDeleted
+ && journal.State != FileMutationJournalState.Completed)
{
throw new InvalidOperationException(
"The markerless registration destination is not verified.");
@@ -307,7 +316,7 @@ RegistrationPublicationMatchOutcome ValidateCommitPublication()
: validation;
}
- if (journal.State < FileMutationJournalState.RegistrationCommitted)
+ if (journal.State == FileMutationJournalState.TargetVerified)
{
var commitValidation =
_fileMutationJournalStore.AdvanceWithCommitValidation(
@@ -362,7 +371,7 @@ RegistrationPublicationMatchOutcome ValidateCommitPublication()
}
if (action != FileAction.Move
- && journal.State < FileMutationJournalState.Completed)
+ && journal.State != FileMutationJournalState.Completed)
{
var completionValidation =
_fileMutationJournalStore.AdvanceWithCommitValidation(
@@ -387,10 +396,9 @@ RegistrationPublicationMatchOutcome ValidateCommitPublication()
"The markerless registration journal disappeared after publication completion.");
}
- return journal.State != FileMutationJournalState.NeedsAttention
- && (action == FileAction.Move
- ? journal.State >= FileMutationJournalState.RegistrationCommitted
- : journal.State >= FileMutationJournalState.Completed);
+ return action == FileAction.Move
+ ? FileMutationJournalLifecycle.MayRetireSource(journal.State)
+ : journal.State == FileMutationJournalState.Completed;
}
diff --git a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistrationMove.cs b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistrationMove.cs
index ee916e1ca..87055f4b6 100644
--- a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistrationMove.cs
+++ b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistrationMove.cs
@@ -40,11 +40,13 @@ public partial class FileMover
throw new InvalidOperationException(
"The markerless registration move identity does not match the requested completion.");
}
- if (journal.State == FileMutationJournalState.NeedsAttention)
+ if (journal.State == FileMutationJournalState.NeedsAttention
+ || journal.State == FileMutationJournalState.RollbackAuthorized
+ || journal.State == FileMutationJournalState.RolledBack)
{
return false;
}
- if (journal.State < FileMutationJournalState.RegistrationCommitted
+ if (!FileMutationJournalLifecycle.MayRetireSource(journal.State)
|| !journal.AudiobookId.HasValue)
{
_logger.LogWarning(
@@ -127,7 +129,8 @@ await MarkMarkerlessRegistrationNeedsAttentionAsync(
return false;
}
- if (journal.State >= FileMutationJournalState.SourceDeleted)
+ if (journal.State is FileMutationJournalState.SourceDeleted
+ or FileMutationJournalState.Completed)
{
var sourceOpenOutcome = gate.SourceParent.TryOpenExistingFileWithOutcome(
gate.SourceName,
@@ -150,7 +153,7 @@ await MarkMarkerlessRegistrationNeedsAttentionAsync(
}
}
- if (journal.State < FileMutationJournalState.SourceDeletionAuthorized)
+ if (journal.State == FileMutationJournalState.RegistrationCommitted)
{
journal = await _fileMutationJournalStore.AdvanceAsync(
journal.OperationId,
diff --git a/listenarr.infrastructure/FileSystem/FileMutationJournalStore.AdvanceState.cs b/listenarr.infrastructure/FileSystem/FileMutationJournalStore.AdvanceState.cs
index 133cac239..32c718bab 100644
--- a/listenarr.infrastructure/FileSystem/FileMutationJournalStore.AdvanceState.cs
+++ b/listenarr.infrastructure/FileSystem/FileMutationJournalStore.AdvanceState.cs
@@ -153,7 +153,8 @@ private static void ValidateAdvanceRequest(
"Owner metadata reconciliation must be committed atomically with the owning audiobook metadata, not through the filesystem journal store.");
}
if (state >= FileMutationJournalState.TargetIdentityPersisted
- && state != FileMutationJournalState.NeedsAttention
+ && state is not (FileMutationJournalState.NeedsAttention
+ or FileMutationJournalState.RolledBack)
&& string.IsNullOrWhiteSpace(targetPhysicalObjectIdentity))
{
throw new ArgumentException(
@@ -183,6 +184,44 @@ private void ApplyAdvance(
throw new InvalidOperationException(
"A file mutation whose owner metadata is reconciled is terminal and cannot be advanced.");
}
+ if (journal.State == FileMutationJournalState.RolledBack
+ && state != FileMutationJournalState.RolledBack)
+ {
+ throw new InvalidOperationException(
+ "A rolled-back file-registration publication cannot be advanced.");
+ }
+ if (journal.State == FileMutationJournalState.Completed
+ && state is not (FileMutationJournalState.Completed
+ or FileMutationJournalState.NeedsAttention))
+ {
+ throw new InvalidOperationException(
+ "A completed file-registration publication cannot resume filesystem mutation.");
+ }
+ if (state == FileMutationJournalState.RollbackAuthorized
+ && (journal.State is not (
+ FileMutationJournalState.TargetIdentityPersisted
+ or FileMutationJournalState.TargetVerified)
+ || journal.AudiobookId.HasValue
+ || journal.AudiobookFileId.HasValue
+ || audiobookId.HasValue))
+ {
+ throw new InvalidOperationException(
+ "Only an anonymous verified registration publication can authorize rollback.");
+ }
+ if (state == FileMutationJournalState.RolledBack
+ && (journal.State is not (
+ FileMutationJournalState.Planned
+ or FileMutationJournalState.TargetIdentityPersisted
+ or FileMutationJournalState.TargetVerified
+ or FileMutationJournalState.RollbackAuthorized
+ or FileMutationJournalState.RolledBack)
+ || journal.AudiobookId.HasValue
+ || journal.AudiobookFileId.HasValue
+ || audiobookId.HasValue))
+ {
+ throw new InvalidOperationException(
+ "Only an anonymous uncommitted registration publication can be rolled back.");
+ }
if (journal.State == FileMutationJournalState.NeedsAttention
&& state != FileMutationJournalState.NeedsAttention)
{
diff --git a/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs b/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs
index 0adbf044d..b2eeb9875 100644
--- a/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs
+++ b/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs
@@ -98,6 +98,38 @@ internal static PinnedAudiobookFileRegistrationLease Open(
commitRegistration);
}
+ internal static PinnedAudiobookFileRegistrationLease OpenForMetadataRead(
+ string publicPath,
+ string? expectedPhysicalObjectIdentity)
+ {
+ if (OperatingSystem.IsLinux()
+ && PhysicalObjectIdentitySafety.IsKnownWeak(expectedPhysicalObjectIdentity))
+ {
+ return OpenPinnedPathOnly(publicPath);
+ }
+
+ try
+ {
+ return Open(publicPath, expectedPhysicalObjectIdentity);
+ }
+ catch (PlatformNotSupportedException) when (OperatingSystem.IsLinux())
+ {
+ return OpenPinnedPathOnly(publicPath);
+ }
+ }
+
+ private static PinnedAudiobookFileRegistrationLease OpenPinnedPathOnly(
+ string publicPath)
+ {
+ var canonicalPath = Path.GetFullPath(publicPath);
+ var parentPath = Path.GetDirectoryName(canonicalPath)
+ ?? throw new InvalidOperationException("The metadata path has no parent directory.");
+ using var parent = PinnedDirectoryCreation.OpenPinnedHierarchyNoFollow(
+ parentPath, createMissing: false);
+ var file = parent.OpenExistingFileForStableRead(Path.GetFileName(canonicalPath));
+ return CreatePinnedPathOnly(file, canonicalPath);
+ }
+
internal static PinnedAudiobookFileRegistrationLease Create(
PinnedDirectoryCreation.PinnedFileEntry file,
string publicPath,
diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.Hierarchy.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.Hierarchy.cs
index a91815c8e..226f105d1 100644
--- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.Hierarchy.cs
+++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.Hierarchy.cs
@@ -155,6 +155,14 @@ internal IReadOnlyList GetDirectoryObjectIdentityCandidates()
: [PinnedDirectoryCreation.GetDirectoryObjectIdentity(_handle)];
}
+ internal IReadOnlyList GetLegacyWeakDirectoryObjectIdentityCandidates()
+ {
+ ThrowIfDisposed();
+ return OperatingSystem.IsLinux()
+ ? PinnedDirectoryCreation.GetLinuxLegacyWeakObjectIdentityCandidates(_handle)
+ : Array.Empty();
+ }
+
internal bool MatchesManagedDirectoryIdentity(
int? expectedVersion,
string? expectedValue)
diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxIdentityCandidates.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxIdentityCandidates.cs
index 1682a8338..e9215d165 100644
--- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxIdentityCandidates.cs
+++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxIdentityCandidates.cs
@@ -8,6 +8,34 @@ internal sealed partial class PinnedDirectoryCreation
{
private static IReadOnlyList GetLinuxObjectIdentityCandidates(
SafeFileHandle handle)
+ {
+ var evidence = ReadLinuxObjectIdentityEvidence(handle);
+ return CreateLinuxObjectIdentityCandidatesFromEvidence(
+ evidence.DeviceMajor,
+ evidence.DeviceMinor,
+ evidence.Inode,
+ evidence.HasBirthTime,
+ evidence.BirthTimeSeconds,
+ evidence.BirthTimeNanoseconds,
+ evidence.GenerationIdentities);
+ }
+
+ private static IReadOnlyList GetLinuxLegacyWeakObjectIdentityCandidates(
+ SafeFileHandle handle)
+ {
+ var evidence = ReadLinuxObjectIdentityEvidence(handle);
+ return CreateLinuxLegacyWeakObjectIdentityCandidatesFromEvidence(
+ evidence.DeviceMajor,
+ evidence.DeviceMinor,
+ evidence.Inode,
+ evidence.HasBirthTime,
+ evidence.BirthTimeSeconds,
+ evidence.BirthTimeNanoseconds,
+ evidence.GenerationIdentities);
+ }
+
+ private static LinuxObjectIdentityEvidence ReadLinuxObjectIdentityEvidence(
+ SafeFileHandle handle)
{
const uint statxInode = 0x00000100;
const uint statxBirthTime = 0x00000800;
@@ -27,17 +55,25 @@ private static IReadOnlyList GetLinuxObjectIdentityCandidates(
"The filesystem does not expose an inode for durable object identity.");
}
- var generationIdentities = GetLinuxGenerationIdentityCandidates(handle);
- return CreateLinuxObjectIdentityCandidatesFromEvidence(
+ return new LinuxObjectIdentityEvidence(
information.DeviceMajor,
information.DeviceMinor,
information.Inode,
- hasBirthTime: (information.Mask & statxBirthTime) != 0,
+ (information.Mask & statxBirthTime) != 0,
information.BirthTime.Seconds,
information.BirthTime.Nanoseconds,
- generationIdentities);
+ GetLinuxGenerationIdentityCandidates(handle));
}
+ private sealed record LinuxObjectIdentityEvidence(
+ uint DeviceMajor,
+ uint DeviceMinor,
+ ulong Inode,
+ bool HasBirthTime,
+ long BirthTimeSeconds,
+ uint BirthTimeNanoseconds,
+ IReadOnlyList GenerationIdentities);
+
internal static string CreateLinuxObjectIdentityFromEvidence(
uint deviceMajor,
uint deviceMinor,
@@ -68,7 +104,7 @@ internal static IReadOnlyList CreateLinuxObjectIdentityCandidatesFromEvi
{
ArgumentNullException.ThrowIfNull(generationIdentities);
var strongGenerations = generationIdentities
- .Where(candidate => !string.IsNullOrWhiteSpace(candidate))
+ .Where(IsDurableLinuxGenerationIdentity)
.Distinct(StringComparer.Ordinal)
.ToArray();
if (strongGenerations.Length == 0)
@@ -114,6 +150,11 @@ internal static bool ArePersistedObjectIdentitiesDurablyEquivalent(
{
ArgumentException.ThrowIfNullOrWhiteSpace(left);
ArgumentException.ThrowIfNullOrWhiteSpace(right);
+ if (PhysicalObjectIdentitySafety.IsKnownWeak(left)
+ || PhysicalObjectIdentitySafety.IsKnownWeak(right))
+ {
+ return false;
+ }
if (string.Equals(left, right, StringComparison.Ordinal))
{
return true;
@@ -203,6 +244,75 @@ private static bool TryGetLinuxStrongGenerationKey(
return false;
}
+ internal static IReadOnlyList CreateLinuxLegacyWeakObjectIdentityCandidatesFromEvidence(
+ uint deviceMajor,
+ uint deviceMinor,
+ ulong inode,
+ bool hasBirthTime,
+ long birthTimeSeconds,
+ uint birthTimeNanoseconds,
+ IReadOnlyList generationIdentities)
+ {
+ ArgumentNullException.ThrowIfNull(generationIdentities);
+ var weakGenerations = generationIdentities
+ .Where(IsLegacyWeakLinuxGenerationIdentity)
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+ if (weakGenerations.Length == 0)
+ {
+ return Array.Empty();
+ }
+
+ var candidates = new List(weakGenerations.Length * 2);
+ foreach (var generationIdentity in weakGenerations)
+ {
+ candidates.Add(FormattableString.Invariant(
+ $"linux-generation:{deviceMajor:x8}:{deviceMinor:x8}:{inode:x16}:{generationIdentity}"));
+ }
+
+ if (hasBirthTime)
+ {
+ var legacyIdentity = FormattableString.Invariant(
+ $"linux:{deviceMajor:x8}:{deviceMinor:x8}:{inode:x16}:{birthTimeSeconds:x16}:{birthTimeNanoseconds:x8}");
+ foreach (var generationIdentity in weakGenerations)
+ {
+ candidates.Add($"{legacyIdentity}:{generationIdentity}");
+ }
+ }
+
+ return candidates.ToArray();
+ }
+
+ private static bool IsDurableLinuxGenerationIdentity(string candidate)
+ {
+ if (string.IsNullOrWhiteSpace(candidate))
+ {
+ return false;
+ }
+
+ var parts = candidate.Split(':');
+ return parts[0] switch
+ {
+ "gen" => parts.Length == 2 && IsFixedHex(parts[1], 8),
+ "fh" => TryValidateLinuxFileHandle(parts, 0, requireDurable: true),
+ _ => false
+ };
+ }
+
+ private static bool IsLegacyWeakLinuxGenerationIdentity(string candidate)
+ {
+ if (string.IsNullOrWhiteSpace(candidate))
+ {
+ return false;
+ }
+
+ var parts = candidate.Split(':');
+ return parts.Length == 3
+ && string.Equals(parts[0], "fh", StringComparison.Ordinal)
+ && TryValidateLinuxFileHandle(parts, 0, requireDurable: false)
+ && string.Equals(parts[1], "00000081", StringComparison.OrdinalIgnoreCase);
+ }
+
private static bool TryValidateLinuxGenerationSuffix(
string[] parts,
int suffixIndex) =>
@@ -210,14 +320,32 @@ private static bool TryValidateLinuxGenerationSuffix(
{
"gen" => parts.Length == suffixIndex + 2
&& IsFixedHex(parts[suffixIndex + 1], 8),
- "fh" => parts.Length == suffixIndex + 3
- && IsFixedHex(parts[suffixIndex + 1], 8)
- && parts[suffixIndex + 2].Length > 0
- && parts[suffixIndex + 2].Length % 2 == 0
- && parts[suffixIndex + 2].All(Uri.IsHexDigit),
+ "fh" => TryValidateLinuxFileHandle(parts, suffixIndex, requireDurable: true),
_ => false
};
+ private static bool TryValidateLinuxFileHandle(
+ string[] parts,
+ int prefixIndex,
+ bool requireDurable)
+ {
+ if (parts.Length != prefixIndex + 3
+ || !string.Equals(parts[prefixIndex], "fh", StringComparison.Ordinal)
+ || !IsFixedHex(parts[prefixIndex + 1], 8)
+ || parts[prefixIndex + 2].Length == 0
+ || parts[prefixIndex + 2].Length % 2 != 0
+ || !parts[prefixIndex + 2].All(Uri.IsHexDigit))
+ {
+ return false;
+ }
+
+ return !requireDurable
+ || !string.Equals(
+ parts[prefixIndex + 1],
+ "00000081",
+ StringComparison.OrdinalIgnoreCase);
+ }
+
private static bool IsFixedHex(string value, int length) =>
value.Length == length && value.All(Uri.IsHexDigit);
}
diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxObjectIdentity.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxObjectIdentity.cs
index 2d1a5aa07..076b491f9 100644
--- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxObjectIdentity.cs
+++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxObjectIdentity.cs
@@ -18,20 +18,37 @@ internal sealed partial class PinnedDirectoryCreation
private const int LinuxFileHandleHeaderBytes = 8;
private const int LinuxInitialFileHandleBytes = 128;
private const int LinuxMaximumFileHandleBytes = 4096;
+ private const int LinuxGenericFileIdentifierType = 0x81;
private const ulong LinuxFsIocGetVersion64 = 0x80087601;
private const ulong LinuxFsIocGetVersion32 = 0x80047601;
private static IReadOnlyList GetLinuxGenerationIdentityCandidates(
SafeFileHandle handle)
{
- var candidates = new List(2);
- var fileHandle = TryGetLinuxFileHandleIdentity(
+ var candidates = new List(3);
+ var fileHandleEvidence = TryGetLinuxFileHandleEvidence(
handle,
LinuxAtEmptyPath | LinuxAtHandleFid,
retryWithoutHandleFid: true);
- if (!string.IsNullOrWhiteSpace(fileHandle))
+ if (fileHandleEvidence != null)
{
- candidates.Add($"fh:{fileHandle}");
+ candidates.Add(fileHandleEvidence.ToGenerationIdentity());
+ if (!fileHandleEvidence.IsDurableGenerationEvidence)
+ {
+ // AT_HANDLE_FID may deliberately return the generic exportfs
+ // FILEID_INO64_GEN representation even when the filesystem can
+ // provide a stronger ordinary handle. Probe without FID before
+ // concluding that only weak evidence is available.
+ var ordinaryFileHandleEvidence = TryGetLinuxFileHandleEvidence(
+ handle,
+ LinuxAtEmptyPath,
+ retryWithoutHandleFid: false);
+ if (ordinaryFileHandleEvidence != null
+ && !ordinaryFileHandleEvidence.HasSameHandle(fileHandleEvidence))
+ {
+ candidates.Add(ordinaryFileHandleEvidence.ToGenerationIdentity());
+ }
+ }
}
try
@@ -41,18 +58,18 @@ private static IReadOnlyList GetLinuxGenerationIdentityCandidates(
candidates.Add(FormattableString.Invariant($"gen:{generation:x8}"));
}
}
- catch (Win32Exception) when (candidates.Count > 0)
+ catch (Win32Exception) when (candidates.Any(IsDurableLinuxGenerationIdentity))
{
// A second, supplementary capability failing unexpectedly must not
// invalidate a strong identity already obtained from this pinned
- // object. Persisted identities that require the failed scheme will
- // still fail closed because their candidate will be absent.
+ // object. A generic FILEID_INO64_GEN FID is deliberately excluded
+ // here because it is compatibility evidence, not durable authority.
}
return candidates;
}
- private static string? TryGetLinuxFileHandleIdentity(
+ private static LinuxFileHandleEvidence? TryGetLinuxFileHandleEvidence(
SafeFileHandle handle,
int flags,
bool retryWithoutHandleFid)
@@ -87,8 +104,12 @@ private static IReadOnlyList GetLinuxGenerationIdentityCandidates(
bytes,
0,
handleBytes);
- return FormattableString.Invariant(
- $"{handleType:x8}:{Convert.ToHexString(bytes).ToLowerInvariant()}");
+ return new LinuxFileHandleEvidence(
+ handleType,
+ bytes,
+ (flags & LinuxAtHandleFid) != 0
+ ? LinuxFileHandleProbeKind.FileIdentifier
+ : LinuxFileHandleProbeKind.FileHandle);
}
var error = Marshal.GetLastWin32Error();
@@ -102,10 +123,11 @@ private static IReadOnlyList GetLinuxGenerationIdentityCandidates(
}
if (retryWithoutHandleFid
- && error == LinuxInvalidArgument
- && (flags & LinuxAtHandleFid) != 0)
+ && (flags & LinuxAtHandleFid) != 0
+ && (IsUnavailableLinuxGenerationProbeError(error)
+ || error == LinuxOverflow))
{
- return TryGetLinuxFileHandleIdentity(
+ return TryGetLinuxFileHandleEvidence(
handle,
LinuxAtEmptyPath,
retryWithoutHandleFid: false);
@@ -128,6 +150,29 @@ private static IReadOnlyList GetLinuxGenerationIdentityCandidates(
return null;
}
+ private enum LinuxFileHandleProbeKind
+ {
+ FileHandle,
+ FileIdentifier
+ }
+
+ private sealed record LinuxFileHandleEvidence(
+ int HandleType,
+ byte[] Bytes,
+ LinuxFileHandleProbeKind ProbeKind)
+ {
+ internal bool IsDurableGenerationEvidence =>
+ HandleType != LinuxGenericFileIdentifierType;
+
+ internal string ToGenerationIdentity() =>
+ FormattableString.Invariant(
+ $"fh:{HandleType:x8}:{Convert.ToHexString(Bytes).ToLowerInvariant()}");
+
+ internal bool HasSameHandle(LinuxFileHandleEvidence other) =>
+ HandleType == other.HandleType
+ && Bytes.AsSpan().SequenceEqual(other.Bytes);
+ }
+
private static bool TryGetLinuxInodeGeneration(
SafeFileHandle handle,
out uint generation)
diff --git a/listenarr.infrastructure/FileSystem/RootFolderStorageConfirmationService.cs b/listenarr.infrastructure/FileSystem/RootFolderStorageConfirmationService.cs
index 121aa5bbf..c4dd5661b 100644
--- a/listenarr.infrastructure/FileSystem/RootFolderStorageConfirmationService.cs
+++ b/listenarr.infrastructure/FileSystem/RootFolderStorageConfirmationService.cs
@@ -9,7 +9,8 @@ internal sealed class RootFolderStorageConfirmationService(
IFileSystemSemanticsResolver semanticsResolver,
IMoveQueueService moveQueueService,
IFilesystemMutationCoordinator mutationCoordinator,
- IAudiobookOperationCoordinator audiobookOperationCoordinator)
+ IAudiobookOperationCoordinator audiobookOperationCoordinator,
+ IFileRegistrationRecoveryProbe fileRegistrationRecoveryProbe)
: IRootFolderStorageConfirmationService
{
internal Action? BeforeCommitForTest { get; set; }
@@ -247,26 +248,42 @@ await MarkPostCommitConfirmationUnstableAsync(
}
}
- private static async Task EnsureNoExternalRecoveryOwnerTouchesRootAsync(
+ private async Task EnsureNoExternalRecoveryOwnerTouchesRootAsync(
ListenArrDbContext db,
int rootFolderId,
string canonicalRootPath,
FileSystemPathSemantics semantics,
CancellationToken cancellationToken)
{
- var audiobooks = await db.Audiobooks
+ var audiobookPaths = await db.Audiobooks
.AsNoTracking()
- .AsSplitQuery()
- .Include(audiobook => audiobook.Files)
+ .Select(audiobook => new
+ {
+ audiobook.Id,
+ audiobook.BasePath,
+ audiobook.FilePath
+ })
.ToListAsync(cancellationToken);
- var audiobookIds = audiobooks
+ var audiobookIds = audiobookPaths
.Where(audiobook =>
PathTouchesConfirmedRoot(audiobook.BasePath, canonicalRootPath, semantics)
- || PathTouchesConfirmedRoot(audiobook.FilePath, canonicalRootPath, semantics)
- || (audiobook.Files?.Any(file =>
- PathTouchesConfirmedRoot(file.Path, canonicalRootPath, semantics)) ?? false))
+ || PathTouchesConfirmedRoot(audiobook.FilePath, canonicalRootPath, semantics))
.Select(audiobook => audiobook.Id)
.ToHashSet();
+ var audiobookFilePaths = await db.AudiobookFiles
+ .AsNoTracking()
+ .Select(file => new
+ {
+ file.AudiobookId,
+ file.Path
+ })
+ .ToListAsync(cancellationToken);
+ audiobookIds.UnionWith(audiobookFilePaths
+ .Where(file => PathTouchesConfirmedRoot(
+ file.Path,
+ canonicalRootPath,
+ semantics))
+ .Select(file => file.AudiobookId));
audiobookIds.UnionWith(await db.LibraryDirectoryOwnerships
.AsNoTracking()
.Where(ownership => ownership.ManagedRootFolderId == rootFolderId
@@ -274,18 +291,26 @@ private static async Task EnsureNoExternalRecoveryOwnerTouchesRootAsync(
&& ownership.State != LibraryDirectoryOwnershipState.Removed)
.Select(ownership => ownership.AudiobookId!.Value)
.ToListAsync(cancellationToken));
+ var registrationBlocker = (await fileRegistrationRecoveryProbe
+ .GetBlockingBoundaryAsync(
+ canonicalRootPath,
+ semantics,
+ cancellationToken))
+ .FirstOrDefault();
+ if (registrationBlocker != null)
+ {
+ throw new RootFolderRecoveryBlockedException(registrationBlocker);
+ }
var activeMutationJournals = await db.FileMutationJournals
.AsNoTracking()
.Where(journal =>
- (journal.AudiobookFileId == null
- && journal.State != FileMutationJournalState.Completed)
- || (journal.AudiobookId != null
+ journal.AudiobookId != null
&& journal.AudiobookFileId != null
&& (journal.AudiobookFileId == FileMutationOwner.CompanionFile
|| journal.AudiobookFileId
== FileMutationOwner.RegistrationCompanionFile
? journal.State != FileMutationJournalState.Completed
- : journal.State != FileMutationJournalState.OwnerMetadataReconciled)))
+ : journal.State != FileMutationJournalState.OwnerMetadataReconciled))
.ToListAsync(cancellationToken);
if (activeMutationJournals.Any(journal =>
(journal.AudiobookId.HasValue
diff --git a/listenarr.infrastructure/FileSystem/RootFolderStorageHealthResolver.cs b/listenarr.infrastructure/FileSystem/RootFolderStorageHealthResolver.cs
index c73361da5..c8f042f7e 100644
--- a/listenarr.infrastructure/FileSystem/RootFolderStorageHealthResolver.cs
+++ b/listenarr.infrastructure/FileSystem/RootFolderStorageHealthResolver.cs
@@ -128,6 +128,18 @@ public async Task ResolveAsync(
cancellationToken);
if (!legacyCurrentGeneration.IsAvailable)
{
+ if (legacyCurrentGeneration.FailureKind
+ == DirectoryObjectIdentityFailureKind.IdentityUnsupported)
+ {
+ return await ValidateFilesystemSemanticsAsync(
+ root,
+ canonicalPath,
+ LimitedIdentityUnsupported(
+ legacyCurrentGeneration.UnavailableReason
+ ?? expected.UnavailableReason),
+ cancellationToken);
+ }
+
return FromFailure(legacyCurrentGeneration);
}
diff --git a/listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.Helpers.cs b/listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.Helpers.cs
new file mode 100644
index 000000000..72d898a54
--- /dev/null
+++ b/listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.Helpers.cs
@@ -0,0 +1,397 @@
+using System.ComponentModel;
+using Listenarr.Domain.Common;
+using Microsoft.EntityFrameworkCore;
+
+namespace Listenarr.Infrastructure.FileSystem;
+
+public sealed partial class VerifiedFileRenameTransactionCoordinator
+{
+ internal enum RootContractValidation
+ {
+ Valid,
+ Unavailable,
+ Mismatch
+ }
+
+ private async Task PersistNewJournalAsync(
+ VerifiedFileRenameJournal journal,
+ CancellationToken cancellationToken)
+ {
+ await using var db = await dbContextFactory.CreateDbContextAsync(
+ cancellationToken);
+ if (await db.VerifiedFileRenameJournals
+ .AsNoTracking()
+ .AnyAsync(
+ candidate => candidate.OperationId == journal.OperationId,
+ cancellationToken))
+ {
+ throw new InvalidOperationException(
+ "The verified organize operation ID is already in use.");
+ }
+
+ db.VerifiedFileRenameJournals.Add(journal);
+ await db.SaveChangesAsync(cancellationToken);
+ }
+
+ internal async Task GetJournalAsync(
+ Guid operationId,
+ CancellationToken cancellationToken)
+ {
+ await using var db = await dbContextFactory.CreateDbContextAsync(
+ cancellationToken);
+ return await db.VerifiedFileRenameJournals
+ .AsNoTracking()
+ .SingleOrDefaultAsync(
+ journal => journal.OperationId == operationId,
+ cancellationToken);
+ }
+
+ internal async Task AdvanceAsync(
+ Guid operationId,
+ VerifiedFileRenameState state,
+ string? error,
+ CancellationToken cancellationToken)
+ {
+ await using var db = await dbContextFactory.CreateDbContextAsync(
+ cancellationToken);
+ var journal = await db.VerifiedFileRenameJournals
+ .SingleAsync(
+ candidate => candidate.OperationId == operationId,
+ cancellationToken);
+ if (!CanAdvance(journal.State, state))
+ {
+ throw new InvalidOperationException(
+ $"Verified organize journal {operationId} cannot advance from {journal.State} to {state}.");
+ }
+
+ journal.State = state;
+ journal.Error = error;
+ journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime;
+ await db.SaveChangesAsync(cancellationToken);
+ }
+
+ internal async Task MarkNeedsAttentionAsync(
+ Guid operationId,
+ string error,
+ CancellationToken cancellationToken)
+ {
+ await using var db = await dbContextFactory.CreateDbContextAsync(
+ cancellationToken);
+ var journal = await db.VerifiedFileRenameJournals
+ .SingleOrDefaultAsync(
+ candidate => candidate.OperationId == operationId,
+ cancellationToken);
+ if (journal == null || IsTerminal(journal.State))
+ {
+ return;
+ }
+
+ journal.State = VerifiedFileRenameState.NeedsAttention;
+ journal.Error = error;
+ journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime;
+ await db.SaveChangesAsync(cancellationToken);
+ }
+
+ internal async Task MarkSourceRetainedAsync(
+ Guid operationId,
+ string reason,
+ CancellationToken cancellationToken)
+ {
+ await AdvanceAsync(
+ operationId,
+ VerifiedFileRenameState.CompletedSourceRetained,
+ reason,
+ cancellationToken);
+ }
+
+ internal static bool IsTerminal(VerifiedFileRenameState state) =>
+ state is VerifiedFileRenameState.Completed
+ or VerifiedFileRenameState.CompletedSourceRetained
+ or VerifiedFileRenameState.RolledBack
+ or VerifiedFileRenameState.NeedsAttention;
+
+ private static bool CanAdvance(
+ VerifiedFileRenameState current,
+ VerifiedFileRenameState next)
+ {
+ if (current == next)
+ {
+ return true;
+ }
+ if (next == VerifiedFileRenameState.NeedsAttention)
+ {
+ return !IsTerminal(current);
+ }
+
+ return current switch
+ {
+ VerifiedFileRenameState.Planned => next is
+ VerifiedFileRenameState.TargetVerified or
+ VerifiedFileRenameState.RolledBack,
+ VerifiedFileRenameState.TargetVerified => next is
+ VerifiedFileRenameState.OwnerMetadataReconciled or
+ VerifiedFileRenameState.RolledBack,
+ VerifiedFileRenameState.OwnerMetadataReconciled => next is
+ VerifiedFileRenameState.SourceQuarantined or
+ VerifiedFileRenameState.CompletedSourceRetained,
+ VerifiedFileRenameState.SourceQuarantined => next is
+ VerifiedFileRenameState.SourceDeleted or
+ VerifiedFileRenameState.CompletedSourceRetained,
+ VerifiedFileRenameState.SourceDeleted => next is
+ VerifiedFileRenameState.Completed or
+ VerifiedFileRenameState.CompletedSourceRetained,
+ _ => false
+ };
+ }
+
+ private static async Task CopyAndVerifyAsync(
+ PinnedDirectoryCreation.PinnedFileEntry source,
+ PinnedDirectoryCreation.PinnedFileEntry target,
+ FilePublicationSourceProof sourceProof,
+ CancellationToken cancellationToken)
+ {
+ await using var input = source.OpenReadStream(
+ bufferSize: 128 * 1024,
+ asynchronous: false);
+ await using var output = target.OpenWriteStream(
+ bufferSize: 128 * 1024,
+ asynchronous: false);
+ await input.CopyToAsync(output, 128 * 1024, cancellationToken);
+ await output.FlushAsync(cancellationToken);
+ output.Flush(flushToDisk: true);
+ if (!await target.MatchesAsync(
+ sourceProof.Length,
+ sourceProof.Sha256,
+ cancellationToken))
+ {
+ throw new InvalidOperationException(
+ "The verified organize staging file failed SHA-256 verification.");
+ }
+ }
+
+ private async Task TryRollbackPreparedTargetAsync(
+ Guid operationId,
+ PinnedDirectoryCreation.PinnedDirectoryAnchor? destinationParent,
+ PinnedDirectoryCreation.PinnedFileEntry? targetEntry,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ if (targetEntry != null && destinationParent != null)
+ {
+ var visibility = targetEntry.ProbeVisiblePathMatch();
+ if (visibility == RegistrationPublicationMatchOutcome.Unavailable)
+ {
+ throw new IOException(
+ "The verified organize staging/target is temporarily unavailable during rollback.");
+ }
+ if (visibility == RegistrationPublicationMatchOutcome.Match)
+ {
+ targetEntry.Delete(immediateWindows: true);
+ destinationParent.FlushDirectoryEntry();
+ }
+ }
+
+ await AdvanceAsync(
+ operationId,
+ VerifiedFileRenameState.RolledBack,
+ error: null,
+ cancellationToken);
+ }
+ catch (Exception exception) when (exception is not (
+ OperationCanceledException or OutOfMemoryException
+ or StackOverflowException))
+ {
+ await MarkNeedsAttentionAsync(
+ operationId,
+ "Verified organize preparation rollback requires attention: "
+ + exception.Message,
+ CancellationToken.None);
+ }
+ }
+
+ internal async Task ValidateRootContractsAsync(
+ VerifiedFileRenameJournal journal,
+ CancellationToken cancellationToken)
+ {
+ var roots = await rootFolderRepository.GetAllAsync();
+ var sourceRoot = roots.SingleOrDefault(
+ root => root.Id == journal.SourceRootFolderId);
+ var destinationRoot = roots.SingleOrDefault(
+ root => root.Id == journal.DestinationRootFolderId);
+ if (sourceRoot == null || destinationRoot == null
+ || sourceRoot.StorageContractRevision
+ != journal.SourceStorageContractRevision
+ || destinationRoot.StorageContractRevision
+ != journal.DestinationStorageContractRevision)
+ {
+ return RootContractValidation.Mismatch;
+ }
+
+ try
+ {
+ var sourceHealth = await storageHealthResolver.ResolveAsync(
+ sourceRoot,
+ cancellationToken);
+ var destinationHealth = await storageHealthResolver.ResolveAsync(
+ destinationRoot,
+ cancellationToken);
+ if (!sourceHealth.CanRetireVerifiedSource
+ || !destinationHealth.CanPublishAdditively)
+ {
+ return sourceHealth.State is RootFolderStorageState.Missing
+ or RootFolderStorageState.Changed
+ || destinationHealth.State is RootFolderStorageState.Missing
+ or RootFolderStorageState.Changed
+ ? RootContractValidation.Mismatch
+ : RootContractValidation.Unavailable;
+ }
+ return RootContractValidation.Valid;
+ }
+ catch (Exception exception) when (exception is
+ IOException or UnauthorizedAccessException
+ or InvalidOperationException or NotSupportedException)
+ {
+ return RootContractValidation.Unavailable;
+ }
+ }
+
+ private static PinnedDirectoryCreation.PinnedDirectoryAnchor
+ OpenOrCreateVerifiedDestinationParent(
+ RootFolder destinationRoot,
+ string destinationParentPath)
+ {
+ var persisted = RootFolderPathSemantics.ResolvePersisted(destinationRoot)
+ ?? throw new InvalidOperationException(
+ "The verified organize destination root has no persisted path semantics.");
+ if (persisted.DetectAmbiguousCaseMatches
+ || !FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost(
+ destinationRoot.Path,
+ out var rootPath,
+ out _)
+ || !FileSystemPathIdentity.IsSameOrInside(
+ destinationParentPath,
+ rootPath,
+ persisted.Semantics))
+ {
+ throw new InvalidOperationException(
+ "The verified organize destination parent is outside its configured root.");
+ }
+
+ var current = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(rootPath);
+ try
+ {
+ var segments = ResolveDestinationHierarchySegments(
+ rootPath,
+ destinationParentPath,
+ persisted.Semantics);
+ if (segments.Count == 0)
+ {
+ return current;
+ }
+
+ foreach (var segment in segments)
+ {
+ PinnedDirectoryCreation.PinnedDirectoryAnchor next;
+ try
+ {
+ next = current.OpenExistingChild(segment);
+ }
+ catch (Win32Exception exception) when (
+ exception.NativeErrorCode is 2 or 3)
+ {
+ using var creation = current.TryCreateChild(segment);
+ next = creation.Created
+ ? creation.OpenCreatedDirectoryAnchor()
+ : current.OpenExistingChild(segment);
+ }
+
+ if (!next.VisiblePathMatches())
+ {
+ next.Dispose();
+ throw new InvalidOperationException(
+ "The verified organize destination hierarchy changed during additive creation.");
+ }
+
+ current.Dispose();
+ current = next;
+ }
+
+ return current;
+ }
+ catch
+ {
+ current.Dispose();
+ throw;
+ }
+ }
+
+ internal static IReadOnlyList ResolveDestinationHierarchySegments(
+ string rootPath,
+ string destinationParentPath,
+ FileSystemPathSemantics semantics)
+ {
+ if (!FileSystemPathIdentity.TryGetRelativePathWithinBase(
+ rootPath,
+ destinationParentPath,
+ semantics,
+ out var relative))
+ {
+ throw new InvalidOperationException(
+ "The verified organize destination parent could not be resolved relative to its configured root semantics.");
+ }
+ if (string.IsNullOrEmpty(relative))
+ {
+ return [];
+ }
+
+ var separators = semantics.Syntax == FileSystemPathSyntax.Windows
+ ? new[] { '\\', '/' }
+ : new[] { '/' };
+ var segments = relative.Split(
+ separators,
+ StringSplitOptions.RemoveEmptyEntries);
+ if (segments.Any(segment => segment is "." or ".."))
+ {
+ throw new InvalidOperationException(
+ "The verified organize destination hierarchy contains a traversal segment.");
+ }
+
+ return segments;
+ }
+
+ private static RootFolder? FindContainingRoot(
+ string path,
+ IReadOnlyCollection roots)
+ {
+ var fullPath = Path.GetFullPath(path);
+ RootFolder? best = null;
+ var bestLength = -1;
+ foreach (var root in roots)
+ {
+ var persisted = RootFolderPathSemantics.ResolvePersisted(root);
+ if (!persisted.HasValue
+ || persisted.Value.DetectAmbiguousCaseMatches
+ || !FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost(
+ root.Path,
+ out var rootPath,
+ out _)
+ || string.IsNullOrWhiteSpace(rootPath)
+ || !FileSystemPathIdentity.IsSameOrInside(
+ fullPath,
+ rootPath,
+ persisted.Value.Semantics))
+ {
+ continue;
+ }
+
+ if (rootPath.Length > bestLength)
+ {
+ best = root;
+ bestLength = rootPath.Length;
+ }
+ }
+
+ return best;
+ }
+}
diff --git a/listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.Lease.cs b/listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.Lease.cs
new file mode 100644
index 000000000..d032e9395
--- /dev/null
+++ b/listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.Lease.cs
@@ -0,0 +1,364 @@
+using Microsoft.Extensions.Logging;
+
+namespace Listenarr.Infrastructure.FileSystem;
+
+public sealed partial class VerifiedFileRenameTransactionCoordinator
+{
+ private sealed class VerifiedFileRenameLease(
+ VerifiedFileRenameTransactionCoordinator owner,
+ VerifiedFileRenameJournal journal,
+ PinnedDirectoryCreation.PinnedDirectoryAnchor sourceParent,
+ PinnedDirectoryCreation.PinnedDirectoryAnchor destinationParent,
+ PinnedDirectoryCreation.PinnedFileEntry sourceEntry,
+ PinnedDirectoryCreation.PinnedFileEntry targetEntry,
+ FilePublicationSourceProof sourceProof,
+ ILogger logger)
+ : IVerifiedFileRenameLease
+ {
+ private bool _disposed;
+
+ public Guid OperationId => journal.OperationId;
+
+ public async Task RollBackAsync(
+ CancellationToken cancellationToken = default)
+ {
+ ThrowIfDisposed();
+ var current = await owner.GetJournalAsync(
+ journal.OperationId,
+ cancellationToken);
+ if (current == null)
+ {
+ return false;
+ }
+ if (current.State == VerifiedFileRenameState.RolledBack)
+ {
+ return true;
+ }
+ if (current.State != VerifiedFileRenameState.TargetVerified)
+ {
+ return false;
+ }
+
+ try
+ {
+ var targetVisibility = targetEntry.ProbeVisiblePathMatch();
+ if (targetVisibility != RegistrationPublicationMatchOutcome.Match
+ || !await targetEntry.MatchesAsync(
+ sourceProof.Length,
+ sourceProof.Sha256,
+ cancellationToken))
+ {
+ throw new InvalidOperationException(
+ "The verified organize target changed before rollback.");
+ }
+ if (sourceEntry.ProbeVisiblePathMatch()
+ != RegistrationPublicationMatchOutcome.Match
+ || !await sourceEntry.MatchesAsync(
+ sourceProof.Length,
+ sourceProof.Sha256,
+ cancellationToken))
+ {
+ throw new InvalidOperationException(
+ "The verified organize source changed before rollback.");
+ }
+
+ targetEntry.Delete(immediateWindows: true);
+ destinationParent.FlushDirectoryEntry();
+ await owner.AdvanceAsync(
+ journal.OperationId,
+ VerifiedFileRenameState.RolledBack,
+ error: null,
+ CancellationToken.None);
+ return true;
+ }
+ catch (Exception exception) when (exception is not (
+ OperationCanceledException or OutOfMemoryException
+ or StackOverflowException))
+ {
+ logger.LogWarning(
+ exception,
+ "Verified organize rollback for {OperationId} requires attention",
+ journal.OperationId);
+ await owner.MarkNeedsAttentionAsync(
+ journal.OperationId,
+ "Verified organize rollback could not prove the original source and published target remained unchanged.",
+ CancellationToken.None);
+ return false;
+ }
+ }
+
+ public async Task CompleteSourceRetirementAsync(
+ CancellationToken cancellationToken = default)
+ {
+ ThrowIfDisposed();
+ var current = await owner.GetJournalAsync(
+ journal.OperationId,
+ cancellationToken);
+ if (current == null)
+ {
+ return VerifiedFileRenameRetirementOutcome.NeedsAttention;
+ }
+ if (current.State == VerifiedFileRenameState.Completed)
+ {
+ return VerifiedFileRenameRetirementOutcome.Completed;
+ }
+ if (current.State == VerifiedFileRenameState.CompletedSourceRetained)
+ {
+ return VerifiedFileRenameRetirementOutcome.SourceRetained;
+ }
+ if (current.State == VerifiedFileRenameState.NeedsAttention)
+ {
+ return VerifiedFileRenameRetirementOutcome.NeedsAttention;
+ }
+ if (current.State == VerifiedFileRenameState.SourceDeleted)
+ {
+ await owner.AdvanceAsync(
+ journal.OperationId,
+ VerifiedFileRenameState.Completed,
+ error: null,
+ CancellationToken.None);
+ return VerifiedFileRenameRetirementOutcome.Completed;
+ }
+ if (current.State is not (
+ VerifiedFileRenameState.OwnerMetadataReconciled
+ or VerifiedFileRenameState.SourceQuarantined))
+ {
+ return VerifiedFileRenameRetirementOutcome.NeedsAttention;
+ }
+
+ var originalSourceName = Path.GetFileName(journal.SourcePath);
+ var retirementName = Path.GetFileName(journal.RetirementPath);
+ try
+ {
+ if (current.State == VerifiedFileRenameState.OwnerMetadataReconciled)
+ {
+ var contracts = await owner.ValidateRootContractsAsync(
+ current,
+ cancellationToken);
+ if (contracts != RootContractValidation.Valid)
+ {
+ await owner.MarkSourceRetainedAsync(
+ journal.OperationId,
+ "Owner metadata was committed, but current storage contracts no longer authorize live source retirement. The old source was retained.",
+ CancellationToken.None);
+ return VerifiedFileRenameRetirementOutcome.SourceRetained;
+ }
+
+ if (!await TargetStillMatchesAsync(cancellationToken))
+ {
+ await owner.MarkNeedsAttentionAsync(
+ journal.OperationId,
+ "Owner metadata was committed, but the verified organize target changed before source retirement. The old source was retained and the tracked destination requires repair.",
+ CancellationToken.None);
+ return VerifiedFileRenameRetirementOutcome.NeedsAttention;
+ }
+ if (!await SourceStillMatchesAsync(cancellationToken))
+ {
+ await owner.MarkSourceRetainedAsync(
+ journal.OperationId,
+ "Owner metadata was committed, but the original pinned source changed before retirement. The source path was retained.",
+ CancellationToken.None);
+ return VerifiedFileRenameRetirementOutcome.SourceRetained;
+ }
+
+ var quarantine = sourceEntry.TryMoveToNoReplace(
+ sourceParent,
+ retirementName);
+ if (!quarantine.Published)
+ {
+ await owner.MarkSourceRetainedAsync(
+ journal.OperationId,
+ "Owner metadata was committed, but the source could not enter the operation-owned retirement namespace without replacement. The old source was retained.",
+ CancellationToken.None);
+ return VerifiedFileRenameRetirementOutcome.SourceRetained;
+ }
+ sourceParent.FlushDirectoryEntry();
+ await owner.AdvanceAsync(
+ journal.OperationId,
+ VerifiedFileRenameState.SourceQuarantined,
+ error: null,
+ CancellationToken.None);
+ owner.AfterSourceQuarantinedForTest?.Invoke();
+ }
+
+ if (!await TargetStillMatchesAsync(cancellationToken))
+ {
+ return await RestoreQuarantinedSourceAsync(
+ originalSourceName,
+ "The verified organize target changed after source quarantine. The exact pinned source was restored, but the tracked destination requires repair.",
+ requiresAttention: true);
+ }
+
+ owner.BeforeRetirementDeleteForTest?.Invoke();
+ if (!await TargetStillMatchesAsync(cancellationToken))
+ {
+ return await RestoreQuarantinedSourceAsync(
+ originalSourceName,
+ "The verified organize target changed immediately before source deletion. The exact pinned source was restored, but the tracked destination requires repair.",
+ requiresAttention: true);
+ }
+ if (!await SourceStillMatchesAsync(cancellationToken))
+ {
+ await owner.MarkNeedsAttentionAsync(
+ journal.OperationId,
+ "The operation-owned retirement source changed before deletion. It was preserved for operator review.",
+ CancellationToken.None);
+ return VerifiedFileRenameRetirementOutcome.NeedsAttention;
+ }
+
+ sourceEntry.Delete(immediateWindows: true);
+ sourceParent.FlushDirectoryEntry();
+ await owner.AdvanceAsync(
+ journal.OperationId,
+ VerifiedFileRenameState.SourceDeleted,
+ error: null,
+ CancellationToken.None);
+ await owner.AdvanceAsync(
+ journal.OperationId,
+ VerifiedFileRenameState.Completed,
+ error: null,
+ CancellationToken.None);
+ return VerifiedFileRenameRetirementOutcome.Completed;
+ }
+ catch (Exception exception) when (exception is not (
+ OperationCanceledException or OutOfMemoryException
+ or StackOverflowException))
+ {
+ logger.LogWarning(
+ exception,
+ "Verified organize source retirement for {OperationId} did not complete",
+ journal.OperationId);
+ var latest = await owner.GetJournalAsync(
+ journal.OperationId,
+ CancellationToken.None);
+ if (latest?.State == VerifiedFileRenameState.OwnerMetadataReconciled)
+ {
+ if (string.Equals(
+ Path.GetFullPath(sourceEntry.FullPath),
+ Path.GetFullPath(journal.RetirementPath),
+ OperatingSystem.IsWindows()
+ ? StringComparison.OrdinalIgnoreCase
+ : StringComparison.Ordinal))
+ {
+ return await RestoreQuarantinedSourceAsync(
+ originalSourceName,
+ "Live source retirement failed after quarantine. The exact pinned source was restored and retained.");
+ }
+
+ await owner.MarkSourceRetainedAsync(
+ journal.OperationId,
+ "Owner metadata was committed, but live source retirement failed. The old source may remain and will not be deleted by restart recovery.",
+ CancellationToken.None);
+ }
+ else if (latest?.State == VerifiedFileRenameState.SourceQuarantined)
+ {
+ return await RestoreQuarantinedSourceAsync(
+ originalSourceName,
+ "Live source retirement failed after quarantine. The exact pinned source was restored and retained when possible.");
+ }
+ return latest?.State switch
+ {
+ VerifiedFileRenameState.SourceDeleted or
+ VerifiedFileRenameState.Completed =>
+ VerifiedFileRenameRetirementOutcome.Completed,
+ VerifiedFileRenameState.CompletedSourceRetained =>
+ VerifiedFileRenameRetirementOutcome.SourceRetained,
+ _ => VerifiedFileRenameRetirementOutcome.NeedsAttention
+ };
+ }
+ }
+
+ private async Task TargetStillMatchesAsync(
+ CancellationToken cancellationToken) =>
+ targetEntry.ProbeVisiblePathMatch()
+ == RegistrationPublicationMatchOutcome.Match
+ && await targetEntry.MatchesAsync(
+ sourceProof.Length,
+ sourceProof.Sha256,
+ cancellationToken);
+
+ private async Task SourceStillMatchesAsync(
+ CancellationToken cancellationToken) =>
+ sourceEntry.ProbeVisiblePathMatch()
+ == RegistrationPublicationMatchOutcome.Match
+ && await sourceEntry.MatchesAsync(
+ sourceProof.Length,
+ sourceProof.Sha256,
+ cancellationToken)
+ && (!sourceProof.HasDurablePhysicalObjectIdentity
+ || sourceEntry.MatchesObjectIdentity(
+ sourceProof.PhysicalObjectIdentity));
+
+ private async Task RestoreQuarantinedSourceAsync(
+ string originalSourceName,
+ string retainedReason,
+ bool requiresAttention = false)
+ {
+ try
+ {
+ if (!await SourceStillMatchesAsync(CancellationToken.None))
+ {
+ throw new InvalidOperationException(
+ "The operation-owned retirement source changed before restoration.");
+ }
+
+ var restore = sourceEntry.TryMoveToNoReplace(
+ sourceParent,
+ originalSourceName);
+ if (!restore.Published)
+ {
+ throw new InvalidOperationException(
+ $"The original source path could not be restored without replacement (native error {restore.NativeErrorCode}).");
+ }
+ sourceParent.FlushDirectoryEntry();
+ if (requiresAttention)
+ {
+ await owner.MarkNeedsAttentionAsync(
+ journal.OperationId,
+ retainedReason,
+ CancellationToken.None);
+ return VerifiedFileRenameRetirementOutcome.NeedsAttention;
+ }
+
+ await owner.MarkSourceRetainedAsync(
+ journal.OperationId,
+ retainedReason,
+ CancellationToken.None);
+ return VerifiedFileRenameRetirementOutcome.SourceRetained;
+ }
+ catch (Exception exception) when (exception is not (
+ OutOfMemoryException or StackOverflowException))
+ {
+ logger.LogWarning(
+ exception,
+ "Verified organize source quarantine for {OperationId} could not be restored",
+ journal.OperationId);
+ await owner.MarkNeedsAttentionAsync(
+ journal.OperationId,
+ "The verified organize source remains in its operation-owned retirement namespace and requires operator repair.",
+ CancellationToken.None);
+ return VerifiedFileRenameRetirementOutcome.NeedsAttention;
+ }
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ _disposed = true;
+ targetEntry.Dispose();
+ sourceEntry.Dispose();
+ destinationParent.Dispose();
+ sourceParent.Dispose();
+ return ValueTask.CompletedTask;
+ }
+
+ private void ThrowIfDisposed()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ }
+ }
+}
diff --git a/listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.cs b/listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.cs
new file mode 100644
index 000000000..abddca7b2
--- /dev/null
+++ b/listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.cs
@@ -0,0 +1,243 @@
+using Listenarr.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Listenarr.Infrastructure.FileSystem;
+
+public sealed partial class VerifiedFileRenameTransactionCoordinator(
+ IDbContextFactory dbContextFactory,
+ IRootFolderRepository rootFolderRepository,
+ IRootFolderStorageHealthResolver storageHealthResolver,
+ TimeProvider timeProvider,
+ ILogger logger)
+ : IVerifiedFileRenameTransactionCoordinator
+{
+ private const string StagingPrefix = ".listenarr-organize-";
+
+ internal Action? AfterJournalPlannedForTest { get; set; }
+ internal Action? AfterTargetPublicationForTest { get; set; }
+ internal Action? AfterSourceQuarantinedForTest { get; set; }
+ internal Action? BeforeRetirementDeleteForTest { get; set; }
+
+ public async Task PrepareAsync(
+ string source,
+ string destination,
+ Guid operationId,
+ Guid batchId,
+ VerifiedFileRenameBatchManifest batchManifest,
+ int audiobookId,
+ int audiobookFileId,
+ FilePublicationSourceProof sourceProof,
+ CancellationToken cancellationToken = default)
+ {
+ if (operationId == Guid.Empty)
+ {
+ throw new ArgumentException(
+ "A verified organize operation ID is required.",
+ nameof(operationId));
+ }
+ if (batchId == Guid.Empty)
+ {
+ throw new ArgumentException(
+ "A verified organize batch ID is required.",
+ nameof(batchId));
+ }
+ if (audiobookId <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(audiobookId));
+ }
+ if (audiobookFileId < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(audiobookFileId));
+ }
+
+ sourceProof.Validate();
+ batchManifest.Validate();
+ var sourcePath = Path.GetFullPath(source);
+ var destinationPath = Path.GetFullPath(destination);
+ if (string.Equals(sourcePath, destinationPath, StringComparison.Ordinal))
+ {
+ return new VerifiedFileRenamePreparationResult(
+ false,
+ Error: "Verified organize requires distinct source and destination paths.");
+ }
+
+ var sourceParentPath = Path.GetDirectoryName(sourcePath);
+ var destinationParentPath = Path.GetDirectoryName(destinationPath);
+ if (string.IsNullOrWhiteSpace(sourceParentPath)
+ || string.IsNullOrWhiteSpace(destinationParentPath))
+ {
+ return new VerifiedFileRenamePreparationResult(
+ false,
+ Error: "Verified organize requires source and destination parent directories.");
+ }
+
+ var roots = await rootFolderRepository.GetAllAsync();
+ var sourceRoot = FindContainingRoot(sourcePath, roots);
+ var destinationRoot = FindContainingRoot(destinationPath, roots);
+ if (sourceRoot == null || destinationRoot == null)
+ {
+ return new VerifiedFileRenamePreparationResult(
+ false,
+ Error: "Verified organize requires configured source and destination roots with persisted path semantics.");
+ }
+
+ var sourceHealth = await storageHealthResolver.ResolveAsync(
+ sourceRoot,
+ cancellationToken);
+ var destinationHealth = await storageHealthResolver.ResolveAsync(
+ destinationRoot,
+ cancellationToken);
+ if (!sourceHealth.CanRetireVerifiedSource
+ || !destinationHealth.CanPublishAdditively)
+ {
+ return new VerifiedFileRenamePreparationResult(
+ false,
+ Error: "Current storage capabilities do not authorize verified organize publication and live source retirement.");
+ }
+
+ var sourceName = Path.GetFileName(sourcePath);
+ var destinationName = Path.GetFileName(destinationPath);
+ var operationName = operationId.ToString("N");
+ var stagingName = StagingPrefix + operationName + ".partial";
+ var stagingPath = Path.Join(destinationParentPath, stagingName);
+ var retirementPath = Path.Join(
+ sourceParentPath,
+ StagingPrefix + operationName + ".source");
+ var now = timeProvider.GetUtcNow().UtcDateTime;
+ var journal = new VerifiedFileRenameJournal
+ {
+ OperationId = operationId,
+ BatchId = batchId,
+ ProtocolVersion = VerifiedFileRenameProtocol.Current,
+ AudiobookId = audiobookId,
+ AudiobookFileId = audiobookFileId,
+ ExpectedBatchMemberCount = batchManifest.ExpectedMemberCount,
+ ExpectedBatchManifestSha256 = batchManifest.ManifestSha256,
+ SourcePath = sourcePath,
+ DestinationPath = destinationPath,
+ StagingPath = stagingPath,
+ RetirementPath = retirementPath,
+ SourceLength = sourceProof.Length,
+ SourceSha256 = sourceProof.Sha256,
+ SourceRootFolderId = sourceRoot.Id,
+ SourceStorageContractRevision = sourceRoot.StorageContractRevision,
+ DestinationRootFolderId = destinationRoot.Id,
+ DestinationStorageContractRevision = destinationRoot.StorageContractRevision,
+ State = VerifiedFileRenameState.Planned,
+ CreatedAt = now,
+ UpdatedAt = now
+ };
+
+ PinnedDirectoryCreation.PinnedDirectoryAnchor? sourceParent = null;
+ PinnedDirectoryCreation.PinnedDirectoryAnchor? destinationParent = null;
+ PinnedDirectoryCreation.PinnedFileEntry? sourceEntry = null;
+ PinnedDirectoryCreation.PinnedFileEntry? targetEntry = null;
+ var journalPersisted = false;
+ try
+ {
+ sourceParent = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(
+ sourceParentPath);
+ destinationParent = OpenOrCreateVerifiedDestinationParent(
+ destinationRoot,
+ destinationParentPath);
+ sourceEntry = sourceParent.OpenExistingFileForStableDelete(sourceName);
+ if (!sourceEntry.IsRegularFile()
+ || !sourceEntry.VisiblePathMatches()
+ || !await sourceEntry.MatchesAsync(
+ sourceProof.Length,
+ sourceProof.Sha256,
+ cancellationToken)
+ || (sourceProof.HasDurablePhysicalObjectIdentity
+ && !sourceEntry.MatchesObjectIdentity(
+ sourceProof.PhysicalObjectIdentity)))
+ {
+ return new VerifiedFileRenamePreparationResult(
+ false,
+ Error: "The organize source changed before verified publication.");
+ }
+
+ await PersistNewJournalAsync(journal, cancellationToken);
+ journalPersisted = true;
+ AfterJournalPlannedForTest?.Invoke();
+
+ targetEntry = destinationParent.CreateNewFile(
+ stagingName,
+ hiddenFile: true);
+ await CopyAndVerifyAsync(
+ sourceEntry,
+ targetEntry,
+ sourceProof,
+ cancellationToken);
+ var publish = targetEntry.TryMoveToNoReplace(
+ destinationParent,
+ destinationName);
+ if (!publish.Published)
+ {
+ throw new IOException(
+ $"The verified organize destination could not be published without replacement (native error {publish.NativeErrorCode}).");
+ }
+ destinationParent.FlushDirectoryEntry();
+ if (!targetEntry.VisiblePathMatches()
+ || !await targetEntry.MatchesAsync(
+ sourceProof.Length,
+ sourceProof.Sha256,
+ cancellationToken))
+ {
+ throw new InvalidOperationException(
+ "The verified organize target changed after publication.");
+ }
+
+ AfterTargetPublicationForTest?.Invoke();
+ await AdvanceAsync(
+ operationId,
+ VerifiedFileRenameState.TargetVerified,
+ error: null,
+ CancellationToken.None);
+
+ var lease = new VerifiedFileRenameLease(
+ this,
+ journal,
+ sourceParent,
+ destinationParent,
+ sourceEntry,
+ targetEntry,
+ sourceProof,
+ logger);
+ sourceParent = null;
+ destinationParent = null;
+ sourceEntry = null;
+ targetEntry = null;
+ return new VerifiedFileRenamePreparationResult(true, lease);
+ }
+ catch (Exception exception) when (exception is not (
+ OperationCanceledException or OutOfMemoryException
+ or StackOverflowException))
+ {
+ logger.LogWarning(
+ exception,
+ "Verified organize operation {OperationId} could not prepare {Source} -> {Destination}",
+ operationId,
+ sourcePath,
+ destinationPath);
+ if (journalPersisted)
+ {
+ await TryRollbackPreparedTargetAsync(
+ operationId,
+ destinationParent,
+ targetEntry,
+ CancellationToken.None);
+ }
+ return new VerifiedFileRenamePreparationResult(
+ false,
+ Error: "The verified organize file publication failed safely.");
+ }
+ finally
+ {
+ targetEntry?.Dispose();
+ sourceEntry?.Dispose();
+ destinationParent?.Dispose();
+ sourceParent?.Dispose();
+ }
+ }
+}
diff --git a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.GenerationProof.cs b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.GenerationProof.cs
index dada861f2..718af7e0f 100644
--- a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.GenerationProof.cs
+++ b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.GenerationProof.cs
@@ -7,6 +7,11 @@ internal static bool VerifyTrackedFileCleanupComplete(
{
foreach (var tracked in trackedPhysicalObjectIdentities)
{
+ if (PhysicalObjectIdentitySafety.IsKnownWeak(tracked.Value))
+ {
+ return false;
+ }
+
var parentPath = Path.GetDirectoryName(tracked.Key);
var fileName = Path.GetFileName(tracked.Key);
if (string.IsNullOrWhiteSpace(parentPath)
diff --git a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.cs b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.cs
index b0e884865..86985f877 100644
--- a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.cs
+++ b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.cs
@@ -285,11 +285,13 @@ private static IReadOnlyDictionary ResolveTrackedPhysicalObjectI
continue;
}
- if (string.IsNullOrWhiteSpace(file.PhysicalObjectIdentity))
+ if (string.IsNullOrWhiteSpace(file.PhysicalObjectIdentity)
+ || PhysicalObjectIdentitySafety.IsKnownWeak(
+ file.PhysicalObjectIdentity))
{
hasUnprovenTrackedPhysicalIdentities = true;
result.Warnings.Add(
- "A tracked audiobook file has no persisted physical generation, so filesystem deletion was blocked.");
+ "A tracked audiobook file has no durable persisted physical generation, so filesystem deletion was blocked.");
continue;
}
diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.ExternalRecovery.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.ExternalRecovery.cs
index a75ac90d2..4652523b1 100644
--- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.ExternalRecovery.cs
+++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.ExternalRecovery.cs
@@ -161,26 +161,30 @@ private async Task
sourcePath,
sourceMode,
cancellationToken);
- if (sourceSemantics.HasValue
- && await _fileRegistrationRecoveryProbe.HasBlockingBoundaryAsync(
+ var sourceBlocker = sourceSemantics.HasValue
+ ? (await _fileRegistrationRecoveryProbe.GetBlockingBoundaryAsync(
sourcePath,
sourceSemantics.Value,
- cancellationToken))
+ cancellationToken)).FirstOrDefault()
+ : null;
+ if (sourceBlocker != null)
{
- return RegistrationBoundaryConflict(sourcePath);
+ return RegistrationBoundaryConflict(sourcePath, sourceBlocker);
}
var targetSemantics = await ResolveRecoveryBoundarySemanticsAsync(
targetPath,
targetMode,
cancellationToken);
- if (targetSemantics.HasValue
- && await _fileRegistrationRecoveryProbe.HasBlockingBoundaryAsync(
+ var targetBlocker = targetSemantics.HasValue
+ ? (await _fileRegistrationRecoveryProbe.GetBlockingBoundaryAsync(
targetPath,
targetSemantics.Value,
- cancellationToken))
+ cancellationToken)).FirstOrDefault()
+ : null;
+ if (targetBlocker != null)
{
- return RegistrationBoundaryConflict(targetPath);
+ return RegistrationBoundaryConflict(targetPath, targetBlocker);
}
return null;
@@ -244,18 +248,26 @@ private async Task ValidateStartRecoveryBoundariesAsync(
var sourceSemantics =
sourcePathSemantics.MetadataSourcePathSemantics?.Semantics
?? sourcePathSemantics.SourceOperationSemantics;
- if (_fileRegistrationRecoveryProbe != null
- && ((sourceSemantics.HasValue
- && await _fileRegistrationRecoveryProbe.HasBlockingBoundaryAsync(
+ FileRegistrationRecoveryBlocker? registrationBlocker = null;
+ if (_fileRegistrationRecoveryProbe != null)
+ {
+ if (sourceSemantics.HasValue)
+ {
+ registrationBlocker = (await _fileRegistrationRecoveryProbe
+ .GetBlockingBoundaryAsync(
root.Path,
sourceSemantics.Value,
- cancellationToken))
- || await _fileRegistrationRecoveryProbe.HasBlockingBoundaryAsync(
+ cancellationToken)).FirstOrDefault();
+ }
+ registrationBlocker ??= (await _fileRegistrationRecoveryProbe
+ .GetBlockingBoundaryAsync(
targetPath,
targetResolution.Semantics,
- cancellationToken)))
+ cancellationToken)).FirstOrDefault();
+ }
+ if (registrationBlocker != null)
{
- var conflict = RegistrationBoundaryConflict(root.Path);
+ var conflict = RegistrationBoundaryConflict(root.Path, registrationBlocker);
throw new RootFolderPathChangeRejectedException(
conflict.Code,
conflict.PublicMessage,
@@ -276,11 +288,13 @@ await EnsureNoTargetBoundaryConflictAsync(
return targetIdentityKey;
}
- private static ExternalRecoveryConflict RegistrationBoundaryConflict(string path) =>
+ private static ExternalRecoveryConflict RegistrationBoundaryConflict(
+ string path,
+ FileRegistrationRecoveryBlocker blocker) =>
new(
"registration_recovery_pending",
- "An unresolved file publication still owns a path under this root. Complete file-registration recovery before changing the root folder path.",
- $"File-registration recovery touches relocation boundary {LogRedaction.SanitizeFilePath(path)}.");
+ $"File publication {blocker.OperationId} is in state {blocker.JournalState}. Complete file-registration recovery before changing the root folder path.",
+ $"File-registration recovery {blocker.OperationId} touches relocation boundary {LogRedaction.SanitizeFilePath(path)}.");
private static async Task
FindExternalRecoveryConflictAsync(
@@ -298,8 +312,11 @@ private static async Task
.Where(journal => journal.AudiobookId != null
&& audiobookIds.Contains(journal.AudiobookId.Value)
&& journal.AudiobookFileId == null
- && journal.Action == FileAction.Move
- && journal.State != FileMutationJournalState.Completed)
+ && (journal.Action == FileAction.Move
+ || journal.Action == FileAction.Copy
+ || journal.Action == FileAction.HardlinkCopy)
+ && journal.State != FileMutationJournalState.Completed
+ && journal.State != FileMutationJournalState.RolledBack)
.Select(journal => journal.AudiobookId)
.FirstOrDefaultAsync(cancellationToken);
if (registrationOwnerId.HasValue)
diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataRepair.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataRepair.cs
index af10ed3e6..30b9c32e5 100644
--- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataRepair.cs
+++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataRepair.cs
@@ -153,8 +153,11 @@ private async Task EnsureMetadataRepairRowMutationAllowedAsync(
.AnyAsync(
journal => journal.AudiobookId == audiobookId
&& journal.AudiobookFileId == null
- && journal.Action == FileAction.Move
- && journal.State != FileMutationJournalState.Completed,
+ && (journal.Action == FileAction.Move
+ || journal.Action == FileAction.Copy
+ || journal.Action == FileAction.HardlinkCopy)
+ && journal.State != FileMutationJournalState.Completed
+ && journal.State != FileMutationJournalState.RolledBack,
cancellationToken))
{
throw new ApplicationConflictException(
diff --git a/listenarr.infrastructure/Library/Scanning/ScanPathAuthorizationService.cs b/listenarr.infrastructure/Library/Scanning/ScanPathAuthorizationService.cs
index f845addc4..5a00a2c18 100644
--- a/listenarr.infrastructure/Library/Scanning/ScanPathAuthorizationService.cs
+++ b/listenarr.infrastructure/Library/Scanning/ScanPathAuthorizationService.cs
@@ -219,16 +219,27 @@ private async Task TryCapturePhysicalIdentityAsync(
if (enrolled.FailureKind
== DirectoryObjectIdentityFailureKind.LegacyWeakIdentity)
{
- if (!liveBoundary.IsAvailable)
+ if (liveBoundary.IsAvailable)
+ {
+ verifiedBoundaryIdentity = liveBoundary;
+ limitedBoundary = true;
+ }
+ else if (liveBoundary.FailureKind
+ == DirectoryObjectIdentityFailureKind.IdentityUnsupported)
+ {
+ // A released weak Linux identity may still be the best
+ // evidence this mount can provide (for example CIFS
+ // FILEID_INO64_GEN). Keep scanning under pinned path-only
+ // authority, but do not restore destructive generation proof.
+ limitedBoundary = true;
+ }
+ else
{
return PhysicalIdentityCapture.Failed(
liveBoundary.UnavailableReason
?? enrolled.UnavailableReason
?? "The configured scan root physical identity cannot be verified.");
}
-
- verifiedBoundaryIdentity = liveBoundary;
- limitedBoundary = true;
}
else if (enrolled.FailureKind
== DirectoryObjectIdentityFailureKind.IdentityUnsupported)
diff --git a/listenarr.infrastructure/Library/Scanning/UnmatchedScanBackgroundService.cs b/listenarr.infrastructure/Library/Scanning/UnmatchedScanBackgroundService.cs
index 4d6028ba4..495493438 100644
--- a/listenarr.infrastructure/Library/Scanning/UnmatchedScanBackgroundService.cs
+++ b/listenarr.infrastructure/Library/Scanning/UnmatchedScanBackgroundService.cs
@@ -178,6 +178,9 @@ public partial class UnmatchedScanProcessor : IUnmatchedScanProcessor
private static readonly string[] AudioExtensions = { ".m4b", ".mp3", ".flac", ".ogg", ".opus", ".m4a", ".aac", ".wav" };
private sealed record StemGroup(string Stem, List Files);
private sealed record GroupCandidate(string FilePath, string Stem, bool IsAncillary, string TitleKey, string AuthorKey);
+ private sealed record UnmatchedScanOutcome(
+ List Results,
+ List Warnings);
private readonly IUnmatchedScanQueueService _queue;
private readonly IServiceScopeFactory _scopeFactory;
@@ -207,18 +210,31 @@ public async Task ProcessJobAsync(UnmatchedScanJob job, CancellationToken cancel
_logger.LogInformation("Processing unmatched scan job {JobId} for {Path}", job.Id, job.RootFolderPath);
_queue.UpdateJob(job.Id, "Processing");
- var results = await ScanAsync(job.RootFolderPath, cancellationToken);
+ var outcome = await ScanAsync(job.RootFolderPath, cancellationToken);
- _queue.UpdateJob(job.Id, "Completed", results);
- _logger.LogInformation("Unmatched scan job {JobId} completed: {Count} unmatched items", job.Id, results.Count);
+ _queue.UpdateJob(
+ job.Id,
+ "Completed",
+ outcome.Results,
+ warnings: outcome.Warnings);
+ _logger.LogInformation(
+ "Unmatched scan job {JobId} completed: {Count} unmatched items, {WarningCount} warning(s)",
+ job.Id,
+ outcome.Results.Count,
+ outcome.Warnings.Count);
await _hubContext.Clients.All.SendAsync(
"UnmatchedScanComplete",
- new { jobId = job.Id.ToString(), count = results.Count },
+ new
+ {
+ jobId = job.Id.ToString(),
+ count = outcome.Results.Count,
+ warningCount = outcome.Warnings.Count
+ },
cancellationToken);
}
- private async Task> ScanAsync(string rootFolderPath, CancellationToken ct)
+ private async Task ScanAsync(string rootFolderPath, CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var fileRepository = scope.ServiceProvider.GetRequiredService();
@@ -283,13 +299,37 @@ private async Task> ScanAsync(string rootFolderPath, C
semantics,
pinnedRoot,
authorization.PhysicalIdentity.Value.HasDurableGenerationProof);
- if (enumeration.Issues.Any(issue => issue.Kind is
- ScanDiscoveryIssueKind.DirectoryGenerationChanged
- or ScanDiscoveryIssueKind.EnumerationFailure))
+ if (enumeration.Issues.Any(issue =>
+ issue.Kind == ScanDiscoveryIssueKind.DirectoryGenerationChanged))
+ {
+ throw new InvalidOperationException(
+ "The unmatched scan root changed during enumeration.");
+ }
+
+ var rootEnumerationFailure = enumeration.Issues.Any(issue =>
+ issue.Kind == ScanDiscoveryIssueKind.EnumerationFailure
+ && !string.IsNullOrWhiteSpace(issue.Path)
+ && FileSystemPathIdentity.AreEquivalent(
+ issue.Path!,
+ canonicalRootFolderPath,
+ semantics));
+ if (rootEnumerationFailure)
{
throw new InvalidOperationException(
- "The unmatched scan root changed or became unavailable during enumeration.");
+ "The unmatched scan root became unavailable during enumeration.");
}
+
+ var skippedPathCount = enumeration.Issues.Count(issue =>
+ issue.Kind == ScanDiscoveryIssueKind.EnumerationFailure);
+ var warnings = new List();
+ if (skippedPathCount > 0)
+ {
+ warnings.Add(
+ skippedPathCount == 1
+ ? "One path could not be read and was skipped. Other readable library-import results were preserved."
+ : $"{skippedPathCount} paths could not be read and were skipped. Other readable library-import results were preserved.");
+ }
+
var candidates = enumeration.Candidates.ToList();
// Filter to untracked files
@@ -434,7 +474,13 @@ await ApplyPinnedFolderMetadataAsync(
}
});
- return results.OrderBy(r => r.Author).ThenBy(r => r.Series).ThenBy(r => r.Title).ToList();
+ return new UnmatchedScanOutcome(
+ results
+ .OrderBy(r => r.Author)
+ .ThenBy(r => r.Series)
+ .ThenBy(r => r.Title)
+ .ToList(),
+ warnings);
}
}
diff --git a/listenarr.infrastructure/Metadata/Jobs/MetadataRescanService.cs b/listenarr.infrastructure/Metadata/Jobs/MetadataRescanService.cs
index 19db9caad..144fc7bc2 100644
--- a/listenarr.infrastructure/Metadata/Jobs/MetadataRescanService.cs
+++ b/listenarr.infrastructure/Metadata/Jobs/MetadataRescanService.cs
@@ -142,7 +142,7 @@ await RemoveNonAudioFileAsync(
.GetRequiredService();
cancellationToken.ThrowIfCancellationRequested();
using var registrationLease =
- PinnedAudiobookFileRegistrationLease.Open(
+ PinnedAudiobookFileRegistrationLease.OpenForMetadataRead(
resolvedIdentity.CanonicalPath,
file.PhysicalObjectIdentity);
if (!registrationLease.MatchesCurrentPublication())
@@ -153,13 +153,20 @@ await RemoveNonAudioFileAsync(
return;
}
- if (await taskFileService.RefreshPhysicalGenerationAsync(
+ var updated = registrationLease.HasDurablePhysicalObjectIdentity
+ ? await taskFileService.RefreshPhysicalGenerationAsync(
new Audiobook { Id = file.AudiobookId },
file.Id,
file.PhysicalObjectIdentity,
registrationLease,
"MetadataRescan",
- cancellationToken))
+ cancellationToken)
+ : await taskFileService.RefreshMetadataAsync(
+ new Audiobook { Id = file.AudiobookId },
+ file.Id,
+ registrationLease,
+ cancellationToken);
+ if (updated)
{
logger.LogInformation(
"Updated metadata for file id={Id}",
diff --git a/listenarr.infrastructure/Persistence/CompatibilityFilePublicationRecoveryService.cs b/listenarr.infrastructure/Persistence/CompatibilityFilePublicationRecoveryService.cs
index 7ac339535..334e2889e 100644
--- a/listenarr.infrastructure/Persistence/CompatibilityFilePublicationRecoveryService.cs
+++ b/listenarr.infrastructure/Persistence/CompatibilityFilePublicationRecoveryService.cs
@@ -10,6 +10,7 @@ internal interface ICompatibilityFilePublicationRecoveryService
internal sealed class CompatibilityFilePublicationRecoveryService(
IDbContextFactory dbContextFactory,
+ ICompatibilitySourceCleanupCoordinator compatibilitySourceCleanupCoordinator,
TimeProvider timeProvider,
ILogger logger)
: ICompatibilityFilePublicationRecoveryService
@@ -34,6 +35,8 @@ public async Task ReconcileAsync(
cancellationToken.ThrowIfCancellationRequested();
await ReconcileOperationAsync(operationId, cancellationToken);
}
+
+ await RecoverManifestedBatchesAsync(cancellationToken);
}
private async Task ReconcileOperationAsync(
@@ -123,9 +126,20 @@ CompatibilityFilePublicationState.SourceQuarantined or
== CompatibilityFilePublicationProtocol.Current
&& journal.CleanupOwner != CompatibilityCleanupOwner.None)
{
- // The original batch must decide whether every publication succeeded.
- // Startup recovery cannot reconstruct that manifest, so it revokes
- // destructive authority and completes retain-only.
+ if (journal.BatchId.HasValue
+ && journal.ExpectedBatchMemberCount.HasValue
+ && !string.IsNullOrWhiteSpace(
+ journal.ExpectedBatchSourceManifestSha256))
+ {
+ // A sealed manifest can be revalidated after every operation-level
+ // recovery pass completes. Leave this journal committed so the
+ // batch coordinator can decide the whole batch atomically.
+ return;
+ }
+
+ // Released verified-cleanup journals predate persisted manifests.
+ // Without a durable expected-member set, startup cannot prove that
+ // another source should have produced a journal, so fail closed.
journal.SourceDisposition = CompatibilitySourceDisposition.Retained;
journal.State = CompatibilityFilePublicationState.Completed;
journal.Error = "Interrupted compatibility batch recovered retain-only.";
@@ -147,6 +161,73 @@ CompatibilityFilePublicationState.SourceQuarantined or
await context.SaveChangesAsync(cancellationToken);
}
+ private async Task RecoverManifestedBatchesAsync(
+ CancellationToken cancellationToken)
+ {
+ await using var context = await dbContextFactory.CreateDbContextAsync(
+ cancellationToken);
+ var batchIds = await context.CompatibilityFilePublicationJournals
+ .AsNoTracking()
+ .Where(journal =>
+ journal.BatchId.HasValue
+ && journal.State == CompatibilityFilePublicationState.RegistrationCommitted
+ && journal.CleanupOwner != CompatibilityCleanupOwner.None
+ && journal.ExpectedBatchMemberCount.HasValue
+ && journal.ExpectedBatchSourceManifestSha256 != null)
+ .Select(journal => journal.BatchId!.Value)
+ .Distinct()
+ .OrderBy(batchId => batchId)
+ .ToListAsync(cancellationToken);
+
+ foreach (var batchId in batchIds)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var journals = await context.CompatibilityFilePublicationJournals
+ .AsNoTracking()
+ .Where(journal => journal.BatchId == batchId)
+ .ToListAsync(cancellationToken);
+ var incompleteSealedBatch = journals.Count > 0
+ && journals.All(journal =>
+ journal.ExpectedBatchMemberCount.HasValue
+ && journal.ExpectedBatchMemberCount.Value > 0
+ && !string.IsNullOrWhiteSpace(
+ journal.ExpectedBatchSourceManifestSha256))
+ && journals.Select(journal => journal.ExpectedBatchMemberCount!.Value)
+ .Distinct()
+ .Count() == 1
+ && journals.Select(journal => journal.ExpectedBatchSourceManifestSha256)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Count() == 1
+ && journals[0].ExpectedBatchMemberCount!.Value > journals.Count;
+ if (incompleteSealedBatch)
+ {
+ logger.LogInformation(
+ "Manifested compatibility batch {BatchId} is incomplete at startup ({ObservedCount}/{ExpectedCount}); leaving committed members pending for retry",
+ batchId,
+ journals.Count,
+ journals[0].ExpectedBatchMemberCount!.Value);
+ continue;
+ }
+
+ try
+ {
+ await compatibilitySourceCleanupCoordinator.CompleteBatchAsync(
+ batchId,
+ batchSucceeded: true,
+ cancellationToken);
+ }
+ catch (Exception exception) when (exception is not (
+ OperationCanceledException or OutOfMemoryException
+ or StackOverflowException))
+ {
+ logger.LogWarning(
+ exception,
+ "Manifested compatibility batch {BatchId} could not be recovered",
+ batchId);
+ }
+ }
+ }
+
private void ReconcileInterruptedCleanup(
CompatibilityFilePublicationJournal journal)
{
diff --git a/listenarr.infrastructure/Persistence/Configurations/CompatibilityFilePublicationJournalConfiguration.cs b/listenarr.infrastructure/Persistence/Configurations/CompatibilityFilePublicationJournalConfiguration.cs
index 4868d5053..a877e843a 100644
--- a/listenarr.infrastructure/Persistence/Configurations/CompatibilityFilePublicationJournalConfiguration.cs
+++ b/listenarr.infrastructure/Persistence/Configurations/CompatibilityFilePublicationJournalConfiguration.cs
@@ -22,6 +22,8 @@ public void Configure(
.HasMaxLength(64);
builder.Property(journal => journal.TargetSha256)
.HasMaxLength(64);
+ builder.Property(journal => journal.ExpectedBatchSourceManifestSha256)
+ .HasMaxLength(64);
builder.Property(journal => journal.Error)
.HasMaxLength(2048);
builder.Property(journal => journal.QuarantinePath)
diff --git a/listenarr.infrastructure/Persistence/Configurations/VerifiedFileRenameJournalConfiguration.cs b/listenarr.infrastructure/Persistence/Configurations/VerifiedFileRenameJournalConfiguration.cs
new file mode 100644
index 000000000..215e85815
--- /dev/null
+++ b/listenarr.infrastructure/Persistence/Configurations/VerifiedFileRenameJournalConfiguration.cs
@@ -0,0 +1,37 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Listenarr.Infrastructure.Persistence.Configurations;
+
+internal sealed class VerifiedFileRenameJournalConfiguration
+ : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("VerifiedFileRenameJournals");
+ builder.HasKey(journal => journal.OperationId);
+ builder.Property(journal => journal.SourcePath)
+ .IsRequired()
+ .HasMaxLength(4096);
+ builder.Property(journal => journal.DestinationPath)
+ .IsRequired()
+ .HasMaxLength(4096);
+ builder.Property(journal => journal.StagingPath)
+ .IsRequired()
+ .HasMaxLength(4096);
+ builder.Property(journal => journal.RetirementPath)
+ .IsRequired()
+ .HasMaxLength(4096);
+ builder.Property(journal => journal.SourceSha256)
+ .IsRequired()
+ .HasMaxLength(64);
+ builder.Property(journal => journal.ExpectedBatchManifestSha256)
+ .IsRequired()
+ .HasMaxLength(64);
+ builder.Property(journal => journal.Error)
+ .HasMaxLength(2048);
+ builder.HasIndex(journal => journal.BatchId);
+ builder.HasIndex(journal => journal.AudiobookId);
+ builder.HasIndex(journal => journal.State);
+ }
+}
diff --git a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryProbe.cs b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryProbe.cs
index fc77fcaf5..492db6ddb 100644
--- a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryProbe.cs
+++ b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryProbe.cs
@@ -23,15 +23,28 @@ public async Task HasBlockingAsync(
.AnyAsync(journal =>
journal.AudiobookId == audiobookId
&& journal.AudiobookFileId == null
- && journal.Action == FileAction.Move
- && journal.State != FileMutationJournalState.Completed,
+ && (journal.Action == FileAction.Move
+ || journal.Action == FileAction.Copy
+ || journal.Action == FileAction.HardlinkCopy)
+ && journal.State != FileMutationJournalState.Completed
+ && journal.State != FileMutationJournalState.RolledBack,
cancellationToken);
}
public async Task HasBlockingBoundaryAsync(
string boundaryPath,
FileSystemPathSemantics semantics,
- CancellationToken cancellationToken = default)
+ CancellationToken cancellationToken = default) =>
+ (await GetBlockingBoundaryAsync(
+ boundaryPath,
+ semantics,
+ cancellationToken)).Count > 0;
+
+ public async Task>
+ GetBlockingBoundaryAsync(
+ string boundaryPath,
+ FileSystemPathSemantics semantics,
+ CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(boundaryPath);
var canonicalBoundary = FileSystemPathIdentity.Canonicalize(
@@ -43,22 +56,56 @@ public async Task HasBlockingBoundaryAsync(
.AsNoTracking()
.Where(journal =>
journal.AudiobookFileId == null
- && journal.State != FileMutationJournalState.Completed)
+ && (journal.Action == FileAction.Move
+ || journal.Action == FileAction.Copy
+ || journal.Action == FileAction.HardlinkCopy)
+ && journal.State != FileMutationJournalState.Completed
+ && journal.State != FileMutationJournalState.RolledBack)
.Select(journal => new
{
+ journal.OperationId,
+ journal.State,
+ journal.Action,
+ journal.AudiobookId,
journal.SourcePath,
journal.DestinationPath
})
.ToListAsync(cancellationToken);
- return journals.Any(journal =>
- FileSystemPathIdentity.StoredPathMayTouchBoundary(
+ return journals.Select(journal =>
+ {
+ var sourceTouches = FileSystemPathIdentity.StoredPathMayTouchBoundary(
journal.SourcePath,
canonicalBoundary,
- semantics)
- || FileSystemPathIdentity.StoredPathMayTouchBoundary(
+ semantics);
+ var destinationTouches = FileSystemPathIdentity.StoredPathMayTouchBoundary(
journal.DestinationPath,
canonicalBoundary,
- semantics));
+ semantics);
+ return new FileRegistrationRecoveryBlocker(
+ journal.OperationId,
+ journal.State,
+ journal.Action,
+ journal.AudiobookId,
+ journal.AudiobookId.HasValue
+ ? "Audiobook"
+ : journal.State == FileMutationJournalState.RollbackAuthorized
+ ? "StartupRecovery"
+ : "Unknown",
+ sourceTouches,
+ destinationTouches,
+ journal.State == FileMutationJournalState.NeedsAttention
+ ? FileRegistrationRecoveryDisposition.RequiresOperatorAttention
+ : journal.AudiobookId.HasValue
+ ? FileRegistrationRecoveryDisposition.WaitingForOwnerRetry
+ : FileRegistrationRecoveryDisposition.AutomaticRecovery,
+ journal.State == FileMutationJournalState.NeedsAttention
+ ? "This file publication requires operator repair."
+ : "This file publication is waiting for restart recovery.");
+ })
+ .Where(blocker => blocker.SourceTouchesBoundary
+ || blocker.DestinationTouchesBoundary)
+ .OrderBy(blocker => blocker.OperationId)
+ .ToList();
}
}
diff --git a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Orphans.cs b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Orphans.cs
new file mode 100644
index 000000000..110ab5cbe
--- /dev/null
+++ b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Orphans.cs
@@ -0,0 +1,317 @@
+using Listenarr.Domain.Audiobooks.Enumerations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Listenarr.Infrastructure.Persistence;
+
+public sealed partial class FileRegistrationRecoveryService
+{
+ private async Task AdoptCommittedAnonymousPublicationsAsync(
+ int? audiobookId,
+ Guid? operationId,
+ CancellationToken cancellationToken)
+ {
+ await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ var anonymousQuery = db.FileMutationJournals
+ .AsNoTracking()
+ .Where(journal => (journal.Action == FileAction.Move
+ || journal.Action == FileAction.Copy
+ || journal.Action == FileAction.HardlinkCopy)
+ && journal.AudiobookId == null
+ && journal.AudiobookFileId == null
+ && journal.State == FileMutationJournalState.TargetVerified);
+ if (operationId is Guid scopedOperationId)
+ {
+ anonymousQuery = anonymousQuery.Where(
+ journal => journal.OperationId == scopedOperationId);
+ }
+ var anonymousJournals = await anonymousQuery
+ .OrderBy(journal => journal.CreatedAt)
+ .ThenBy(journal => journal.OperationId)
+ .ToListAsync(cancellationToken);
+ if (anonymousJournals.Count == 0)
+ {
+ return;
+ }
+ var targetClaims = await db.FileMutationJournals
+ .AsNoTracking()
+ .Where(journal => journal.TargetPhysicalObjectIdentity != null
+ && journal.State != FileMutationJournalState.RolledBack)
+ .ToListAsync(cancellationToken);
+
+ var filesQuery = db.AudiobookFiles.AsNoTracking();
+ if (audiobookId is int scopedAudiobookId)
+ {
+ filesQuery = filesQuery.Where(file => file.AudiobookId == scopedAudiobookId);
+ }
+ var trackedFiles = await filesQuery.ToListAsync(cancellationToken);
+ foreach (var journal in anonymousJournals)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var matches = trackedFiles
+ .Where(file => RegisteredPathMatches(file, journal.DestinationPath)
+ && RegisteredGenerationMatches(
+ file,
+ journal.TargetPhysicalObjectIdentity))
+ .ToList();
+ if (matches.Count == 0)
+ {
+ continue;
+ }
+ if (targetClaims.Count(candidate =>
+ AnonymousTargetGenerationMatches(candidate, journal)) != 1)
+ {
+ if (!audiobookId.HasValue)
+ {
+ await TryMarkNeedsAttentionAsync(
+ journal.OperationId,
+ journal.State,
+ "Another anonymous registration journal claims the same published target generation.",
+ cancellationToken);
+ }
+ continue;
+ }
+ if (matches.Count != 1)
+ {
+ if (!audiobookId.HasValue)
+ {
+ await TryMarkNeedsAttentionAsync(
+ journal.OperationId,
+ journal.State,
+ "Multiple tracked audiobook files claim the anonymous publication target generation.",
+ cancellationToken);
+ }
+ continue;
+ }
+
+ var matchedFile = matches[0];
+ var adopted = await TryAdoptAnonymousOwnerAsync(
+ db,
+ journal,
+ matchedFile.AudiobookId,
+ cancellationToken);
+ if (adopted)
+ {
+ logger.LogInformation(
+ "Adopted committed anonymous file-registration publication {OperationId} for audiobook {AudiobookId}",
+ journal.OperationId,
+ matchedFile.AudiobookId);
+ }
+ }
+ }
+
+ private async Task TryAdoptAnonymousOwnerAsync(
+ ListenArrDbContext db,
+ FileMutationJournal expected,
+ int audiobookId,
+ CancellationToken cancellationToken)
+ {
+ var now = timeProvider.GetUtcNow().UtcDateTime;
+ if (!db.Database.IsRelational())
+ {
+ var tracked = await db.FileMutationJournals.SingleOrDefaultAsync(
+ candidate => candidate.OperationId == expected.OperationId,
+ cancellationToken);
+ if (tracked == null
+ || tracked.AudiobookId != null
+ || tracked.AudiobookFileId != null
+ || !IsRegistrationPublicationAction(tracked.Action)
+ || tracked.State != FileMutationJournalState.TargetVerified
+ || !string.Equals(
+ tracked.TargetPhysicalObjectIdentity,
+ expected.TargetPhysicalObjectIdentity,
+ StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ tracked.AudiobookId = audiobookId;
+ tracked.UpdatedAt = now;
+ await db.SaveChangesAsync(cancellationToken);
+ return true;
+ }
+
+ var affected = await db.FileMutationJournals
+ .Where(candidate => candidate.OperationId == expected.OperationId
+ && candidate.AudiobookId == null
+ && candidate.AudiobookFileId == null
+ && (candidate.Action == FileAction.Move
+ || candidate.Action == FileAction.Copy
+ || candidate.Action == FileAction.HardlinkCopy)
+ && candidate.State == FileMutationJournalState.TargetVerified
+ && candidate.TargetPhysicalObjectIdentity
+ == expected.TargetPhysicalObjectIdentity)
+ .ExecuteUpdateAsync(
+ setters => setters
+ .SetProperty(candidate => candidate.AudiobookId, audiobookId)
+ .SetProperty(candidate => candidate.UpdatedAt, now),
+ cancellationToken);
+ return affected == 1;
+ }
+
+ private async Task ReconcileOrphanedAnonymousPublicationsAsync(
+ Guid? operationId,
+ CancellationToken cancellationToken)
+ {
+ await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ var journalQuery = db.FileMutationJournals
+ .AsNoTracking()
+ .Where(journal => (journal.Action == FileAction.Move
+ || journal.Action == FileAction.Copy
+ || journal.Action == FileAction.HardlinkCopy)
+ && journal.AudiobookId == null
+ && journal.AudiobookFileId == null
+ && journal.State != FileMutationJournalState.Completed
+ && journal.State != FileMutationJournalState.RolledBack
+ && journal.State != FileMutationJournalState.NeedsAttention);
+ if (operationId is Guid scopedOperationId)
+ {
+ journalQuery = journalQuery.Where(
+ journal => journal.OperationId == scopedOperationId);
+ }
+ var journals = await journalQuery
+ .OrderBy(journal => journal.CreatedAt)
+ .ThenBy(journal => journal.OperationId)
+ .ToListAsync(cancellationToken);
+ if (journals.Count == 0)
+ {
+ return;
+ }
+ var targetClaims = await db.FileMutationJournals
+ .AsNoTracking()
+ .Where(journal => journal.TargetPhysicalObjectIdentity != null
+ && journal.State != FileMutationJournalState.RolledBack)
+ .ToListAsync(cancellationToken);
+
+ var trackedFiles = await db.AudiobookFiles
+ .AsNoTracking()
+ .ToListAsync(cancellationToken);
+ foreach (var journal in journals)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (journal.State is FileMutationJournalState.RegistrationCommitted
+ or FileMutationJournalState.SourceDeletionAuthorized
+ or FileMutationJournalState.SourceDeleted
+ or FileMutationJournalState.OwnerMetadataReconciled)
+ {
+ await TryMarkNeedsAttentionAsync(
+ journal.OperationId,
+ journal.State,
+ "The registration publication reached an owner-bound state without a durable audiobook owner; its target was preserved.",
+ cancellationToken);
+ continue;
+ }
+
+ var pathOwners = trackedFiles
+ .Where(file => RegisteredPathMatches(file, journal.DestinationPath))
+ .ToList();
+ var exactOwners = pathOwners
+ .Where(file => RegisteredGenerationMatches(
+ file,
+ journal.TargetPhysicalObjectIdentity))
+ .ToList();
+ if (exactOwners.Count == 1)
+ {
+ if (journal.State == FileMutationJournalState.TargetVerified)
+ {
+ await TryAdoptAnonymousOwnerAsync(
+ db,
+ journal,
+ exactOwners[0].AudiobookId,
+ cancellationToken);
+ }
+ else
+ {
+ await TryMarkNeedsAttentionAsync(
+ journal.OperationId,
+ journal.State,
+ "A tracked audiobook file claimed the target before uncommitted compensation completed; the target was preserved.",
+ cancellationToken);
+ }
+ continue;
+ }
+ if (pathOwners.Count > 0)
+ {
+ await TryMarkNeedsAttentionAsync(
+ journal.OperationId,
+ journal.State,
+ exactOwners.Count > 1
+ ? "Multiple tracked audiobook files claim the anonymous publication target generation."
+ : "A tracked audiobook file claims the publication path with contradictory generation evidence.",
+ cancellationToken);
+ continue;
+ }
+ if (journal.State != FileMutationJournalState.Planned
+ && targetClaims.Count(candidate =>
+ AnonymousTargetGenerationMatches(candidate, journal)) != 1)
+ {
+ await TryMarkNeedsAttentionAsync(
+ journal.OperationId,
+ journal.State,
+ "Another anonymous registration journal claims the same published target generation.",
+ cancellationToken);
+ continue;
+ }
+
+ try
+ {
+ var outcome = await fileMover.RollbackUncommittedRegistrationAsync(
+ journal.OperationId);
+ if (outcome == UncommittedPublicationRollbackOutcome.RolledBack)
+ {
+ logger.LogInformation(
+ "Rolled back orphaned anonymous file-registration publication {OperationId}",
+ journal.OperationId);
+ }
+ }
+ catch (Exception exception) when (
+ IsTransientRecoveryFilesystemException(exception))
+ {
+ logger.LogWarning(
+ exception,
+ "Anonymous file-registration publication {OperationId} remains pending because storage is temporarily unavailable",
+ journal.OperationId);
+ }
+ }
+ }
+
+ private async Task LogRegistrationPublicationSummaryAsync(
+ CancellationToken cancellationToken)
+ {
+ await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ var publications = await db.FileMutationJournals
+ .AsNoTracking()
+ .Where(journal => journal.AudiobookFileId == null
+ && (journal.Action == FileAction.Move
+ || journal.Action == FileAction.Copy
+ || journal.Action == FileAction.HardlinkCopy))
+ .Select(journal => new
+ {
+ journal.State,
+ journal.AudiobookId
+ })
+ .ToListAsync(cancellationToken);
+ var completed = publications.Count(journal =>
+ journal.State == FileMutationJournalState.Completed);
+ var rolledBack = publications.Count(journal =>
+ journal.State == FileMutationJournalState.RolledBack);
+ var waitingForCommittedOwner = publications.Count(journal =>
+ journal.AudiobookId.HasValue
+ && FileMutationJournalLifecycle.IsRegistrationPublicationRecoverable(
+ journal.State));
+ var pendingWithoutOwner = publications.Count(journal =>
+ !journal.AudiobookId.HasValue
+ && FileMutationJournalLifecycle.IsRegistrationPublicationRecoverable(
+ journal.State));
+ var needsAttention = publications.Count(journal =>
+ FileMutationJournalLifecycle.RequiresOperatorAttention(journal.State));
+
+ logger.LogInformation(
+ "Registration publication recovery: {Completed} completed, {RolledBack} rolled back, {WaitingForCommittedOwner} waiting for committed owner recovery, {PendingWithoutOwner} transiently pending without owner, {NeedsAttention} need attention",
+ completed,
+ rolledBack,
+ waitingForCommittedOwner,
+ pendingWithoutOwner,
+ needsAttention);
+ }
+}
diff --git a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Protocol.cs b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Protocol.cs
index 1c3f39614..b17bc6e85 100644
--- a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Protocol.cs
+++ b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Protocol.cs
@@ -5,15 +5,23 @@ namespace Listenarr.Infrastructure.Persistence;
public sealed partial class FileRegistrationRecoveryService
{
private async Task EnsureCurrentRecoveryProtocolAsync(
- CancellationToken cancellationToken)
+ CancellationToken cancellationToken,
+ Guid? operationId = null)
{
await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken);
- var unsupported = await db.FileMutationJournals
+ var unsupportedQuery = db.FileMutationJournals
.AsNoTracking()
.Where(journal =>
journal.ProtocolVersion != FileMutationProtocol.Current
&& journal.State != FileMutationJournalState.Completed
- && journal.State != FileMutationJournalState.OwnerMetadataReconciled)
+ && journal.State != FileMutationJournalState.RolledBack
+ && journal.State != FileMutationJournalState.OwnerMetadataReconciled);
+ if (operationId is Guid scopedOperationId)
+ {
+ unsupportedQuery = unsupportedQuery.Where(
+ journal => journal.OperationId == scopedOperationId);
+ }
+ var unsupported = await unsupportedQuery
.OrderBy(journal => journal.CreatedAt)
.ThenBy(journal => journal.OperationId)
.Select(journal => new
@@ -32,12 +40,19 @@ private async Task EnsureCurrentRecoveryProtocolAsync(
"This interrupted file mutation predates durable parent-directory generation fencing and cannot be resumed automatically.";
if (!db.Database.IsRelational())
{
- var tracked = await db.FileMutationJournals
+ var trackedQuery = db.FileMutationJournals
.Where(journal =>
journal.ProtocolVersion != FileMutationProtocol.Current
&& journal.State != FileMutationJournalState.Completed
+ && journal.State != FileMutationJournalState.RolledBack
&& journal.State != FileMutationJournalState.OwnerMetadataReconciled
- && journal.State != FileMutationJournalState.NeedsAttention)
+ && journal.State != FileMutationJournalState.NeedsAttention);
+ if (operationId is Guid trackedOperationId)
+ {
+ trackedQuery = trackedQuery.Where(
+ journal => journal.OperationId == trackedOperationId);
+ }
+ var tracked = await trackedQuery
.ToListAsync(cancellationToken);
foreach (var journal in tracked)
{
@@ -49,12 +64,19 @@ private async Task EnsureCurrentRecoveryProtocolAsync(
}
else
{
- await db.FileMutationJournals
+ var trackedQuery = db.FileMutationJournals
.Where(journal =>
journal.ProtocolVersion != FileMutationProtocol.Current
&& journal.State != FileMutationJournalState.Completed
+ && journal.State != FileMutationJournalState.RolledBack
&& journal.State != FileMutationJournalState.OwnerMetadataReconciled
- && journal.State != FileMutationJournalState.NeedsAttention)
+ && journal.State != FileMutationJournalState.NeedsAttention);
+ if (operationId is Guid relationalOperationId)
+ {
+ trackedQuery = trackedQuery.Where(
+ journal => journal.OperationId == relationalOperationId);
+ }
+ await trackedQuery
.ExecuteUpdateAsync(
setters => setters
.SetProperty(
diff --git a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Receipts.cs b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Receipts.cs
index ca409f908..1a326768f 100644
--- a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Receipts.cs
+++ b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Receipts.cs
@@ -1,4 +1,5 @@
using System.Security.Cryptography;
+using Listenarr.Domain.Audiobooks.Enumerations;
using Microsoft.EntityFrameworkCore;
namespace Listenarr.Infrastructure.Persistence;
@@ -14,8 +15,9 @@ private async Task AppendDurableCompletedReceiptsAsync(
await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var completedJournals = await db.FileMutationJournals
.AsNoTracking()
- .Where(RegistrationMoveOwnerPredicate)
- .Where(journal => journal.AudiobookId == audiobookId
+ .Where(RegistrationPublicationOwnerPredicate)
+ .Where(journal => journal.Action == FileAction.Move
+ && journal.AudiobookId == audiobookId
&& journal.State == FileMutationJournalState.Completed)
.OrderBy(journal => journal.CreatedAt)
.ThenBy(journal => journal.OperationId)
diff --git a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.RepairState.cs b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.RepairState.cs
index af25b4ea1..23958d677 100644
--- a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.RepairState.cs
+++ b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.RepairState.cs
@@ -17,7 +17,8 @@ private async Task TryMarkNeedsAttentionAsync(
.Where(candidate => candidate.OperationId == operationId)
.Select(candidate => candidate.State)
.SingleAsync(cancellationToken);
- if (observedState == FileMutationJournalState.Completed)
+ if (FileMutationJournalLifecycle.ClearsRegistrationRecoveryBoundary(
+ observedState))
{
return false;
}
@@ -90,7 +91,7 @@ private async Task ThrowIfNeedsAttentionAsync(
private static ApplicationConflictException RecoveryPending(Guid operationId) =>
new(
"registration_recovery_pending",
- $"A previously committed file import ({operationId}) is still retiring its original source file. Retry after the source file is no longer in use.");
+ $"A previously committed file import ({operationId}) is still completing its durable publication. Retry after recovery finishes.");
private static ApplicationConflictException RepairRequired(Guid operationId) =>
new(
diff --git a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.cs b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.cs
index 83bb32593..4883c8d53 100644
--- a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.cs
+++ b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.cs
@@ -6,9 +6,9 @@
namespace Listenarr.Infrastructure.Persistence;
///
-/// Resumes source retirement for Move publications after the destination generation
-/// and audiobook ownership were already committed. These journals are deliberately
-/// separate from organize/rename recovery because they do not own an AudiobookFileId.
+/// Adopts registration publications after audiobook ownership was committed and
+/// resumes any remaining source retirement. These journals are separate from
+/// organize/rename recovery because they do not own an AudiobookFileId.
///
public sealed partial class FileRegistrationRecoveryService(
IDbContextFactory dbContextFactory,
@@ -21,18 +21,23 @@ public async Task AdoptCommittedAnonymousAsync(
CancellationToken cancellationToken = default)
{
await EnsureCurrentRecoveryProtocolAsync(cancellationToken);
- await AdoptCommittedAnonymousMoveRegistrationsAsync(
+ await AdoptCommittedAnonymousPublicationsAsync(
audiobookId: null,
+ operationId: null,
cancellationToken);
}
public async Task ReconcileAsync(CancellationToken cancellationToken = default)
{
await AdoptCommittedAnonymousAsync(cancellationToken);
+ await ReconcileOrphanedAnonymousPublicationsAsync(
+ operationId: null,
+ cancellationToken);
+ await LogRegistrationPublicationSummaryAsync(cancellationToken);
await using var readContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var attentionOperationId = await readContext.FileMutationJournals
.AsNoTracking()
- .Where(RegistrationMoveOwnerPredicate)
+ .Where(RegistrationPublicationPredicate)
.Where(journal => journal.State == FileMutationJournalState.NeedsAttention)
.OrderBy(journal => journal.CreatedAt)
.ThenBy(journal => journal.OperationId)
@@ -41,12 +46,12 @@ public async Task ReconcileAsync(CancellationToken cancellationToken = default)
if (attentionOperationId.HasValue)
{
throw new InvalidOperationException(
- $"File-registration move journal {attentionOperationId.Value} requires operator repair before filesystem mutations can resume.");
+ $"File-registration publication {attentionOperationId.Value} requires operator repair before filesystem mutations can resume.");
}
var operationIds = await readContext.FileMutationJournals
.AsNoTracking()
- .Where(RegistrationMoveOwnerPredicate)
+ .Where(RegistrationPublicationOwnerPredicate)
.Where(journal => journal.State == FileMutationJournalState.TargetVerified
|| journal.State == FileMutationJournalState.RegistrationCommitted
|| journal.State == FileMutationJournalState.SourceDeletionAuthorized
@@ -89,15 +94,17 @@ public async Task>
ArgumentNullException.ThrowIfNull(requestedSourcePaths);
await EnsureCurrentRecoveryProtocolAsync(cancellationToken);
- await AdoptCommittedAnonymousMoveRegistrationsAsync(
+ await AdoptCommittedAnonymousPublicationsAsync(
audiobookId,
+ operationId: null,
cancellationToken);
await using var readContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var operationIds = await readContext.FileMutationJournals
.AsNoTracking()
- .Where(RegistrationMoveOwnerPredicate)
+ .Where(RegistrationPublicationOwnerPredicate)
.Where(journal => journal.AudiobookId == audiobookId
- && journal.State != FileMutationJournalState.Completed)
+ && journal.State != FileMutationJournalState.Completed
+ && journal.State != FileMutationJournalState.RolledBack)
.OrderBy(journal => journal.CreatedAt)
.ThenBy(journal => journal.OperationId)
.Select(journal => journal.OperationId)
@@ -129,124 +136,111 @@ await AppendDurableCompletedReceiptsAsync(
return receipts;
}
- private async Task AdoptCommittedAnonymousMoveRegistrationsAsync(
- int? audiobookId,
- CancellationToken cancellationToken)
+ public async Task RetryAsync(
+ Guid operationId,
+ CancellationToken cancellationToken = default)
{
- await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken);
- var anonymousJournals = await db.FileMutationJournals
- .AsNoTracking()
- .Where(journal => journal.Action == FileAction.Move
- && journal.AudiobookId == null
- && journal.AudiobookFileId == null
- && journal.State == FileMutationJournalState.TargetVerified)
- .OrderBy(journal => journal.CreatedAt)
- .ThenBy(journal => journal.OperationId)
- .ToListAsync(cancellationToken);
- if (anonymousJournals.Count == 0)
+ if (operationId == Guid.Empty)
{
- return;
+ throw new ArgumentException(
+ "A registration recovery retry requires a non-empty operation ID.",
+ nameof(operationId));
}
- var filesQuery = db.AudiobookFiles.AsNoTracking();
- if (audiobookId.HasValue)
+ await EnsureCurrentRecoveryProtocolAsync(
+ cancellationToken,
+ operationId);
+ var journal = await LoadRegistrationPublicationAsync(
+ operationId,
+ cancellationToken);
+ if (journal.State == FileMutationJournalState.NeedsAttention)
{
- filesQuery = filesQuery.Where(file => file.AudiobookId == audiobookId.Value);
+ return CreateRecoveryStatus(journal);
}
- var trackedFiles = await filesQuery.ToListAsync(cancellationToken);
- foreach (var journal in anonymousJournals)
+
+ if (!FileMutationJournalLifecycle.ClearsRegistrationRecoveryBoundary(
+ journal.State))
{
- cancellationToken.ThrowIfCancellationRequested();
- var matches = trackedFiles
- .Where(file => RegisteredPathMatches(file, journal.DestinationPath)
- && RegisteredGenerationMatches(
- file,
- journal.TargetPhysicalObjectIdentity))
- .ToList();
- if (matches.Count == 0)
- {
- // This is the valid crash-before-metadata-commit state, or an anonymous
- // journal owned by another audiobook during scoped recovery. Leave it
- // anonymous so its own operation/recovery can resolve it later.
- continue;
- }
- if (anonymousJournals.Count(candidate =>
- AnonymousTargetGenerationMatches(candidate, journal)) != 1)
- {
- throw new InvalidOperationException(
- $"Anonymous file-registration move journal {journal.OperationId} shares its published target generation with another unowned journal and cannot be adopted safely.");
- }
- if (matches.Count != 1)
+ if (!journal.AudiobookId.HasValue)
{
- throw new InvalidOperationException(
- $"Anonymous file-registration move journal {journal.OperationId} matches multiple tracked audiobook files and cannot be adopted safely.");
+ await AdoptCommittedAnonymousPublicationsAsync(
+ audiobookId: null,
+ operationId,
+ cancellationToken);
+ await ReconcileOrphanedAnonymousPublicationsAsync(
+ operationId,
+ cancellationToken);
}
- var matchedFile = matches[0];
- var adopted = await TryAdoptAnonymousOwnerAsync(
- db,
- journal,
- matchedFile.AudiobookId,
+ journal = await LoadRegistrationPublicationAsync(
+ operationId,
cancellationToken);
- if (adopted)
+ if (journal.AudiobookId.HasValue
+ && FileMutationJournalLifecycle.IsRegistrationPublicationRecoverable(
+ journal.State))
{
- logger.LogInformation(
- "Adopted committed anonymous file-registration move {OperationId} for audiobook {AudiobookId}",
- journal.OperationId,
- matchedFile.AudiobookId);
+ await ReconcileOperationAsync(
+ operationId,
+ failWhenStillPending: false,
+ cancellationToken);
+ journal = await LoadRegistrationPublicationAsync(
+ operationId,
+ cancellationToken);
}
}
+
+ return CreateRecoveryStatus(journal);
}
- private async Task TryAdoptAnonymousOwnerAsync(
- ListenArrDbContext db,
- FileMutationJournal expected,
- int audiobookId,
+ private async Task LoadRegistrationPublicationAsync(
+ Guid operationId,
CancellationToken cancellationToken)
{
- var now = timeProvider.GetUtcNow().UtcDateTime;
- if (!db.Database.IsRelational())
+ await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ var journal = await db.FileMutationJournals
+ .AsNoTracking()
+ .SingleOrDefaultAsync(
+ candidate => candidate.OperationId == operationId,
+ cancellationToken)
+ ?? throw new KeyNotFoundException(
+ "File-registration recovery operation not found.");
+ if (!IsRegistrationPublicationAction(journal.Action)
+ || journal.AudiobookFileId.HasValue)
{
- var tracked = await db.FileMutationJournals.SingleOrDefaultAsync(
- candidate => candidate.OperationId == expected.OperationId,
- cancellationToken);
- if (tracked == null
- || tracked.AudiobookId != null
- || tracked.AudiobookFileId != null
- || tracked.Action != FileAction.Move
- || tracked.State != FileMutationJournalState.TargetVerified
- || !string.Equals(
- tracked.TargetPhysicalObjectIdentity,
- expected.TargetPhysicalObjectIdentity,
- StringComparison.Ordinal))
- {
- return false;
- }
-
- tracked.AudiobookId = audiobookId;
- tracked.UpdatedAt = now;
- await db.SaveChangesAsync(cancellationToken);
- return true;
+ throw new InvalidOperationException(
+ "The requested operation is not a file-registration publication.");
}
- var affected = await db.FileMutationJournals
- .Where(candidate => candidate.OperationId == expected.OperationId
- && candidate.AudiobookId == null
- && candidate.AudiobookFileId == null
- && candidate.Action == FileAction.Move
- && candidate.State == FileMutationJournalState.TargetVerified
- && candidate.TargetPhysicalObjectIdentity
- == expected.TargetPhysicalObjectIdentity)
- .ExecuteUpdateAsync(
- setters => setters
- .SetProperty(
- candidate => candidate.AudiobookId,
- audiobookId)
- .SetProperty(
- candidate => candidate.UpdatedAt,
- now),
- cancellationToken);
- return affected == 1;
+ return journal;
+ }
+
+ private static FileRegistrationRecoveryStatus CreateRecoveryStatus(
+ FileMutationJournal journal)
+ {
+ var needsAttention = FileMutationJournalLifecycle
+ .RequiresOperatorAttention(journal.State);
+ var recoverable = FileMutationJournalLifecycle
+ .IsRegistrationPublicationRecoverable(journal.State);
+ var cleared = FileMutationJournalLifecycle
+ .ClearsRegistrationRecoveryBoundary(journal.State);
+ return new FileRegistrationRecoveryStatus(
+ journal.OperationId,
+ journal.State,
+ journal.AudiobookId,
+ cleared
+ ? FileRegistrationRecoveryDisposition.Cleared
+ : needsAttention || !recoverable
+ ? FileRegistrationRecoveryDisposition.RequiresOperatorAttention
+ : journal.AudiobookId.HasValue
+ ? FileRegistrationRecoveryDisposition.WaitingForOwnerRetry
+ : FileRegistrationRecoveryDisposition.AutomaticRecovery,
+ CanRetry: recoverable,
+ CanAbandon: false,
+ cleared
+ ? "The file-registration recovery boundary is clear."
+ : needsAttention || !recoverable
+ ? "This publication still requires operator repair; no destructive action was authorized."
+ : "Recovery remains pending because its durable evidence could not yet be reconciled.");
}
private async Task ReconcileOperationAsync(
@@ -262,7 +256,8 @@ private async Task TryAdoptAnonymousOwnerAsync(
journal = await db.FileMutationJournals
.AsNoTracking()
.SingleAsync(candidate => candidate.OperationId == operationId, cancellationToken);
- if (journal.State == FileMutationJournalState.Completed)
+ if (FileMutationJournalLifecycle.ClearsRegistrationRecoveryBoundary(
+ journal.State))
{
return null;
}
@@ -270,8 +265,9 @@ private async Task TryAdoptAnonymousOwnerAsync(
{
throw RepairRequired(operationId);
}
- if (!IsRegistrationMoveOwner(journal)
- || journal.State < FileMutationJournalState.TargetVerified
+ if (!IsRegistrationPublicationOwner(journal)
+ || !FileMutationJournalLifecycle.IsRegistrationPublicationRecoverable(
+ journal.State)
|| !journal.AudiobookId.HasValue)
{
return null;
@@ -320,7 +316,7 @@ private async Task TryAdoptAnonymousOwnerAsync(
{
preparedLease = !string.IsNullOrWhiteSpace(journal.SourceSha256)
? await fileMover.PrepareActionForRegistrationAsync(
- FileAction.Move,
+ journal.Action,
journal.SourcePath,
journal.DestinationPath,
journal.OperationId,
@@ -330,7 +326,7 @@ private async Task TryAdoptAnonymousOwnerAsync(
journal.SourceLength,
journal.SourceSha256))
: await fileMover.PrepareActionForRegistrationAsync(
- FileAction.Move,
+ journal.Action,
journal.SourcePath,
journal.DestinationPath,
journal.OperationId,
@@ -363,11 +359,12 @@ private async Task TryAdoptAnonymousOwnerAsync(
if (!lease.PrepareCleanupRecovery(audiobookId)
|| lease.CompletePublication()
== RegistrationPublicationCompletion.CommittedCleanupPending
- || !await fileMover.CompletePreparedMoveAsync(
- journal.SourcePath,
- journal.DestinationPath,
- lease,
- journal.OperationId))
+ || (journal.Action == FileAction.Move
+ && !await fileMover.CompletePreparedMoveAsync(
+ journal.SourcePath,
+ journal.DestinationPath,
+ lease,
+ journal.OperationId)))
{
await ThrowIfNeedsAttentionAsync(operationId, cancellationToken);
if (failWhenStillPending)
@@ -378,14 +375,16 @@ private async Task TryAdoptAnonymousOwnerAsync(
}
logger.LogInformation(
- "Recovered committed file-registration move {OperationId} for audiobook {AudiobookId}",
+ "Recovered committed file-registration publication {OperationId} for audiobook {AudiobookId}",
operationId,
audiobookId);
- return new FileRegistrationRecoveryReceipt(
- journal.OperationId,
- audiobookId,
- journal.SourcePath,
- journal.DestinationPath);
+ return journal.Action == FileAction.Move
+ ? new FileRegistrationRecoveryReceipt(
+ journal.OperationId,
+ audiobookId,
+ journal.SourcePath,
+ journal.DestinationPath)
+ : null;
}
private static bool IsTransientRecoveryFilesystemException(Exception exception)
@@ -407,7 +406,11 @@ private static bool AnonymousTargetGenerationMatches(
FileMutationJournal left,
FileMutationJournal right)
{
- if (string.IsNullOrWhiteSpace(left.TargetPhysicalObjectIdentity)
+ if (!string.Equals(
+ left.DestinationPath,
+ right.DestinationPath,
+ StringComparison.Ordinal)
+ || string.IsNullOrWhiteSpace(left.TargetPhysicalObjectIdentity)
|| string.IsNullOrWhiteSpace(right.TargetPhysicalObjectIdentity))
{
return false;
@@ -467,15 +470,26 @@ ArgumentException or InvalidOperationException
}
}
- private static bool IsRegistrationMoveOwner(FileMutationJournal journal) =>
- journal.Action == FileAction.Move
+ private static bool IsRegistrationPublicationAction(FileAction action) =>
+ action is FileAction.Move or FileAction.Copy or FileAction.HardlinkCopy;
+
+ private static bool IsRegistrationPublicationOwner(FileMutationJournal journal) =>
+ IsRegistrationPublicationAction(journal.Action)
&& journal.AudiobookId != null
&& journal.AudiobookFileId == null;
private static System.Linq.Expressions.Expression>
- RegistrationMoveOwnerPredicate => journal =>
- journal.Action == FileAction.Move
- && journal.AudiobookId != null
+ RegistrationPublicationOwnerPredicate => journal =>
+ (journal.Action == FileAction.Move
+ || journal.Action == FileAction.Copy
+ || journal.Action == FileAction.HardlinkCopy)
+ && journal.AudiobookId != null && journal.AudiobookFileId == null;
+
+ private static System.Linq.Expressions.Expression>
+ RegistrationPublicationPredicate => journal =>
+ (journal.Action == FileAction.Move
+ || journal.Action == FileAction.Copy
+ || journal.Action == FileAction.HardlinkCopy)
&& journal.AudiobookFileId == null;
}
diff --git a/listenarr.infrastructure/Persistence/FileRenameCommitStore.cs b/listenarr.infrastructure/Persistence/FileRenameCommitStore.cs
index 7b010f12a..cd8045e8e 100644
--- a/listenarr.infrastructure/Persistence/FileRenameCommitStore.cs
+++ b/listenarr.infrastructure/Persistence/FileRenameCommitStore.cs
@@ -5,8 +5,8 @@
namespace Listenarr.Infrastructure.Persistence;
///
-/// Commits tracked audiobook path changes and the terminal state of their
-/// owner-bound rename journals through the same scoped DbContext.
+/// Commits tracked audiobook path changes and the terminal/owner-commit state of
+/// their owner-bound rename journals through the same scoped DbContext.
///
public sealed class FileRenameCommitStore(
ListenArrDbContext dbContext,
@@ -35,8 +35,17 @@ public async Task CommitOwnerMetadataAsync(
}
var journals = new List();
+ var verifiedJournals = new List();
var targetLeases = new List();
- var originalJournalState = new Dictionary();
+ var verifiedLeases = new List();
+ var originalJournalState = new Dictionary();
+ var originalVerifiedState = new Dictionary();
IDbContextTransaction? ownedTransaction = null;
try
{
@@ -45,7 +54,7 @@ public async Task CommitOwnerMetadataAsync(
if (dbContext.Database.CurrentTransaction != null)
{
throw new InvalidOperationException(
- "Rename owner-metadata commit must own its database transaction so filesystem generation proof cannot outlive the commit boundary.");
+ "Rename owner-metadata commit must own its database transaction so filesystem proof cannot outlive the commit boundary.");
}
ownedTransaction = await dbContext.Database.BeginTransactionAsync(
@@ -57,55 +66,61 @@ public async Task CommitOwnerMetadataAsync(
journals = await dbContext.FileMutationJournals
.Where(journal => distinctIds.Contains(journal.OperationId))
.ToListAsync(cancellationToken);
- if (journals.Count != distinctIds.Length)
+ verifiedJournals = await dbContext.VerifiedFileRenameJournals
+ .Where(journal => distinctIds.Contains(journal.OperationId))
+ .ToListAsync(cancellationToken);
+ foreach (var verifiedJournal in verifiedJournals)
+ {
+ // The live verified-rename lease advances rollback/attention state
+ // through its own DbContext. This scoped commit context may still
+ // be tracking the journal from an earlier failed owner commit, so
+ // refresh before deciding which durable state is authoritative.
+ await dbContext.Entry(verifiedJournal).ReloadAsync(cancellationToken);
+ }
+ if (journals.Count + verifiedJournals.Count != distinctIds.Length
+ || journals.Select(journal => journal.OperationId)
+ .Intersect(verifiedJournals.Select(journal => journal.OperationId))
+ .Any())
+ {
+ throw new InvalidOperationException(
+ "One or more owner-bound rename journals are missing or ambiguous before metadata commit.");
+ }
+ if (journals.Count > 0 && verifiedJournals.Count > 0)
{
throw new InvalidOperationException(
- "One or more owner-bound rename journals are missing before metadata commit.");
+ "One organize owner commit cannot mix durable-generation and verified weak-storage rename protocols.");
}
- foreach (var journal in journals)
+ if (journals.Count > 0)
{
- if (journal.Action != FileAction.Move
- || journal.AudiobookId != audiobookId
- || !journal.AudiobookFileId.HasValue)
- {
- throw new InvalidOperationException(
- "A rename journal is not a move bound to the audiobook whose metadata is being committed.");
- }
- if (journal.State != FileMutationJournalState.Completed)
- {
- throw new InvalidOperationException(
- "A rename journal has not completed its filesystem mutation before metadata commit.");
- }
- if (string.IsNullOrWhiteSpace(journal.TargetPhysicalObjectIdentity))
- {
- throw new InvalidOperationException(
- "A completed rename journal has no persisted target physical generation.");
- }
-
- originalJournalState[journal.OperationId] =
- (journal.State, journal.Error, journal.UpdatedAt);
- var targetLease = PinnedAudiobookFileRegistrationLease.Open(
- journal.DestinationPath,
- journal.TargetPhysicalObjectIdentity);
- targetLeases.Add(targetLease);
- if (targetLease.ProbeCurrentPublication()
- != RegistrationPublicationMatchOutcome.Match)
- {
- throw new InvalidOperationException(
- "A completed rename target is not currently the journaled physical generation.");
- }
-
- journal.State = FileMutationJournalState.OwnerMetadataReconciled;
- journal.Error = null;
- journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime;
+ PrepareDurableRenameCommit(
+ audiobookId,
+ journals,
+ targetLeases,
+ originalJournalState);
+ }
+ else
+ {
+ await PrepareVerifiedRenameCommitAsync(
+ audiobookId,
+ distinctIds,
+ verifiedJournals,
+ verifiedLeases,
+ originalVerifiedState,
+ cancellationToken);
}
}
EnsureTargetsStillMatch(targetLeases);
+ await EnsureVerifiedEntriesStillMatchAsync(
+ verifiedLeases,
+ cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
AfterSaveBeforeTargetRevalidationForTest?.Invoke();
EnsureTargetsStillMatch(targetLeases);
+ await EnsureVerifiedEntriesStillMatchAsync(
+ verifiedLeases,
+ cancellationToken);
if (ownedTransaction != null)
{
await ownedTransaction.CommitAsync(cancellationToken);
@@ -128,6 +143,19 @@ public async Task CommitOwnerMetadataAsync(
journal.Error = original.Error;
journal.UpdatedAt = original.UpdatedAt;
}
+ foreach (var journal in verifiedJournals)
+ {
+ if (!originalVerifiedState.TryGetValue(
+ journal.OperationId,
+ out var original))
+ {
+ continue;
+ }
+
+ journal.State = original.State;
+ journal.Error = original.Error;
+ journal.UpdatedAt = original.UpdatedAt;
+ }
throw;
}
finally
@@ -140,6 +168,181 @@ public async Task CommitOwnerMetadataAsync(
{
targetLease.Dispose();
}
+ foreach (var verifiedLease in verifiedLeases)
+ {
+ verifiedLease.Dispose();
+ }
+ }
+ }
+
+ private void PrepareDurableRenameCommit(
+ int audiobookId,
+ IReadOnlyCollection journals,
+ ICollection targetLeases,
+ IDictionary originalJournalState)
+ {
+ foreach (var journal in journals)
+ {
+ if (journal.Action != FileAction.Move
+ || journal.AudiobookId != audiobookId
+ || !journal.AudiobookFileId.HasValue)
+ {
+ throw new InvalidOperationException(
+ "A rename journal is not a move bound to the audiobook whose metadata is being committed.");
+ }
+ if (journal.State != FileMutationJournalState.Completed)
+ {
+ throw new InvalidOperationException(
+ "A rename journal has not completed its filesystem mutation before metadata commit.");
+ }
+ if (string.IsNullOrWhiteSpace(journal.TargetPhysicalObjectIdentity))
+ {
+ throw new InvalidOperationException(
+ "A completed rename journal has no persisted target physical generation.");
+ }
+
+ originalJournalState[journal.OperationId] =
+ (journal.State, journal.Error, journal.UpdatedAt);
+ var targetLease = PinnedAudiobookFileRegistrationLease.Open(
+ journal.DestinationPath,
+ journal.TargetPhysicalObjectIdentity);
+ targetLeases.Add(targetLease);
+ if (targetLease.ProbeCurrentPublication()
+ != RegistrationPublicationMatchOutcome.Match)
+ {
+ throw new InvalidOperationException(
+ "A completed rename target is not currently the journaled physical generation.");
+ }
+
+ journal.State = FileMutationJournalState.OwnerMetadataReconciled;
+ journal.Error = null;
+ journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime;
+ }
+ }
+
+ private async Task PrepareVerifiedRenameCommitAsync(
+ int audiobookId,
+ IReadOnlyCollection distinctIds,
+ IReadOnlyList journals,
+ ICollection leases,
+ IDictionary originalState,
+ CancellationToken cancellationToken)
+ {
+ if (journals.Count == 0)
+ {
+ return;
+ }
+ if (journals.All(journal => journal.State == VerifiedFileRenameState.RolledBack))
+ {
+ if (journals.Any(journal =>
+ journal.ProtocolVersion != VerifiedFileRenameProtocol.Current
+ || journal.AudiobookId != audiobookId))
+ {
+ throw new InvalidOperationException(
+ "A rolled-back verified organize journal is not bound to the expected audiobook/protocol.");
+ }
+ return;
+ }
+ if (journals.Any(journal =>
+ journal.ProtocolVersion != VerifiedFileRenameProtocol.Current
+ || journal.AudiobookId != audiobookId
+ || journal.AudiobookFileId < 0
+ || journal.State != VerifiedFileRenameState.TargetVerified))
+ {
+ throw new InvalidOperationException(
+ "Every verified organize journal must be target-verified and owner-bound before metadata commit.");
+ }
+
+ var batchIds = journals.Select(journal => journal.BatchId).Distinct().ToArray();
+ if (batchIds.Length != 1 || batchIds[0] == Guid.Empty)
+ {
+ throw new InvalidOperationException(
+ "A verified organize owner commit must contain exactly one sealed batch.");
+ }
+
+ var fullBatch = await dbContext.VerifiedFileRenameJournals
+ .Where(journal => journal.BatchId == batchIds[0])
+ .OrderBy(journal => journal.AudiobookFileId)
+ .ThenBy(journal => journal.SourcePath)
+ .ToListAsync(cancellationToken);
+ if (fullBatch.Count != journals[0].ExpectedBatchMemberCount
+ || fullBatch.Count != distinctIds.Count
+ || !fullBatch.Select(journal => journal.OperationId)
+ .ToHashSet()
+ .SetEquals(distinctIds)
+ || fullBatch.Any(journal =>
+ journal.AudiobookId != audiobookId
+ || journal.State != VerifiedFileRenameState.TargetVerified
+ || journal.ExpectedBatchMemberCount != fullBatch.Count
+ || !string.Equals(
+ journal.ExpectedBatchManifestSha256,
+ journals[0].ExpectedBatchManifestSha256,
+ StringComparison.OrdinalIgnoreCase)))
+ {
+ throw new InvalidOperationException(
+ "The verified organize batch is incomplete or inconsistent before owner metadata commit.");
+ }
+
+ var manifest = VerifiedFileRenameBatchManifest.Create(
+ fullBatch.Select(journal => new VerifiedFileRenameBatchMember(
+ journal.AudiobookFileId,
+ journal.SourcePath,
+ journal.DestinationPath)));
+ manifest.Validate();
+ if (manifest.ExpectedMemberCount != fullBatch.Count
+ || !string.Equals(
+ manifest.ManifestSha256,
+ journals[0].ExpectedBatchManifestSha256,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException(
+ "The persisted verified organize batch manifest does not match its journal members.");
+ }
+
+ var rootIds = fullBatch
+ .SelectMany(journal => new[]
+ {
+ journal.SourceRootFolderId,
+ journal.DestinationRootFolderId
+ })
+ .Distinct()
+ .ToArray();
+ var roots = await dbContext.RootFolders
+ .AsNoTracking()
+ .Where(root => rootIds.Contains(root.Id))
+ .ToDictionaryAsync(root => root.Id, cancellationToken);
+ foreach (var journal in fullBatch)
+ {
+ if (!roots.TryGetValue(journal.SourceRootFolderId, out var sourceRoot)
+ || !roots.TryGetValue(
+ journal.DestinationRootFolderId,
+ out var destinationRoot)
+ || sourceRoot.StorageContractRevision
+ != journal.SourceStorageContractRevision
+ || destinationRoot.StorageContractRevision
+ != journal.DestinationStorageContractRevision)
+ {
+ throw new InvalidOperationException(
+ "A verified organize root storage contract changed before owner metadata commit.");
+ }
+ }
+
+ foreach (var journal in journals)
+ {
+ originalState[journal.OperationId] =
+ (journal.State, journal.Error, journal.UpdatedAt);
+ var lease = VerifiedRenameCommitLease.Open(journal);
+ await lease.EnsureMatchesAsync(cancellationToken);
+ leases.Add(lease);
+ journal.State = VerifiedFileRenameState.OwnerMetadataReconciled;
+ journal.Error = null;
+ journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime;
}
}
@@ -161,4 +364,120 @@ private static void EnsureTargetsStillMatch(
}
}
}
+
+ private static async Task EnsureVerifiedEntriesStillMatchAsync(
+ IReadOnlyCollection leases,
+ CancellationToken cancellationToken)
+ {
+ foreach (var lease in leases)
+ {
+ await lease.EnsureMatchesAsync(cancellationToken);
+ }
+ }
+
+ private sealed class VerifiedRenameCommitLease : IDisposable
+ {
+ private readonly PinnedDirectoryCreation.PinnedDirectoryAnchor _sourceParent;
+ private readonly PinnedDirectoryCreation.PinnedDirectoryAnchor _destinationParent;
+ private readonly PinnedDirectoryCreation.PinnedFileEntry _source;
+ private readonly PinnedDirectoryCreation.PinnedFileEntry _target;
+ private readonly long _length;
+ private readonly string _sha256;
+ private bool _disposed;
+
+ private VerifiedRenameCommitLease(
+ PinnedDirectoryCreation.PinnedDirectoryAnchor sourceParent,
+ PinnedDirectoryCreation.PinnedDirectoryAnchor destinationParent,
+ PinnedDirectoryCreation.PinnedFileEntry source,
+ PinnedDirectoryCreation.PinnedFileEntry target,
+ long length,
+ string sha256)
+ {
+ _sourceParent = sourceParent;
+ _destinationParent = destinationParent;
+ _source = source;
+ _target = target;
+ _length = length;
+ _sha256 = sha256;
+ }
+
+ public static VerifiedRenameCommitLease Open(
+ VerifiedFileRenameJournal journal)
+ {
+ var sourceParentPath = Path.GetDirectoryName(journal.SourcePath)
+ ?? throw new InvalidOperationException(
+ "The verified organize source has no parent directory.");
+ var destinationParentPath = Path.GetDirectoryName(journal.DestinationPath)
+ ?? throw new InvalidOperationException(
+ "The verified organize destination has no parent directory.");
+ var sourceParent = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(
+ sourceParentPath);
+ PinnedDirectoryCreation.PinnedDirectoryAnchor? destinationParent = null;
+ PinnedDirectoryCreation.PinnedFileEntry? source = null;
+ PinnedDirectoryCreation.PinnedFileEntry? target = null;
+ try
+ {
+ destinationParent = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(
+ destinationParentPath);
+ source = sourceParent.OpenExistingFileForStableRead(
+ Path.GetFileName(journal.SourcePath));
+ target = destinationParent.OpenExistingFileForStableRead(
+ Path.GetFileName(journal.DestinationPath));
+ var lease = new VerifiedRenameCommitLease(
+ sourceParent,
+ destinationParent,
+ source,
+ target,
+ journal.SourceLength,
+ journal.SourceSha256);
+ sourceParent = null!;
+ destinationParent = null;
+ source = null;
+ target = null;
+ return lease;
+ }
+ finally
+ {
+ target?.Dispose();
+ source?.Dispose();
+ destinationParent?.Dispose();
+ sourceParent?.Dispose();
+ }
+ }
+
+ public async Task EnsureMatchesAsync(CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (_source.ProbeVisiblePathMatch()
+ != RegistrationPublicationMatchOutcome.Match
+ || _target.ProbeVisiblePathMatch()
+ != RegistrationPublicationMatchOutcome.Match
+ || !await _source.MatchesAsync(
+ _length,
+ _sha256,
+ cancellationToken)
+ || !await _target.MatchesAsync(
+ _length,
+ _sha256,
+ cancellationToken))
+ {
+ throw new InvalidOperationException(
+ "A verified organize source or target changed during owner-metadata commit.");
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _target.Dispose();
+ _source.Dispose();
+ _destinationParent.Dispose();
+ _sourceParent.Dispose();
+ }
+ }
}
diff --git a/listenarr.infrastructure/Persistence/FileRenameRecoveryProbe.cs b/listenarr.infrastructure/Persistence/FileRenameRecoveryProbe.cs
index 81c438176..5679a19c4 100644
--- a/listenarr.infrastructure/Persistence/FileRenameRecoveryProbe.cs
+++ b/listenarr.infrastructure/Persistence/FileRenameRecoveryProbe.cs
@@ -16,7 +16,7 @@ public async Task HasBlockingAsync(
}
await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken);
- return await db.FileMutationJournals
+ if (await db.FileMutationJournals
.AsNoTracking()
.AnyAsync(journal =>
journal.AudiobookId == audiobookId
@@ -26,6 +26,18 @@ public async Task HasBlockingAsync(
== FileMutationOwner.RegistrationCompanionFile
? journal.State != FileMutationJournalState.Completed
: journal.State != FileMutationJournalState.OwnerMetadataReconciled),
+ cancellationToken))
+ {
+ return true;
+ }
+
+ return await db.VerifiedFileRenameJournals
+ .AsNoTracking()
+ .AnyAsync(journal =>
+ journal.AudiobookId == audiobookId
+ && journal.State != VerifiedFileRenameState.Completed
+ && journal.State != VerifiedFileRenameState.CompletedSourceRetained
+ && journal.State != VerifiedFileRenameState.RolledBack,
cancellationToken);
}
}
diff --git a/listenarr.infrastructure/Persistence/LibraryFilesystemStartupReconciliationService.cs b/listenarr.infrastructure/Persistence/LibraryFilesystemStartupReconciliationService.cs
index f6d6f70f2..4295676a3 100644
--- a/listenarr.infrastructure/Persistence/LibraryFilesystemStartupReconciliationService.cs
+++ b/listenarr.infrastructure/Persistence/LibraryFilesystemStartupReconciliationService.cs
@@ -70,6 +70,12 @@ await RunScopedAsync(
static (service, token) => service.ReconcileAsync(token),
stoppingToken);
+ phase = "VerifiedFileRenameRecovery";
+ readiness.MarkRunning(phase);
+ await RunScopedAsync(
+ static (service, token) => service.ReconcileAsync(token),
+ stoppingToken);
+
phase = "FileRenameRecovery";
readiness.MarkRunning(phase);
await RunScopedAsync(
diff --git a/listenarr.infrastructure/Persistence/ListenArrDbContext.cs b/listenarr.infrastructure/Persistence/ListenArrDbContext.cs
index 9c99a38fa..93b5ea653 100644
--- a/listenarr.infrastructure/Persistence/ListenArrDbContext.cs
+++ b/listenarr.infrastructure/Persistence/ListenArrDbContext.cs
@@ -42,6 +42,7 @@ public class ListenArrDbContext : DbContext
public DbSet Downloads { get; set; } = null!;
public DbSet DownloadProcessingJobs { get; set; } = null!;
public DbSet FileMutationJournals { get; set; } = null!;
+ public DbSet VerifiedFileRenameJournals { get; set; } = null!;
public DbSet CompatibilityFilePublicationJournals { get; set; } = null!;
public DbSet WeakStorageScanCandidates { get; set; } = null!;
public DbSet DownloadHistories { get; set; } = null!;
diff --git a/listenarr.infrastructure/Persistence/Migrations/20260830025709_AddCompatibilityBatchManifest.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260830025709_AddCompatibilityBatchManifest.Designer.cs
new file mode 100644
index 000000000..a71dc0b22
--- /dev/null
+++ b/listenarr.infrastructure/Persistence/Migrations/20260830025709_AddCompatibilityBatchManifest.Designer.cs
@@ -0,0 +1,2762 @@
+//
+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("20260830025709_AddCompatibilityBatchManifest")]
+ partial class AddCompatibilityBatchManifest
+ {
+ ///
+ 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("IdempotencyKey")
+ .HasMaxLength(200)
+ .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("IdempotencyKey")
+ .IsUnique()
+ .HasFilter("\"IdempotencyKey\" IS NOT NULL");
+
+ 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