diff --git a/fe/src/__tests__/DownloadClientFormModal.spec.ts b/fe/src/__tests__/DownloadClientFormModal.spec.ts index 17beed61d..23320dabd 100644 --- a/fe/src/__tests__/DownloadClientFormModal.spec.ts +++ b/fe/src/__tests__/DownloadClientFormModal.spec.ts @@ -172,4 +172,47 @@ describe('DownloadClientFormModal', () => { expect(calledWith.password).toBe('') expect(calledWith.id).toBe('4') }) + + it('renders URL Base field for qbittorrent and includes it in the test payload when set', async () => { + const api = await import('@/services/api') + ;(api.testDownloadClient as unknown) = vi.fn(async (config: unknown) => ({ + success: true, + message: 'ok', + client: config, + })) + + const wrapper = mount(DownloadClientFormModal, { + global: { plugins: [createPinia()] }, + props: { visible: true, editingClient: null }, + }) + + await wrapper.setProps({ + editingClient: { + id: '5', + name: 'qbt', + type: 'qbittorrent', + host: 'qbittorrent.local', + port: 8080, + isEnabled: true, + useSSL: false, + downloadPath: '', + username: '', + password: '', + settings: {}, + }, + }) + await wrapper.vm.$nextTick() + + const urlBaseInput = wrapper.find('input[id="urlBase"]') + expect(urlBaseInput.exists()).toBe(true) + + await urlBaseInput.setValue('/qbittorrent') + + const testButton = wrapper.find('button.btn-info') + await testButton.trigger('click') + + expect(api.testDownloadClient as unknown).toHaveBeenCalled() + const calledWith = (api.testDownloadClient as unknown).mock.calls[0][0] + expect(calledWith.settings.urlBase).toBe('/qbittorrent') + }) }) diff --git a/fe/src/__tests__/LibraryImportFooter.spec.ts b/fe/src/__tests__/LibraryImportFooter.spec.ts index ca97b90bb..26cfb578a 100644 --- a/fe/src/__tests__/LibraryImportFooter.spec.ts +++ b/fe/src/__tests__/LibraryImportFooter.spec.ts @@ -25,9 +25,10 @@ import type { SearchResult, RootFolder } from '@/types' const success = vi.fn() const error = vi.fn() +const warning = vi.fn() vi.mock('@/services/toastService', () => ({ - useToast: () => ({ success, error }), + useToast: () => ({ success, error, warning }), })) describe('LibraryImportFooter', () => { @@ -161,4 +162,39 @@ describe('LibraryImportFooter', () => { await importButton.trigger('click') expect(importSelected).not.toHaveBeenCalled() }) + + it('discloses copy-and-retain policy before a move from weak storage', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useLibraryImportStore() + store.action = 'move' + + const wrapper = mount(LibraryImportFooter, { + props: { + folders: [ + { + id: 1, + path: 'D:\\library', + canPublishNewFiles: true, + canMutateFilesystem: true, + }, + ] as unknown as RootFolder[], + sourceFolder: { + id: 2, + path: '\\\\nas\\audiobooks', + canPublishNewFiles: true, + canMutateFilesystem: false, + } as unknown as RootFolder, + }, + global: { plugins: [pinia] }, + }) + + const policy = wrapper.get('[data-testid="move-policy-warning"]') + expect(policy.text()).toContain('Move will copy the selected files and retain the source') + expect(policy.text()).toContain('will not attempt source cleanup') + + store.action = 'hardlink/copy' + await wrapper.vm.$nextTick() + expect(wrapper.find('[data-testid="move-policy-warning"]').exists()).toBe(false) + }) }) diff --git a/fe/src/__tests__/RootFoldersSettings.spec.ts b/fe/src/__tests__/RootFoldersSettings.spec.ts index 75276c8b2..99d0b6f41 100644 --- a/fe/src/__tests__/RootFoldersSettings.spec.ts +++ b/fe/src/__tests__/RootFoldersSettings.spec.ts @@ -384,6 +384,27 @@ describe('RootFoldersSettings', () => { wrapper.unmount() }) + it('states that weak-storage moves copy and retain the source', async () => { + vi.mocked(apiService.getRootFolders).mockResolvedValue([ + { + ...rootFolder(null), + storageState: 'Limited', + storageReason: 'IdentityUnsupported', + storageMessage: 'Durable file identity is unavailable.', + canPublishNewFiles: true, + canMutateFilesystem: false, + }, + ]) + const pinia = createReadyPinia() + const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) + await flushPromises() + + const policy = wrapper.get('[data-cy="compatibility-publication-message"]') + expect(policy.text()).toContain('will copy files into this storage and retain the source') + expect(policy.text()).toContain('will not attempt source cleanup') + wrapper.unmount() + }) + it('shows initializing, blocks filesystem actions, and keeps metadata editing available', async () => { const folder = { ...rootFolder(null), diff --git a/fe/src/__tests__/libraryImport.store.spec.ts b/fe/src/__tests__/libraryImport.store.spec.ts index 856946ba0..09a41c38a 100644 --- a/fe/src/__tests__/libraryImport.store.spec.ts +++ b/fe/src/__tests__/libraryImport.store.spec.ts @@ -101,7 +101,20 @@ describe('library import store', () => { } store.action = 'move' - await store.importSelected('D:\\library') + startManualImport.mockResolvedValueOnce({ + importedCount: 3, + totalCount: 3, + results: [ + { + success: true, + sourcePath: 'C:\\incoming\\Part 1.mp3', + destinationPath: 'D:\\library\\Ordered Book\\Part 1.mp3', + warning: 'The source file was retained because durable identity is unavailable.', + }, + ], + }) + + const result = await store.importSelected('D:\\library') expect(addToLibrary).toHaveBeenCalledTimes(1) expect(startManualImport).toHaveBeenCalledTimes(1) @@ -117,6 +130,9 @@ describe('library import store', () => { { fullPath: 'C:\\incoming\\Part 10.mp3', matchedAudiobookId: 42 }, ], }) + expect(result.warnings).toEqual([ + 'The source file was retained because durable identity is unavailable.', + ]) }) it('registers files in place using the discovered book folder and backend success result', async () => { @@ -176,7 +192,7 @@ describe('library import store', () => { }, ], }) - expect(result).toEqual({ imported: 1, errors: [] }) + expect(result).toEqual({ imported: 1, errors: [], warnings: [] }) expect(store.itemList).toHaveLength(0) }) @@ -442,4 +458,103 @@ describe('library import store', () => { expect(store.hasUnprocessedItems).toBe(true) expect(store.failedCount).toBe(1) }) + + it('carries every series membership of a multi-series match into the add request', async () => { + const { useLibraryImportStore } = await import('@/stores/libraryImport') + const store = useLibraryImportStore() + + // A book can legitimately belong to more than one series. Audnexus returns these as + // seriesPrimary/seriesSecondary and the backend builds one membership per entry, so the + // import path has to send both rather than keeping only the first. + store.items = { + '/incoming/Two Series/Book.m4b': { + id: '/incoming/Two Series/Book.m4b', + fullPath: '/incoming/Two Series/Book.m4b', + sourceFiles: ['/incoming/Two Series/Book.m4b'], + folderPath: '/incoming/Two Series', + relativePath: 'Two Series', + folderName: 'Two Series', + format: 'M4B', + fileCount: 1, + selectedMatch: { + title: 'Two Series Book', + authors: [{ name: 'Author' }], + series: [ + { asin: 'B01E633FQM', name: 'First Series', position: '0' }, + { asin: 'B01F5TL5K4', name: 'Second Series', position: '7' }, + ], + } as unknown as SearchResult, + hasSearched: true, + isSearching: false, + selected: true, + }, + } + store.action = 'none' + + await store.importSelected('') + + const metadata = addToLibrary.mock.calls[0][0] + expect(metadata.seriesMemberships).toEqual([ + { + seriesName: 'First Series', + seriesNumber: '0', + seriesAsin: 'B01E633FQM', + isPrimary: true, + sortOrder: 0, + }, + { + seriesName: 'Second Series', + seriesNumber: '7', + seriesAsin: 'B01F5TL5K4', + isPrimary: false, + sortOrder: 1, + }, + ]) + // The primary is still mirrored onto the legacy scalars. + expect(metadata.series).toBe('First Series') + expect(metadata.seriesNumber).toBe('0') + }) + + it('drops a series asin that is really the series name', async () => { + const { useLibraryImportStore } = await import('@/stores/libraryImport') + const store = useLibraryImportStore() + + // When the search endpoint cannot re-fetch the book by ASIN it synthesizes a single + // series entry whose `asin` is a copy of the series name. That must not be persisted + // as a series ASIN. + store.items = { + '/incoming/Fallback/Book.m4b': { + id: '/incoming/Fallback/Book.m4b', + fullPath: '/incoming/Fallback/Book.m4b', + sourceFiles: ['/incoming/Fallback/Book.m4b'], + folderPath: '/incoming/Fallback', + relativePath: 'Fallback', + folderName: 'Fallback', + format: 'M4B', + fileCount: 1, + selectedMatch: { + title: 'Fallback Book', + authors: [{ name: 'Author' }], + series: [{ asin: 'Some Series', name: 'Some Series', position: '2' }], + } as unknown as SearchResult, + hasSearched: true, + isSearching: false, + selected: true, + }, + } + store.action = 'none' + + await store.importSelected('') + + const metadata = addToLibrary.mock.calls[0][0] + expect(metadata.seriesMemberships).toEqual([ + { + seriesName: 'Some Series', + seriesNumber: '2', + seriesAsin: undefined, + isPrimary: true, + sortOrder: 0, + }, + ]) + }) }) diff --git a/fe/src/components/domain/audiobook/LibraryImportFooter.vue b/fe/src/components/domain/audiobook/LibraryImportFooter.vue index 4888d1613..5bd6ce2b6 100644 --- a/fe/src/components/domain/audiobook/LibraryImportFooter.vue +++ b/fe/src/components/domain/audiobook/LibraryImportFooter.vue @@ -59,6 +59,18 @@ {{ store.failedCount }} lookup{{ store.failedCount === 1 ? '' : 's' }} failed - these were not matched and will be retried on the next run + +
+ + + This storage cannot prove durable file identity. Move will copy the selected files and + retain the source; Listenarr will not attempt source cleanup. + +
-
+
- Path prefix for qBittorrent instances behind a reverse proxy (e.g. + /qbittorrent). Must match the prefix your reverse proxy strips before + forwarding to qBittorrent's own root-relative API - qBittorrent itself has no + built-in base path setting. Leave blank if qBittorrent is reachable directly at the + host/port above. + RPC path for the Transmission endpoint. Default is /transmission/rpc. Some seedbox providers use a custom path (e.g. /rpc). @@ -496,6 +503,14 @@ const getPortHelpText = () => { return hints[formData.value.type] || 'Port the download client is listening on.' } +const showUrlBase = computed(() => { + return formData.value.type === 'transmission' || formData.value.type === 'qbittorrent' +}) + +const getUrlBasePlaceholder = () => { + return formData.value.type === 'qbittorrent' ? '/qbittorrent' : '/transmission/rpc' +} + const getCategoryHelp = () => { if (isUsenet.value) { return 'Adding a category specific to Listenarr avoids conflicts with unrelated non-Listenarr downloads. Using a category is optional, but strongly recommended.' @@ -593,9 +608,7 @@ const testConnection = async () => { ...(formData.value.type === 'sabnzbd' && formData.value.apiKey ? { apiKey: formData.value.apiKey } : {}), - ...(formData.value.type === 'transmission' && formData.value.urlBase - ? { urlBase: formData.value.urlBase } - : {}), + ...(showUrlBase.value && formData.value.urlBase ? { urlBase: formData.value.urlBase } : {}), ...(formData.value.category && { category: formData.value.category }), ...(formData.value.tags && { tags: formData.value.tags }), recentPriority: formData.value.recentPriority, @@ -653,9 +666,7 @@ const handleSubmit = async () => { ...(formData.value.type === 'sabnzbd' && formData.value.apiKey ? { apiKey: formData.value.apiKey } : {}), - ...(formData.value.type === 'transmission' && formData.value.urlBase - ? { urlBase: formData.value.urlBase } - : {}), + ...(showUrlBase.value && formData.value.urlBase ? { urlBase: formData.value.urlBase } : {}), ...(formData.value.category && { category: formData.value.category }), ...(formData.value.tags && { tags: formData.value.tags }), recentPriority: formData.value.recentPriority, diff --git a/fe/src/components/settings/RootFoldersSettings.vue b/fe/src/components/settings/RootFoldersSettings.vue index aefe69914..4e90e77cf 100644 --- a/fe/src/components/settings/RootFoldersSettings.vue +++ b/fe/src/components/settings/RootFoldersSettings.vue @@ -178,6 +178,14 @@ > {{ folder.storageMessage }}

+

+ Move policy: Listenarr will copy files into this storage and retain the source. It + will not attempt source cleanup while durable file identity is unavailable. +

a.name ?? '').filter(Boolean) : [] - // series may come back as AudibleSeries[] from the search endpoint + // series may come back as AudibleSeries[] from the search endpoint. A book can belong to + // more than one series (Audnexus seriesPrimary/seriesSecondary), so every entry becomes a + // membership; the scalar fields below stay populated from the primary for older consumers. const seriesRaw = result.series as unknown - const seriesItem = Array.isArray(seriesRaw) - ? (seriesRaw as Array<{ name?: string; asin?: string; position?: string }>)[0] - : null + const seriesEntries = Array.isArray(seriesRaw) + ? (seriesRaw as Array<{ name?: string; asin?: string; position?: string }>) + : [] + const seriesMemberships: AudiobookSeriesMembership[] = seriesEntries + .filter((entry) => (entry?.name ?? '').trim().length > 0) + .map((entry, index) => { + const asin = entry.asin?.trim() + return { + seriesName: (entry.name ?? '').trim(), + seriesNumber: entry.position?.trim() || undefined, + // The search fallback branch fills `asin` with the series *name* when the ASIN + // re-fetch fails, so only keep a value that actually looks like an ASIN. Until now + // that bogus value was discarded anyway; a membership would persist it. + seriesAsin: asin && ASIN_PATTERN.test(asin) ? asin : undefined, + isPrimary: index === 0, + sortOrder: index, + } + }) + const seriesItem = seriesEntries[0] ?? null const series = seriesItem?.name ?? (typeof seriesRaw === 'string' ? seriesRaw : undefined) const seriesNumber = seriesItem?.position ?? result.seriesNumber const seriesAsin = seriesItem?.asin ?? result.seriesAsin @@ -119,6 +144,7 @@ function matchToMetadata(result: SearchResult): AudibleBookMetadata { series, seriesNumber, seriesAsin, + ...(seriesMemberships.length > 0 ? { seriesMemberships } : {}), description: result.description, publisher: result.publisher, language: result.language, @@ -422,7 +448,7 @@ export const useLibraryImportStore = defineStore('libraryImport', () => { items.value[id] = { ...item, isSearching: true } try { - const isAsin = /^[A-Z0-9]{10}$/i.test(query.trim()) + const isAsin = ASIN_PATTERN.test(query.trim()) const results = await apiService.advancedSearch( isAsin ? { asin: query.trim(), cap: 5 } : { title: query, cap: 5 }, ) @@ -506,9 +532,10 @@ export const useLibraryImportStore = defineStore('libraryImport', () => { async function importSelected( rootFolderPath: string, - ): Promise<{ imported: number; errors: string[] }> { + ): Promise<{ imported: number; errors: string[]; warnings: string[] }> { const toImport = itemList.value.filter((i) => i.selected && i.selectedMatch) importErrors.value = [] + const warnings: string[] = [] let imported = 0 for (const item of toImport) { @@ -574,6 +601,12 @@ export const useLibraryImportStore = defineStore('libraryImport', () => { ) } + for (const result of importResult.results ?? []) { + if (result.success && result.warning && !warnings.includes(result.warning)) { + warnings.push(result.warning) + } + } + // Remove imported item from store only after the backend confirms every // source file represented by this book row was registered successfully. const updated = { ...items.value } @@ -588,7 +621,7 @@ export const useLibraryImportStore = defineStore('libraryImport', () => { } } - return { imported, errors: importErrors.value } + return { imported, errors: importErrors.value, warnings } } return { diff --git a/fe/src/types/index.ts b/fe/src/types/index.ts index 445dc7585..259471aa3 100644 --- a/fe/src/types/index.ts +++ b/fe/src/types/index.ts @@ -328,6 +328,7 @@ export interface RootFolder { canChangePath?: boolean canReadFilesystem?: boolean canScanFilesystem?: boolean + canPublishNewFiles?: boolean canMutateFilesystem?: boolean confirmationToken?: string | null activeRelocation?: RootFolderPathChangeResult | null @@ -1091,6 +1092,11 @@ export interface ManualImportResult { error?: string skipped?: boolean skipReason?: string + requestedAction?: string + effectiveAction?: string + sourceDisposition?: string + warningCode?: string + warning?: string } // Audible API Types diff --git a/fe/src/views/library/LibraryImportView.vue b/fe/src/views/library/LibraryImportView.vue index d7bc74bb4..b79716372 100644 --- a/fe/src/views/library/LibraryImportView.vue +++ b/fe/src/views/library/LibraryImportView.vue @@ -214,6 +214,7 @@
@@ -264,6 +265,9 @@ const configStore = useConfigurationStore() const filesystemReadinessStore = useFilesystemReadinessStore() const selectedFolderId = ref(null) +const selectedSourceFolder = computed(() => + rootFoldersStore.folders.find((folder) => folder.id === selectedFolderId.value), +) const sortKey = ref('folder') const sortDirection = ref('asc') const columnWidths = ref({ ...DEFAULT_LIBRARY_IMPORT_COLUMN_WIDTHS }) diff --git a/listenarr.api/Dtos/ManualImport/ManualImportResultDto.cs b/listenarr.api/Dtos/ManualImport/ManualImportResultDto.cs index 7bc4da1f0..64480b6e1 100644 --- a/listenarr.api/Dtos/ManualImport/ManualImportResultDto.cs +++ b/listenarr.api/Dtos/ManualImport/ManualImportResultDto.cs @@ -27,6 +27,11 @@ public class ManualImportResultDto public string? Error { get; set; } public bool Skipped { get; set; } public string? SkipReason { get; set; } + public string? RequestedAction { get; set; } + public string? EffectiveAction { get; set; } + public string? SourceDisposition { get; set; } + public string? WarningCode { get; set; } + public string? Warning { get; set; } public static ManualImportResultDto SkippedResult( string reason, diff --git a/listenarr.api/Features/Downloads/ManualImportCompanionImporter.Publication.cs b/listenarr.api/Features/Downloads/ManualImportCompanionImporter.Publication.cs new file mode 100644 index 000000000..6d8be2b33 --- /dev/null +++ b/listenarr.api/Features/Downloads/ManualImportCompanionImporter.Publication.cs @@ -0,0 +1,116 @@ +namespace Listenarr.Api.Features.Downloads; + +public sealed partial class ManualImportCompanionImporter +{ + private async Task PublishAndRegisterAudioCompanionAsync( + FilePublicationPlan publicationPlan, + string sourcePath, + string destinationPath, + Guid operationId, + FilePublicationSourceProof expectedSourceProof, + Audiobook audiobook, + AudiobookFileOwnershipCheckResult ownership, + CancellationToken cancellationToken) + { + var expectedIdentity = publicationPlan.EffectiveAction + == FileAction.HardlinkCopy + ? ownership.ExistingFile?.PhysicalObjectIdentity + : null; + var preparation = await _fileMover + .PrepareActionForRegistrationDetailedAsync( + publicationPlan, + sourcePath, + destinationPath, + operationId, + expectedIdentity, + expectedSourceProof); + + using var registrationLease = preparation.RegistrationLease; + if (registrationLease == null + || _audiobookFileService == null + || !(publicationPlan.Mode + == FilePublicationExecutionMode.AdditiveCopyRetainSource + ? await _audiobookFileService.RegisterCompatibilityPublicationAsync( + audiobook, + ownership, + registrationLease, + "manual-import-companion", + cancellationToken) + : await _audiobookFileService.RegisterPublishedGenerationAsync( + audiobook, + ownership, + registrationLease, + "manual-import-companion", + cancellationToken))) + { + return false; + } + + if (publicationPlan.EffectiveAction == FileAction.Move + && !await _fileMover.CompletePreparedMoveAsync( + sourcePath, + destinationPath, + registrationLease, + operationId)) + { + await _audiobookFileService.RollbackPublishedGenerationIfStaleAsync( + audiobook, + registrationLease); + return false; + } + + var completion = registrationLease.CompletePublication(); + if (completion + == RegistrationPublicationCompletion.CommittedCleanupPending) + { + _logger.LogWarning( + "Manual import companion committed for audiobook {AudiobookId}, but registration-publication cleanup remains pending for {Path}", + audiobook.Id, + LogRedaction.SanitizeFilePath(destinationPath)); + } + + return true; + } + + private async Task PublishUnregisteredCompanionAsync( + FilePublicationPlan publicationPlan, + string sourcePath, + string destinationPath, + Guid operationId, + FilePublicationSourceProof expectedSourceProof, + int audiobookId) + { + var preparation = await _fileMover + .PrepareActionForRegistrationDetailedAsync( + publicationPlan, + sourcePath, + destinationPath, + operationId, + expectedRegisteredPhysicalObjectIdentity: null, + expectedSourceProof, + isCompanionFile: true, + companionAudiobookId: audiobookId); + using var lease = preparation.RegistrationLease; + if (lease == null || !lease.PrepareCleanupRecovery(audiobookId)) + { + return false; + } + + var completion = lease.CompletePublication(); + if (completion + == RegistrationPublicationCompletion.CommittedCleanupPending) + { + _logger.LogWarning( + "Manual import companion publication committed, but cleanup remains pending for {Path}", + LogRedaction.SanitizeFilePath(destinationPath)); + return false; + } + + return publicationPlan.EffectiveAction != FileAction.Move + || await _fileMover.CompletePreparedMoveAsync( + sourcePath, + destinationPath, + lease, + operationId); + } +} diff --git a/listenarr.api/Features/Downloads/ManualImportCompanionImporter.cs b/listenarr.api/Features/Downloads/ManualImportCompanionImporter.cs index e4a3d653a..7a3851d4a 100644 --- a/listenarr.api/Features/Downloads/ManualImportCompanionImporter.cs +++ b/listenarr.api/Features/Downloads/ManualImportCompanionImporter.cs @@ -18,7 +18,7 @@ namespace Listenarr.Api.Features.Downloads; -public sealed class ManualImportCompanionImporter +public sealed partial class ManualImportCompanionImporter { private readonly IMetadataService _metadataService; private readonly IFileMover _fileMover; @@ -27,6 +27,8 @@ public sealed class ManualImportCompanionImporter private readonly ILibraryDirectoryOwnershipStore _directoryOwnershipStore; private readonly ILogger _logger; private readonly IAudiobookFileService? _audiobookFileService; + private readonly IFilePublicationCapabilityResolver? + _filePublicationCapabilityResolver; public ManualImportCompanionImporter( IMetadataService metadataService, @@ -35,7 +37,8 @@ public ManualImportCompanionImporter( IFileSystem fileSystem, ILibraryDirectoryOwnershipStore directoryOwnershipStore, ILogger logger, - IAudiobookFileService? audiobookFileService = null) + IAudiobookFileService? audiobookFileService = null, + IFilePublicationCapabilityResolver? filePublicationCapabilityResolver = null) { _metadataService = metadataService; _fileMover = fileMover; @@ -45,6 +48,7 @@ public ManualImportCompanionImporter( _directoryOwnershipStore = directoryOwnershipStore; _logger = logger; _audiobookFileService = audiobookFileService; + _filePublicationCapabilityResolver = filePublicationCapabilityResolver; } public async Task> BuildAudioMatchProfilesAsync( @@ -76,6 +80,7 @@ public async Task ImportAsync( FileSystemPathSemantics sourceSemantics, IReadOnlyDictionary destinationResolutionsByAudiobook, IEnumerable importBlacklist, + IReadOnlyCollection rootFolders, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -209,6 +214,24 @@ public async Task ImportAsync( sourceProof, destinationPath, destinationResolution.Semantics); + var publicationPlan = _filePublicationCapabilityResolver == null + ? sourceProof.HasDurablePhysicalObjectIdentity + ? FilePublicationPlan.Durable(action) + : FilePublicationPlan.Additive(action) + : await _filePublicationCapabilityResolver.ResolveAsync( + action, + companionFile, + destinationPath, + sourceProof, + cancellationToken); + if (!publicationPlan.IsAllowed) + { + _logger.LogWarning( + "Skipping companion file {FilePath}: {Reason}", + companionFile, + LogRedaction.SanitizeText(publicationPlan.Message)); + continue; + } var destinationDirectory = Path.GetDirectoryName(destinationPath) ?? throw new InvalidOperationException( @@ -242,18 +265,49 @@ AudiobookFileOwnershipCheckOutcome.Available or continue; } - await _directoryOwnershipStore.EnsureCreatedHierarchyAsync( + // The managed boundary has to be a configured root folder: AuthorizeAsync matches the + // boundary against RootFolders by equivalence, not by containment. destinationRoot is + // the common parent of the destination paths this batch produced, so for the ordinary + // single-book import it is the book folder, which is never a root and is always + // refused. Select the boundary the same way the primary audio file's import does, so + // the companion and the audio it accompanies are authorized against the same root. + var companionBoundary = LibraryDirectoryOwnershipPlanning.SelectMostSpecificBoundary( destinationDirectory, - destinationRoot, - destinationResolution.Semantics, - "manual-import-companion", - operationId, - audiobookIds[0], - cancellationToken); + rootFolders.Select(root => root.Path), + destinationResolution.Semantics) + ?? destinationResolution.BoundaryPath; + if (string.IsNullOrWhiteSpace(companionBoundary)) + { + _logger.LogWarning( + "Skipping companion file {FilePath} because its destination has no managed ownership boundary", + companionFile); + continue; + } + + if (publicationPlan.Mode + == FilePublicationExecutionMode.AdditiveCopyRetainSource) + { + await _directoryOwnershipStore.EnsureAdditiveHierarchyAsync( + destinationDirectory, + companionBoundary, + destinationResolution.Semantics, + cancellationToken); + } + else + { + await _directoryOwnershipStore.EnsureCreatedHierarchyAsync( + destinationDirectory, + companionBoundary, + destinationResolution.Semantics, + "manual-import-companion", + operationId, + audiobookIds[0], + cancellationToken); + } cancellationToken.ThrowIfCancellationRequested(); var success = isAudioCompanion ? await PublishAndRegisterAudioCompanionAsync( - action, + publicationPlan, companionFile, destinationPath, operationId, @@ -261,21 +315,13 @@ await _directoryOwnershipStore.EnsureCreatedHierarchyAsync( targetAudiobook!, ownership!, cancellationToken) - : action == FileAction.Move - ? await _fileMover.PerformActionOn( - action, - companionFile, - destinationPath, - operationId, - audiobookIds[0], - FileMutationOwner.CompanionFile, - sourceProof) - : await _fileMover.PerformActionOn( - action, - companionFile, - destinationPath, - operationId, - sourceProof); + : await PublishUnregisteredCompanionAsync( + publicationPlan, + companionFile, + destinationPath, + operationId, + sourceProof, + audiobookIds[0]); if (success) { destinationTracker.Commit(destinationReservation); @@ -291,72 +337,6 @@ await _directoryOwnershipStore.EnsureCreatedHierarchyAsync( return importedCount; } - private async Task PublishAndRegisterAudioCompanionAsync( - FileAction action, - string sourcePath, - string destinationPath, - Guid operationId, - FilePublicationSourceProof expectedSourceProof, - Audiobook audiobook, - AudiobookFileOwnershipCheckResult ownership, - CancellationToken cancellationToken) - { - var expectedIdentity = action == FileAction.HardlinkCopy - ? ownership.ExistingFile?.PhysicalObjectIdentity - : null; - using var registrationLease = !string.IsNullOrWhiteSpace(expectedIdentity) - ? await _fileMover.PrepareActionForRegistrationAsync( - action, - sourcePath, - destinationPath, - operationId, - expectedIdentity, - expectedSourceProof) - : await _fileMover.PrepareActionForRegistrationAsync( - action, - sourcePath, - destinationPath, - operationId, - expectedRegisteredPhysicalObjectIdentity: null, - expectedSourceProof); - if (registrationLease == null - || _audiobookFileService == null - || !await _audiobookFileService.RegisterPublishedGenerationAsync( - audiobook, - ownership, - registrationLease, - "manual-import-companion", - cancellationToken)) - { - return false; - } - - if (action == FileAction.Move - && !await _fileMover.CompletePreparedMoveAsync( - sourcePath, - destinationPath, - registrationLease, - operationId)) - { - await _audiobookFileService.RollbackPublishedGenerationIfStaleAsync( - audiobook, - registrationLease); - return false; - } - - var completion = registrationLease.CompletePublication(); - if (completion - == RegistrationPublicationCompletion.CommittedCleanupPending) - { - _logger.LogWarning( - "Manual import companion committed for audiobook {AudiobookId}, but registration-publication cleanup remains pending for {Path}", - audiobook.Id, - LogRedaction.SanitizeFilePath(destinationPath)); - } - - return true; - } - private static bool TryResolveCompanionDestination( string sourceRootPath, string destinationRoot, diff --git a/listenarr.api/Features/Downloads/ManualImportController.DirectoryOwnership.cs b/listenarr.api/Features/Downloads/ManualImportController.DirectoryOwnership.cs index cc7bcfa7a..b27089a3c 100644 --- a/listenarr.api/Features/Downloads/ManualImportController.DirectoryOwnership.cs +++ b/listenarr.api/Features/Downloads/ManualImportController.DirectoryOwnership.cs @@ -4,8 +4,8 @@ namespace Listenarr.Api.Features.Downloads; public partial class ManualImportController { - private async Task PrepareOwnedManualImportActionForRegistrationAsync( - FileAction action, + private async Task PrepareOwnedManualImportActionForRegistrationAsync( + FilePublicationPlan publicationPlan, string source, string destination, Audiobook audiobook, @@ -33,35 +33,65 @@ public partial class ManualImportController expectedSourceProof.Validate(); - await _directoryOwnershipStore.EnsureCreatedHierarchyAsync( - destinationDirectory, - boundary, - semantics, - "manual-import", - operationId, - audiobook.Id, - cancellationToken); + if (publicationPlan.Mode + == FilePublicationExecutionMode.AdditiveCopyRetainSource) + { + await _directoryOwnershipStore.EnsureAdditiveHierarchyAsync( + destinationDirectory, + boundary, + semantics, + cancellationToken); + } + else + { + await _directoryOwnershipStore.EnsureCreatedHierarchyAsync( + destinationDirectory, + boundary, + semantics, + "manual-import", + operationId, + audiobook.Id, + cancellationToken); + } cancellationToken.ThrowIfCancellationRequested(); - if (action == FileAction.HardlinkCopy - && !string.IsNullOrWhiteSpace( - expectedRegisteredPhysicalObjectIdentity)) + if (publicationPlan.Mode == FilePublicationExecutionMode.Durable) { - return await _fileMover.PrepareActionForRegistrationAsync( - action, - source, - destination, - operationId, - expectedRegisteredPhysicalObjectIdentity, - expectedSourceProof); + var lease = publicationPlan.EffectiveAction == FileAction.HardlinkCopy + && !string.IsNullOrWhiteSpace( + expectedRegisteredPhysicalObjectIdentity) + ? await _fileMover.PrepareActionForRegistrationAsync( + publicationPlan.EffectiveAction, + source, + destination, + operationId, + expectedRegisteredPhysicalObjectIdentity, + expectedSourceProof) + : await _fileMover.PrepareActionForRegistrationAsync( + publicationPlan.EffectiveAction, + source, + destination, + operationId, + expectedRegisteredPhysicalObjectIdentity: null, + expectedSourceProof); + return new FilePublicationPreparationResult( + lease == null + ? FilePublicationOutcome.Blocked + : FilePublicationOutcome.Success, + publicationPlan.RequestedAction, + publicationPlan.EffectiveAction, + publicationPlan.SourceDisposition, + lease); } - return await _fileMover.PrepareActionForRegistrationAsync( - action, + return await _fileMover.PrepareActionForRegistrationDetailedAsync( + publicationPlan, source, destination, operationId, - expectedRegisteredPhysicalObjectIdentity: null, + publicationPlan.EffectiveAction == FileAction.HardlinkCopy + ? expectedRegisteredPhysicalObjectIdentity + : null, expectedSourceProof); } } diff --git a/listenarr.api/Features/Downloads/ManualImportController.ProcessItem.cs b/listenarr.api/Features/Downloads/ManualImportController.ProcessItem.cs index 26c7fa95b..5b478b4e7 100644 --- a/listenarr.api/Features/Downloads/ManualImportController.ProcessItem.cs +++ b/listenarr.api/Features/Downloads/ManualImportController.ProcessItem.cs @@ -191,13 +191,36 @@ private async Task ImportFileAsync( item.FullPath); } - var destinationReservation = - await destinationTracker.PlanIdempotentOrUniqueAsync( - sourceProof, - destinationPath, - destinationResolution, - cancellationToken); - destinationPath = destinationReservation.Path; + var requestedDestinationPath = destinationPath; + ManualImportDestinationReservation destinationReservation; + AudiobookFileOwnershipCheckResult ownership; + while (true) + { + destinationReservation = + await destinationTracker.PlanIdempotentOrUniqueAsync( + sourceProof, + requestedDestinationPath, + destinationResolution, + cancellationToken); + destinationPath = destinationReservation.Path; + ownership = await _audiobookFileService + .CheckAudiobookFileOwnershipAsync( + audiobook, + destinationPath, + pathPlan.AudiobookBasePath, + cancellationToken); + if (!destinationReservation.ReusesExistingFile + || sourceProof.HasDurablePhysicalObjectIdentity + || ownership.Outcome + != AudiobookFileOwnershipCheckOutcome.Available) + { + break; + } + + // Byte equality is not ownership. Exclude an unowned existing + // pathname and continue planning a new no-overwrite destination. + destinationTracker.Commit(destinationReservation); + } var authoritativeBasePath = pathPlan.AudiobookBasePath; if (string.IsNullOrWhiteSpace(authoritativeBasePath)) { @@ -206,12 +229,6 @@ await destinationTracker.PlanIdempotentOrUniqueAsync( item.FullPath); } - var ownership = await _audiobookFileService - .CheckAudiobookFileOwnershipAsync( - audiobook, - destinationPath, - authoritativeBasePath, - cancellationToken); if (ownership.Outcome is not ( AudiobookFileOwnershipCheckOutcome.Available or AudiobookFileOwnershipCheckOutcome.AlreadyOwnedByAudiobook)) @@ -241,6 +258,32 @@ AudiobookFileOwnershipCheckOutcome.Available or }; } + var publicationPlan = _filePublicationCapabilityResolver == null + ? sourceProof.HasDurablePhysicalObjectIdentity + ? FilePublicationPlan.Durable(action) + : FilePublicationPlan.Additive(action) + : await _filePublicationCapabilityResolver.ResolveAsync( + action, + item.FullPath, + destinationPath, + sourceProof, + cancellationToken); + if (!publicationPlan.IsAllowed) + { + return new ManualImportResultDto + { + Success = false, + Error = publicationPlan.Message, + SourcePath = item.FullPath, + DestinationPath = destinationPath, + Audiobook = audiobook, + RequestedAction = action.ToString(), + EffectiveAction = publicationPlan.EffectiveAction.ToString(), + SourceDisposition = publicationPlan.SourceDisposition.ToString(), + WarningCode = publicationPlan.ReasonCode + }; + } + var operationId = FileMoveOperationIdentity.CreateForPaths( "manual-import", audiobook.Id, @@ -250,9 +293,9 @@ AudiobookFileOwnershipCheckOutcome.Available or sourceProof, destinationPath, destinationSemantics); - using (var registrationLease = + var preparation = await PrepareOwnedManualImportActionForRegistrationAsync( - action, + publicationPlan, item.FullPath, destinationPath, audiobook, @@ -262,15 +305,39 @@ await PrepareOwnedManualImportActionForRegistrationAsync( operationId, ownership.ExistingFile?.PhysicalObjectIdentity, sourceProof, - cancellationToken)) + cancellationToken); + using (var registrationLease = preparation.RegistrationLease) { - if (registrationLease == null - || !await RegisterPublishedManualImportAsync( - audiobook, - ownership, - registrationLease, - authoritativeBasePath, - cancellationToken)) + if (registrationLease == null) + { + return new ManualImportResultDto + { + Success = false, + Error = preparation.Message + ?? "The file could not be published and registered safely.", + SourcePath = item.FullPath, + DestinationPath = destinationPath, + Audiobook = audiobook + }; + } + + var registered = publicationPlan.Mode + == FilePublicationExecutionMode.AdditiveCopyRetainSource + ? await _audiobookFileService + .RegisterCompatibilityPublicationWithBasePathAsync( + audiobook, + ownership, + registrationLease, + authoritativeBasePath, + "manual-import", + cancellationToken) + : await RegisterPublishedManualImportAsync( + audiobook, + ownership, + registrationLease, + authoritativeBasePath, + cancellationToken); + if (!registered) { return new ManualImportResultDto { @@ -282,7 +349,7 @@ await PrepareOwnedManualImportActionForRegistrationAsync( }; } - if (action == FileAction.Move + if (publicationPlan.EffectiveAction == FileAction.Move && !await _fileMover.CompletePreparedMoveAsync( item.FullPath, destinationPath, @@ -303,7 +370,8 @@ await _audiobookFileService }; } - if (!string.IsNullOrWhiteSpace(audiobook.Asin)) + if (registrationLease.HasDurablePhysicalObjectIdentity + && !string.IsNullOrWhiteSpace(audiobook.Asin)) { try { @@ -340,7 +408,12 @@ await _metadataService.WriteAsinTagAsync( Success = true, SourcePath = item.FullPath, DestinationPath = destinationPath, - Audiobook = audiobook + Audiobook = audiobook, + RequestedAction = action.ToString(), + EffectiveAction = publicationPlan.EffectiveAction.ToString(), + SourceDisposition = publicationPlan.SourceDisposition.ToString(), + WarningCode = publicationPlan.ReasonCode, + Warning = publicationPlan.Message }; } catch (Exception ex) when (ex is not OperationCanceledException diff --git a/listenarr.api/Features/Downloads/ManualImportController.cs b/listenarr.api/Features/Downloads/ManualImportController.cs index 05eaa68ad..44b324348 100644 --- a/listenarr.api/Features/Downloads/ManualImportController.cs +++ b/listenarr.api/Features/Downloads/ManualImportController.cs @@ -38,6 +38,8 @@ public partial class ManualImportController : ControllerBase private readonly IRootFolderService _rootFolderService; private readonly IFileMover _fileMover; private readonly IFilePublicationSourceCapability _filePublicationSourceCapability; + private readonly IFilePublicationCapabilityResolver? + _filePublicationCapabilityResolver; private readonly IAudiobookFileService _audiobookFileService; private readonly IFileSystem _fileSystem; private readonly IFileSystemSemanticsResolver _semanticsResolver; @@ -72,7 +74,8 @@ public ManualImportController( ILibraryDirectoryOwnershipStore directoryOwnershipStore, ILibraryFilesystemMutationGate filesystemMutationGate, ManualImportPathPlanner? pathPlanner = null, - ManualImportCompanionImporter? companionImporter = null) + ManualImportCompanionImporter? companionImporter = null, + IFilePublicationCapabilityResolver? filePublicationCapabilityResolver = null) { _logger = logger; _audiobookRepository = audiobookRepository; @@ -88,6 +91,7 @@ public ManualImportController( _fileMover = fileMover; _filePublicationSourceCapability = filePublicationSourceCapability ?? throw new ArgumentNullException(nameof(filePublicationSourceCapability)); + _filePublicationCapabilityResolver = filePublicationCapabilityResolver; _audiobookFileService = audiobookFileService; _fileSystem = fileSystem; _semanticsResolver = semanticsResolver; @@ -107,7 +111,8 @@ public ManualImportController( fileSystem, directoryOwnershipStore, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, - audiobookFileService); + audiobookFileService, + filePublicationCapabilityResolver); } /// @@ -306,6 +311,7 @@ await ExecuteWithAudiobookLocksAsync( sourceSemantics, planningDestinationResolutions, appSettings.ImportBlacklistExtensions, + rootFolders, operationToken); _logger.LogInformation( "Manual import companion-file pass completed with {Count} imported companion file(s)", diff --git a/listenarr.api/Features/Downloads/ManualImportDestinationTracker.cs b/listenarr.api/Features/Downloads/ManualImportDestinationTracker.cs index cc34bdb8c..14a0ca0ef 100644 --- a/listenarr.api/Features/Downloads/ManualImportDestinationTracker.cs +++ b/listenarr.api/Features/Downloads/ManualImportDestinationTracker.cs @@ -138,7 +138,8 @@ private async Task PlanAsync( { return new ManualImportDestinationReservation( desiredDestination, - boundaryKey); + boundaryKey, + ReusesExistingFile: true); } // Use the destination volume's case rules for both in-memory batch collisions @@ -179,4 +180,7 @@ private async Task ExistingMatchesSourceProofAsync( } } -public sealed record ManualImportDestinationReservation(string Path, string BoundaryKey); +public sealed record ManualImportDestinationReservation( + string Path, + string BoundaryKey, + bool ReusesExistingFile = false); diff --git a/listenarr.api/Features/Library/RootFoldersController.Mapping.cs b/listenarr.api/Features/Library/RootFoldersController.Mapping.cs index d1f42ec02..345c8cf27 100644 --- a/listenarr.api/Features/Library/RootFoldersController.Mapping.cs +++ b/listenarr.api/Features/Library/RootFoldersController.Mapping.cs @@ -44,6 +44,7 @@ private async Task MapAsync(RootFolder root) CanChangePath: false, CanReadFilesystem: false, CanScanFilesystem: false, + CanPublishNewFiles: false, CanMutateFilesystem: false, ConfirmationToken: null, root.CreatedAt, @@ -74,6 +75,7 @@ private async Task MapAsync(RootFolder root) storage.CanChangePath && active == null, storage.CanReadFilesystem, storage.CanScanFilesystem, + storage.CanPublishNewFiles, storage.CanMutateFilesystem, storage.ConfirmationToken, root.CreatedAt, diff --git a/listenarr.api/Features/Library/RootFoldersController.cs b/listenarr.api/Features/Library/RootFoldersController.cs index e19c8a8b0..7d497d178 100644 --- a/listenarr.api/Features/Library/RootFoldersController.cs +++ b/listenarr.api/Features/Library/RootFoldersController.cs @@ -38,6 +38,7 @@ public sealed record RootFolderDto( bool CanChangePath, bool CanReadFilesystem, bool CanScanFilesystem, + bool CanPublishNewFiles, bool CanMutateFilesystem, string? ConfirmationToken, DateTime CreatedAt, diff --git a/listenarr.api/Features/Prowlarr/ProwlarrImportUrlPlanner.cs b/listenarr.api/Features/Prowlarr/ProwlarrImportUrlPlanner.cs index 5a6bfa852..6ffa8c977 100644 --- a/listenarr.api/Features/Prowlarr/ProwlarrImportUrlPlanner.cs +++ b/listenarr.api/Features/Prowlarr/ProwlarrImportUrlPlanner.cs @@ -43,6 +43,28 @@ public static string BuildProxyUrl(string baseUrl, int indexerId) return $"{root}/{indexerId}/api"; } + /// + /// Return the base URL the Prowlarr API actually answered on. A Prowlarr instance running under a + /// URL base redirects the discovery request onto that base, so the URL the user supplied can be + /// missing a path segment that every proxied indexer URL needs. + /// + public static string ResolveBaseUrlFromDiscovery(string requestedBaseUrl, Uri? discoveryUri, string discoveryPath) + { + if (discoveryUri == null || !discoveryUri.IsAbsoluteUri) + { + return requestedBaseUrl; + } + + var answered = discoveryUri.GetLeftPart(UriPartial.Path).TrimEnd('/'); + if (!answered.EndsWith(discoveryPath, StringComparison.OrdinalIgnoreCase)) + { + return requestedBaseUrl; + } + + var resolved = answered.Substring(0, answered.Length - discoveryPath.Length).TrimEnd('/'); + return string.IsNullOrEmpty(resolved) ? requestedBaseUrl : resolved; + } + public static string NormalizeProxyUrl(string? rawUrl) { if (string.IsNullOrWhiteSpace(rawUrl)) return rawUrl ?? string.Empty; diff --git a/listenarr.api/Features/Prowlarr/ProwlarrIndexerImportWorkflow.cs b/listenarr.api/Features/Prowlarr/ProwlarrIndexerImportWorkflow.cs index 01304801e..2d0e8a717 100644 --- a/listenarr.api/Features/Prowlarr/ProwlarrIndexerImportWorkflow.cs +++ b/listenarr.api/Features/Prowlarr/ProwlarrIndexerImportWorkflow.cs @@ -16,6 +16,8 @@ namespace Listenarr.Api.Features.Prowlarr { public sealed class ProwlarrIndexerImportWorkflow { + private const string IndexerDiscoveryPath = "/api/v1/indexer"; + private readonly IIndexerRepository _indexerRepository; private readonly IConfigurationService _configurationService; private readonly HttpClient _httpClientNoRedirect; @@ -67,9 +69,10 @@ public async Task ImportAsync(ProwlarrImpor HttpResponseMessage response; string payload; + Uri? discoveryUri; try { - (response, payload) = await FetchProwlarrIndexersAsync(baseUrl, effectiveApiKey.Trim()); + (response, payload, discoveryUri) = await FetchProwlarrIndexersAsync(baseUrl, effectiveApiKey.Trim()); } catch (HttpRequestException ex) { @@ -103,6 +106,14 @@ public async Task ImportAsync(ProwlarrImpor return ProwlarrIndexerImportWorkflowResult.UpstreamError("Unexpected Prowlarr API response", StatusCodes.Status502BadGateway); } + var effectiveBaseUrl = ProwlarrImportUrlPlanner.ResolveBaseUrlFromDiscovery(baseUrl, discoveryUri, IndexerDiscoveryPath); + if (!string.Equals(effectiveBaseUrl, baseUrl, StringComparison.Ordinal)) + { + _logger.LogInformation( + "Prowlarr answered on {Url} after a redirect; using it as the indexer proxy base", + LogRedaction.SanitizeUrl(effectiveBaseUrl)); + } + await _configurationService.SaveProwlarrImportSettingsAsync(new ProwlarrImportConnectionSettings { Url = effectiveUrl, @@ -118,7 +129,7 @@ await _configurationService.SaveProwlarrImportSettingsAsync(new ProwlarrImportCo if (!string.IsNullOrWhiteSpace(effectiveTagFilter)) { - tagMap = await TryFetchProwlarrTagMapAsync(baseUrl, effectiveApiKey.Trim()); + tagMap = await TryFetchProwlarrTagMapAsync(effectiveBaseUrl, effectiveApiKey.Trim()); if ((tagMap == null || tagMap.Count == 0) && ProwlarrIndexerPayloadParser.PayloadRequiresTagMap(doc.RootElement)) { _logger.LogWarning( @@ -162,7 +173,7 @@ await _configurationService.SaveProwlarrImportSettingsAsync(new ProwlarrImportCo : string.Empty; var implementation = protocol.Equals("usenet", StringComparison.OrdinalIgnoreCase) ? "Newznab" : "Torznab"; - var proxyUrl = ProwlarrImportUrlPlanner.BuildProxyUrl(baseUrl, indexerId); + var proxyUrl = ProwlarrImportUrlPlanner.BuildProxyUrl(effectiveBaseUrl, indexerId); var normalizedUrl = ProwlarrImportUrlPlanner.NormalizeProxyUrl(proxyUrl); var exists = existingIndexers.FirstOrDefault(i => @@ -217,15 +228,15 @@ private ProwlarrIndexerImportWorkflowResult BuildProwlarrApiFailure(string baseU return ProwlarrIndexerImportWorkflowResult.UpstreamError("Failed to reach Prowlarr API", StatusCodes.Status502BadGateway); } - private async Task<(HttpResponseMessage Response, string Payload)> FetchProwlarrIndexersAsync(string baseUrl, string apiKey) + private async Task<(HttpResponseMessage Response, string Payload, Uri? FinalUri)> FetchProwlarrIndexersAsync(string baseUrl, string apiKey) { var encodedKey = WebUtility.UrlEncode(apiKey); // NOTE: This targets external Prowlarr instances, whose API path is /api/v1. // It is intentionally independent from Listenarr's own API version segment. var endpoints = new List { - $"{baseUrl}/api/v1/indexer", - $"{baseUrl}/api/v1/indexer?apikey={encodedKey}" + $"{baseUrl}{IndexerDiscoveryPath}", + $"{baseUrl}{IndexerDiscoveryPath}?apikey={encodedKey}" }; HttpResponseMessage? lastResponse = null; @@ -233,7 +244,7 @@ private ProwlarrIndexerImportWorkflowResult BuildProwlarrApiFailure(string baseU foreach (var endpoint in endpoints) { - var response = await SendValidatedAsync(currentUri => + var (response, finalUri) = await SendValidatedAsync(currentUri => { var retryRequest = new HttpRequestMessage(HttpMethod.Get, currentUri); retryRequest.Headers.Add("X-Api-Key", apiKey); @@ -243,7 +254,7 @@ private ProwlarrIndexerImportWorkflowResult BuildProwlarrApiFailure(string baseU if (response.IsSuccessStatusCode) { - return (response, body); + return (response, body, finalUri); } lastResponse?.Dispose(); @@ -258,7 +269,7 @@ private ProwlarrIndexerImportWorkflowResult BuildProwlarrApiFailure(string baseU } } - return (lastResponse ?? new HttpResponseMessage(HttpStatusCode.BadGateway), lastPayload); + return (lastResponse ?? new HttpResponseMessage(HttpStatusCode.BadGateway), lastPayload, null); } private async Task?> TryFetchProwlarrTagMapAsync(string baseUrl, string apiKey) @@ -274,13 +285,14 @@ private ProwlarrIndexerImportWorkflowResult BuildProwlarrApiFailure(string baseU foreach (var endpoint in endpoints) { - using var response = await SendValidatedAsync(currentUri => + var (tagResponse, _) = await SendValidatedAsync(currentUri => { var retryRequest = new HttpRequestMessage(HttpMethod.Get, currentUri); retryRequest.Headers.Add("X-Api-Key", apiKey); return retryRequest; }, endpoint); + using var response = tagResponse; var body = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { @@ -345,14 +357,14 @@ private ProwlarrIndexerImportWorkflowResult BuildProwlarrApiFailure(string baseU return null; } - private async Task SendValidatedAsync( + private async Task<(HttpResponseMessage Response, Uri FinalUri)> SendValidatedAsync( Func requestFactory, string url, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken cancellationToken = default) { var uri = new Uri(url); - var (response, _) = await OutboundRequestSecurity.SendWithValidatedRedirectsAsync( + return await OutboundRequestSecurity.SendWithValidatedRedirectsAsync( requestFactory, uri, _httpClientNoRedirect, @@ -360,7 +372,6 @@ private async Task SendValidatedAsync( allowPrivateTargets: true, completionOption: completionOption, cancellationToken: cancellationToken); - return response; } } diff --git a/listenarr.application/Audiobooks/Contracts/IAudiobookFileService.cs b/listenarr.application/Audiobooks/Contracts/IAudiobookFileService.cs index 3e27c4842..8d48dd1ae 100644 --- a/listenarr.application/Audiobooks/Contracts/IAudiobookFileService.cs +++ b/listenarr.application/Audiobooks/Contracts/IAudiobookFileService.cs @@ -122,6 +122,21 @@ Task RegisterPublishedGenerationWithBasePathAsync( string? source = "scan", CancellationToken cancellationToken = default); + Task RegisterCompatibilityPublicationAsync( + Audiobook audiobook, + AudiobookFileOwnershipCheckResult initialOwnership, + IAudiobookFileRegistrationLease registrationLease, + string? source = "scan", + CancellationToken cancellationToken = default); + + Task RegisterCompatibilityPublicationWithBasePathAsync( + Audiobook audiobook, + AudiobookFileOwnershipCheckResult initialOwnership, + IAudiobookFileRegistrationLease registrationLease, + string authoritativeBasePath, + string? source = "scan", + CancellationToken cancellationToken = default); + Task RollbackPublishedGenerationIfStaleAsync( Audiobook audiobook, IAudiobookFileRegistrationLease registrationLease); diff --git a/listenarr.application/Audiobooks/Contracts/IAudiobookMetadataRefreshService.cs b/listenarr.application/Audiobooks/Contracts/IAudiobookMetadataRefreshService.cs new file mode 100644 index 000000000..05bf110db --- /dev/null +++ b/listenarr.application/Audiobooks/Contracts/IAudiobookMetadataRefreshService.cs @@ -0,0 +1,35 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Application.Audiobooks.Contracts +{ + /// + /// Fetches upstream metadata for an audiobook using its ASIN and fills in fields that are + /// currently empty. Used to auto-populate a book immediately after a scan discovers an ASIN + /// embedded in the file, so the user doesn't have to separately click "Rescan Metadata". + /// Existing (non-empty) fields are never overwritten. + /// + public interface IAudiobookMetadataRefreshService + { + /// + /// Populates missing metadata on from its ASIN. + /// Returns true if any field was filled in (and the audiobook was saved). + /// + Task TryPopulateMissingMetadataAsync(Audiobook audiobook, string? region = null, CancellationToken cancellationToken = default); + } +} diff --git a/listenarr.application/Audiobooks/Contracts/ILibraryDirectoryOwnershipStore.cs b/listenarr.application/Audiobooks/Contracts/ILibraryDirectoryOwnershipStore.cs index d931d493a..bab3fb2aa 100644 --- a/listenarr.application/Audiobooks/Contracts/ILibraryDirectoryOwnershipStore.cs +++ b/listenarr.application/Audiobooks/Contracts/ILibraryDirectoryOwnershipStore.cs @@ -45,6 +45,20 @@ Task> EnsureCreatedHierarchyAsync( int? audiobookId = null, CancellationToken cancellationToken = default); + /// + /// Creates only missing directory components beneath an already managed + /// boundary. This additive-only operation deliberately records no cleanup + /// ownership and therefore never authorizes later directory deletion. + /// + Task EnsureAdditiveHierarchyAsync( + string destinationDirectory, + string managedBoundary, + FileSystemPathSemantics semantics, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException( + "Additive-only directory publication is unavailable.")); + Task ResolveOwnedAsync( string path, FileSystemPathSemantics semantics, diff --git a/listenarr.application/Audiobooks/Contracts/IRootFolderStorageHealthResolver.cs b/listenarr.application/Audiobooks/Contracts/IRootFolderStorageHealthResolver.cs index 3d3205bb0..3bf531194 100644 --- a/listenarr.application/Audiobooks/Contracts/IRootFolderStorageHealthResolver.cs +++ b/listenarr.application/Audiobooks/Contracts/IRootFolderStorageHealthResolver.cs @@ -37,7 +37,8 @@ public sealed record RootFolderStorageObservation( bool CanChangePath, bool CanMutateFilesystem, string? ConfirmationToken, - string? Detail = null) + string? Detail = null, + bool CanPublishNewFiles = false) { public bool CanReadFilesystem => State is RootFolderStorageState.Healthy or RootFolderStorageState.Limited; diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.ClaimDiagnostics.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.ClaimDiagnostics.cs new file mode 100644 index 000000000..c6928a703 --- /dev/null +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.ClaimDiagnostics.cs @@ -0,0 +1,57 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Domain.Common; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Audiobooks.Files +{ + // Diagnostics helpers shared by the registration paths. Split out of + // AudiobookFileService.cs to keep that file under the size cap the + // architecture tests enforce. + public partial class AudiobookFileService + { + private static string ResolveAbsolutePath(string? path) => + string.IsNullOrWhiteSpace(path) + ? string.Empty + : FileSystemPathIdentity.ResolveNativeAbsolutePath(path); + + private void LogClaimRejection( + int audiobookId, + string path, + AudiobookFileClaimResult claim) + { + var sanitizedPath = LogRedaction.SanitizeFilePath(path); + if (claim.Outcome == AudiobookFileClaimOutcome.AlreadyOwnedByAudiobook) + { + logger.LogDebug( + "AudiobookFile already exists for audiobook {AudiobookId} at path {Path}", + audiobookId, + sanitizedPath); + return; + } + + logger.LogWarning( + "Audiobook file ownership claim rejected for audiobook {AudiobookId} at {Path}: {Outcome}. {Reason}", + audiobookId, + sanitizedPath, + claim.Outcome, + claim.Reason); + } + + } +} diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.IdentifierAdoption.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.IdentifierAdoption.cs new file mode 100644 index 000000000..b3954e636 --- /dev/null +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.IdentifierAdoption.cs @@ -0,0 +1,196 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Audiobooks.Files +{ + public partial class AudiobookFileService + { + /// + /// Copies identifiers found in a scanned file's embedded tags (ASIN, ISBN) onto the + /// audiobook when it doesn't already have them. This lets "Rescan Metadata" resolve + /// upstream metadata for files imported with an embedded ASIN, without the user having to + /// type the identifier by hand. Existing identifiers are never overwritten. + /// + /// Runs inside the per-audiobook operation lock (called from EnsureAudiobookFileCoreAsync), + /// so the identifier write cannot race file ownership or a concurrent audiobook update. + /// Returns true when a NEW identifier was adopted, so the caller can trigger the upstream + /// metadata refresh AFTER the lock is released -- that network lookup must not hold the lock. + /// + private async Task AdoptFileIdentifiersAsync( + Audiobook audiobook, + AudioMetadata? meta, + string filePath, + CancellationToken cancellationToken) + { + if (meta == null) + { + return false; + } + + var changed = false; + + if (string.IsNullOrWhiteSpace(audiobook.Asin)) + { + // Only adopt when every file linked to this audiobook that carries an ASIN carries the + // same one. Disagreement means a file was likely mis-attributed to this book, and a + // wrongly linked file must not be allowed to donate its identifier (which the metadata + // auto-refresh would then act on). Returns null on conflict, so nothing is adopted. + var agreedAsin = await ResolveUnanimousFileAsinAsync(audiobook, meta, cancellationToken); + if (!string.IsNullOrWhiteSpace(agreedAsin)) + { + audiobook.Asin = agreedAsin; + changed = true; + } + } + + // ISBN, like ASIN, is only adopted when the book has none -- never appended on top of an + // existing set, so a mis-attributed file cannot accumulate a stray identifier. + if ((audiobook.Isbn == null || audiobook.Isbn.Count == 0) && !string.IsNullOrWhiteSpace(meta.Isbn)) + { + audiobook.Isbn = new List { meta.Isbn.Trim() }; + changed = true; + } + + if (!changed) + { + return false; + } + + try + { + await audiobookRepository.UpdateAsync(audiobook); + logger.LogInformation( + "Adopted identifiers from file tags for audiobook {AudiobookId} (ASIN set: {HasAsin})", + audiobook.Id, + !string.IsNullOrWhiteSpace(audiobook.Asin)); + + await historyRepository.AddAsync(new History + { + AudiobookId = audiobook.Id, + AudiobookTitle = audiobook.Title ?? "Unknown", + EventType = "Identifier Added", + Message = "Identifier read from embedded file tags during scan", + Source = "Scan", + Data = JsonSerializer.Serialize(new { audiobook.Asin, Isbn = audiobook.Isbn, FilePath = filePath }), + Timestamp = DateTime.UtcNow + }); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Failed to persist adopted file identifiers for audiobook {AudiobookId}", audiobook.Id); + return false; + } + + return true; + } + + /// + /// Returns the single ASIN shared by every ASIN-carrying file linked to this audiobook + /// (plus the file currently being processed), or null when there is none or when the files + /// disagree. A disagreement is treated as a sign the file-to-book attribution is wrong, so + /// no identifier is adopted rather than picking one arbitrarily. + /// + /// Limitation: the agreement set is only the files linked at the moment this runs. On a + /// first scan the first tagged file to arrive is "unanimous" simply by being the sole + /// member, so the guard is weakest exactly when a fresh mis-attribution is most likely. + /// It still refuses once a second, disagreeing file appears; it cannot retroactively + /// un-adopt an ASIN taken from a lone early file that later proves to be the odd one out. + /// + private async Task ResolveUnanimousFileAsinAsync( + Audiobook audiobook, + AudioMetadata currentMeta, + CancellationToken cancellationToken) + { + var asins = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrWhiteSpace(currentMeta.Asin)) + { + asins.Add(currentMeta.Asin.Trim()); + } + + List linkedFiles; + try + { + linkedFiles = await audiobookFileRepository.GetByAudiobookIdAsync(audiobook.Id, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogDebug(ex, "Could not load linked files to verify ASIN agreement for audiobook {AudiobookId}", audiobook.Id); + return asins.Count == 1 ? asins.First() : null; + } + + foreach (var linked in linkedFiles) + { + var path = linked.Path; + if (string.IsNullOrWhiteSpace(path)) + { + continue; + } + + if (!Path.IsPathRooted(path) && !string.IsNullOrWhiteSpace(audiobook.BasePath)) + { + path = Path.Combine(audiobook.BasePath, path); + } + + var meta = await ExtractMetadataAsync(path, path, path); + if (!string.IsNullOrWhiteSpace(meta?.Asin)) + { + asins.Add(meta!.Asin!.Trim()); + } + } + + if (asins.Count > 1) + { + logger.LogInformation( + "Not adopting an ASIN for audiobook {AudiobookId}: its linked files carry {Count} distinct ASINs, so attribution is uncertain", + audiobook.Id, + asins.Count); + return null; + } + + return asins.Count == 1 ? asins.First() : null; + } + + /// + /// Fetches upstream metadata for a book whose ASIN was just adopted from a file tag and + /// fills in only the fields that are still empty. Runs AFTER the operation lock is released + /// (see AdoptFileIdentifiersAsync) so the network lookup never holds the filesystem lock. + /// Best-effort: never throws into the scan. + /// + private async Task RefreshMetadataAfterAdoptionAsync(int audiobookId, CancellationToken cancellationToken) + { + try + { + var refreshed = await audiobookRepository.GetByIdSnapshotAsync(audiobookId, cancellationToken); + if (refreshed == null || string.IsNullOrWhiteSpace(refreshed.Asin)) + { + return; + } + + await metadataRefreshService.TryPopulateMissingMetadataAsync( + refreshed, + cancellationToken: cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Auto metadata refresh after identifier adoption failed for audiobook {AudiobookId}", audiobookId); + } + } + } +} diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs index aafb6dbcb..a561b0643 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs @@ -5,6 +5,44 @@ namespace Listenarr.Application.Audiobooks.Files; public partial class AudiobookFileService { + /// + /// The byte length of the file a registration lease holds open. + /// + /// + /// A lease's metadata path is not always a path to the file. On Linux it is a + /// /proc/{pid}/fd/{fd} descriptor link, and stat'ing the link reports the length of + /// the link itself rather than of its target, which is a constant 64 bytes. Reading the + /// length from the pinned handle keeps the generation guarantee the lease exists to + /// provide, since it never consults the visible path, and reports the bytes the file + /// actually has. + /// + /// Leases that do not expose generation-bound reads fall back to the metadata path, which + /// is the public path for those callers. + /// + private static long? ResolveRegisteredLength( + IAudiobookFileRegistrationLease? registrationLease, + string metadataPath) + { + if (registrationLease != null) + { + try + { + using var stream = registrationLease.OpenMetadataReadStream(); + if (stream.CanSeek) + { + return stream.Length; + } + } + catch (NotSupportedException) + { + // The lease does not expose generation-bound reads; fall through to the path. + } + } + + var fileInfo = new FileInfo(metadataPath); + return fileInfo.Exists ? fileInfo.Length : null; + } + private async Task ExtractMetadataAsync( string metadataPath, string cacheIdentity, diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs index 3dad98b99..3a66b7095 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs @@ -274,10 +274,11 @@ private static AudiobookFile CreatePhysicalGenerationSnapshot( string? source, bool replaceMetadata) { - var fileInfo = new FileInfo(registrationLease.MetadataPath); var replacement = AudiobookFile.CreateUnresolved(currentFile.Path); replacement.AudiobookId = currentFile.AudiobookId; - replacement.Size = fileInfo.Exists ? fileInfo.Length : currentFile.Size; + replacement.Size = ResolveRegisteredLength( + registrationLease, + registrationLease.MetadataPath) ?? currentFile.Size; replacement.DurationSeconds = replaceMetadata ? metadata?.Duration.TotalSeconds : Math.Abs(metadata?.Duration.TotalSeconds ?? 0) > double.Epsilon @@ -429,9 +430,13 @@ private static AudiobookFile ClonePhysicalGeneration(AudiobookFile source) if (!string.IsNullOrWhiteSpace(source.PhysicalObjectIdentity) && source.PhysicalIdentityObservedAtUtc.HasValue) { + // The source row may have been materialized from the database, where + // the UTC-by-contract observation time round-trips as Unspecified. clone.ApplyPhysicalObjectIdentity( source.PhysicalObjectIdentity, - source.PhysicalIdentityObservedAtUtc.Value); + DateTime.SpecifyKind( + source.PhysicalIdentityObservedAtUtc.Value, + DateTimeKind.Utc)); } return clone; diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.Registration.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.Registration.cs index f10a7bf81..5ac6a566c 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.Registration.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.Registration.cs @@ -45,6 +45,109 @@ public Task RegisterPublishedGenerationWithBasePathAsync( cancellationToken); } + public Task RegisterCompatibilityPublicationAsync( + Audiobook audiobook, + AudiobookFileOwnershipCheckResult initialOwnership, + IAudiobookFileRegistrationLease registrationLease, + string? source = "scan", + CancellationToken cancellationToken = default) => + RegisterCompatibilityPublicationCoreAsync( + audiobook, + initialOwnership, + registrationLease, + authoritativeBasePath: null, + source, + cancellationToken); + + public Task RegisterCompatibilityPublicationWithBasePathAsync( + Audiobook audiobook, + AudiobookFileOwnershipCheckResult initialOwnership, + IAudiobookFileRegistrationLease registrationLease, + string authoritativeBasePath, + string? source = "scan", + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(authoritativeBasePath); + return RegisterCompatibilityPublicationCoreAsync( + audiobook, + initialOwnership, + registrationLease, + FileUtils.NormalizeStoredPath(authoritativeBasePath), + source, + cancellationToken); + } + + private async Task RegisterCompatibilityPublicationCoreAsync( + Audiobook audiobook, + AudiobookFileOwnershipCheckResult initialOwnership, + IAudiobookFileRegistrationLease registrationLease, + string? authoritativeBasePath, + string? source, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(audiobook); + ArgumentNullException.ThrowIfNull(registrationLease); + if (registrationLease.HasDurablePhysicalObjectIdentity) + { + throw new InvalidOperationException( + "Compatibility registration accepts path-only publication leases only."); + } + if (!registrationLease.MatchesCurrentPublication() + || !registrationLease.PrepareCleanupRecovery(audiobook.Id)) + { + return false; + } + + BasePathRegistrationOutcome registration; + switch (initialOwnership.Outcome) + { + case AudiobookFileOwnershipCheckOutcome.Available: + registration = authoritativeBasePath == null + ? new BasePathRegistrationOutcome( + await EnsureAudiobookFileAsync( + audiobook, + registrationLease, + source, + cancellationToken), + null) + : await EnsureAudiobookFileWithBasePathAsync( + audiobook, + registrationLease, + authoritativeBasePath, + source, + cancellationToken); + break; + + case AudiobookFileOwnershipCheckOutcome.AlreadyOwnedByAudiobook: + if (!string.IsNullOrWhiteSpace( + initialOwnership.ExistingFile?.PhysicalObjectIdentity)) + { + return false; + } + + registration = authoritativeBasePath == null + ? new BasePathRegistrationOutcome(true, null) + : await ApplyAuthoritativeBasePathAsync( + audiobook.Id, + authoritativeBasePath, + cancellationToken); + break; + + default: + return false; + } + + if (!registration.Success) + { + return false; + } + + ApplyCommittedBasePath(audiobook, registration.Mutation); + var completion = registrationLease.CompletePublication(); + return completion is RegistrationPublicationCompletion.Completed + or RegistrationPublicationCompletion.CommittedCleanupPending; + } + private async Task RegisterPublishedGenerationCoreAsync( Audiobook audiobook, AudiobookFileOwnershipCheckResult initialOwnership, diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs index 823fe2f2c..11afa82e9 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs @@ -16,6 +16,7 @@ * along with this program. If not, see . */ using Microsoft.Extensions.Caching.Memory; +using System.Runtime.CompilerServices; using System.Text.Json; using Listenarr.Application.Common; using Listenarr.Domain.Common; @@ -30,6 +31,7 @@ public partial class AudiobookFileService( IAudiobookFileRepository audiobookFileRepository, IHistoryRepository historyRepository, IMetadataService metadataService, + IAudiobookMetadataRefreshService metadataRefreshService, IToastService toastService, IFfmpegService ffmpegService, IFileSystem fileSystem, @@ -100,7 +102,7 @@ private async Task success ? context.Mutation : null); } - private Task EnsureAudiobookFileAsync( + private async Task EnsureAudiobookFileAsync( Audiobook audiobook, string filePath, IAudiobookFileRegistrationLease? registrationLease, @@ -110,7 +112,12 @@ private Task EnsureAudiobookFileAsync( CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(audiobook); - return filesystemMutationCoordinator.ExecuteExclusiveAsync( + + // Set true (inside the lock) when a scan just adopted a brand-new identifier onto a + // previously bare book, so the upstream metadata lookup can run AFTER the lock releases. + var adoptedIdentifier = new StrongBox(false); + + var result = await filesystemMutationCoordinator.ExecuteExclusiveAsync( globalToken => audiobookOperationCoordinator.ExecuteExclusiveAsync( audiobook.Id, async token => @@ -144,10 +151,20 @@ await moveQueueService.EnsureFilesystemMutationAllowedAsync( registrationLease, basePathMutation, source, + adoptedIdentifier, token); }, globalToken), cancellationToken); + + // The upstream metadata lookup must not run while the global filesystem lock is held, + // so it happens here, after the lock is released. Fills empty fields only. + if (result && adoptedIdentifier.Value) + { + await RefreshMetadataAfterAdoptionAsync(audiobook.Id, cancellationToken); + } + + return result; } private async Task EnsureAudiobookFileCoreAsync( @@ -156,6 +173,7 @@ private async Task EnsureAudiobookFileCoreAsync( IAudiobookFileRegistrationLease? registrationLease, AudiobookBasePathMutation? basePathMutation, string? source, + StrongBox adoptedIdentifierSignal, CancellationToken cancellationToken) { try @@ -327,10 +345,11 @@ private async Task EnsureAudiobookFileCoreAsync( cacheIdentity, filePath); - var fi = new FileInfo(metadataPath); var fileRecord = AudiobookFile.CreateUnresolved(filePath); fileRecord.AudiobookId = audiobook.Id; - fileRecord.Size = fi.Exists ? fi.Length : null; + fileRecord.Size = ResolveRegisteredLength( + registrationLease, + metadataPath); fileRecord.Source = source; fileRecord.CreatedAt = DateTime.UtcNow; fileRecord.DurationSeconds = meta?.Duration.TotalSeconds; @@ -416,6 +435,21 @@ await DeleteCreatedPhysicalGenerationAsync( logger.LogDebug(hx, "Failed to create history entry for added audiobook file {Path}", LogRedaction.SanitizeFilePath(filePath)); } + // Adopt an ASIN/ISBN embedded in the just-registered file onto a bare book, + // inside the operation lock. Signals the caller to run the upstream metadata + // refresh after the lock releases. Best-effort: never fails the registration. + try + { + if (await AdoptFileIdentifiersAsync(audiobook, meta, filePath, cancellationToken)) + { + adoptedIdentifierSignal.Value = true; + } + } + catch (Exception adoptEx) when (adoptEx is not OperationCanceledException && adoptEx is not OutOfMemoryException && adoptEx is not StackOverflowException) + { + logger.LogDebug(adoptEx, "Identifier adoption failed for added audiobook file {Path}", LogRedaction.SanitizeFilePath(filePath)); + } + return true; } catch (PersistenceException dbEx) @@ -437,33 +471,5 @@ await DeleteCreatedPhysicalGenerationAsync( } } - private static string ResolveAbsolutePath(string? path) => - string.IsNullOrWhiteSpace(path) - ? string.Empty - : FileSystemPathIdentity.ResolveNativeAbsolutePath(path); - - private void LogClaimRejection( - int audiobookId, - string path, - AudiobookFileClaimResult claim) - { - var sanitizedPath = LogRedaction.SanitizeFilePath(path); - if (claim.Outcome == AudiobookFileClaimOutcome.AlreadyOwnedByAudiobook) - { - logger.LogDebug( - "AudiobookFile already exists for audiobook {AudiobookId} at path {Path}", - audiobookId, - sanitizedPath); - return; - } - - logger.LogWarning( - "Audiobook file ownership claim rejected for audiobook {AudiobookId} at {Path}: {Outcome}. {Reason}", - audiobookId, - sanitizedPath, - claim.Outcome, - claim.Reason); - } - } } diff --git a/listenarr.application/Audiobooks/Quality/QualityProfileService.cs b/listenarr.application/Audiobooks/Quality/QualityProfileService.cs index 3097a5060..eaab906c0 100644 --- a/listenarr.application/Audiobooks/Quality/QualityProfileService.cs +++ b/listenarr.application/Audiobooks/Quality/QualityProfileService.cs @@ -190,9 +190,15 @@ private async Task UnsetAllDefaultsAsync() } } - public async Task ScoreSearchResult(SearchResult searchResult, QualityProfile profile) + public Task ScoreSearchResult(SearchResult searchResult, QualityProfile profile) => + ScoreSearchResult(searchResult, profile, resolvedIndexers: null); + + private async Task ScoreSearchResult( + SearchResult searchResult, + QualityProfile profile, + IReadOnlyDictionary? resolvedIndexers) { - var scorer = new SearchResultScorer(_indexerRepository, _logger); + var scorer = new SearchResultScorer(_indexerRepository, _logger, resolvedIndexers); var score = await scorer.Score(searchResult, profile); // Also calculate the Prowlarr-style composite (Smart) score so the UI @@ -287,7 +293,15 @@ private int GetQualityScore(string? quality) public async Task> ScoreSearchResults(List searchResults, QualityProfile profile) { - var scores = await Task.WhenAll(searchResults.Select(result => ScoreSearchResult(result, profile))); + // Resolve every indexer this batch refers to before fanning out, not inside it. + // Scoring runs in parallel and the scorer reads indexer retention per result, so a + // per-result lookup meant N concurrent queries against one scoped DbContext. EF + // rejects the overlap, the scorer catches it and logs at Debug, and the result keeps + // a retention of 0 with Usenet detection skipped. The scores come out quietly wrong + // rather than the request failing. + var resolvedIndexers = await ResolveIndexersAsync(searchResults); + var scores = await Task.WhenAll( + searchResults.Select(result => ScoreSearchResult(result, profile, resolvedIndexers))); // Ensure rejected results are ordered last regardless of numeric TotalScore return scores @@ -296,6 +310,43 @@ public async Task> ScoreSearchResults(List sear .ToList(); } + private async Task> ResolveIndexersAsync( + List searchResults) + { + var resolved = new Dictionary(); + if (_indexerRepository == null) + { + return resolved; + } + + // Sequential and de-duplicated: a batch usually refers to a handful of indexers even + // when it carries hundreds of results, so this is fewer queries than before as well as + // non-overlapping ones. + foreach (var indexerId in searchResults + .Where(result => result.IndexerId.HasValue) + .Select(result => result.IndexerId!.Value) + .Distinct()) + { + try + { + var indexer = await _indexerRepository.GetByIdAsync(indexerId); + if (indexer != null) + { + resolved[indexerId] = indexer; + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning( + ex, + "Failed to resolve indexer {IndexerId} while scoring a search batch; retention and Usenet detection will be skipped for its results", + indexerId); + } + } + + return resolved; + } + /// /// Checks if a quality string contains VBR preset indicators (v0, v1, v2). /// diff --git a/listenarr.application/Common/FileNamingService.Helpers.cs b/listenarr.application/Common/FileNamingService.Helpers.cs index 6241a17d7..715ded1bb 100644 --- a/listenarr.application/Common/FileNamingService.Helpers.cs +++ b/listenarr.application/Common/FileNamingService.Helpers.cs @@ -86,7 +86,11 @@ private Dictionary BuildVariables(AudioMetadata metadata) { "Publisher", string.IsNullOrWhiteSpace(metadata.Publisher) ? string.Empty : SanitizePathComponent(metadata.Publisher) }, { "Language", string.IsNullOrWhiteSpace(metadata.Language) ? string.Empty : SanitizePathComponent(metadata.Language) }, { "Asin", string.IsNullOrWhiteSpace(metadata.Asin) ? string.Empty : SanitizePathComponent(metadata.Asin) }, - { "SeriesNumber", FirstNonEmpty(metadata.SeriesPosition?.ToString(CultureInfo.InvariantCulture), metadata.TrackNumber?.ToString()) }, + // Prefer the position exactly as the source gave it. A non-numeric but real + // position (an omnibus at "1-4") does not survive the decimal parse, and + // falling through to TrackNumber here would write a track number into the + // filename as if it were the series number. + { "SeriesNumber", FirstNonEmpty(metadata.SeriesPositionRaw, metadata.SeriesPosition?.ToString(CultureInfo.InvariantCulture), metadata.TrackNumber?.ToString()) }, { "Year", FirstNonEmpty(metadata.Year?.ToString()) }, { "Quality", FirstNonEmpty(metadata.BitRate.HasValue ? metadata.BitRate + "kbps" : null, metadata.Format) }, { "DiskNumber", metadata.DiscNumber?.ToString() ?? string.Empty }, diff --git a/listenarr.application/Downloads/Common/DownloadClientGateway.cs b/listenarr.application/Downloads/Common/DownloadClientGateway.cs index ee1057f4e..80adcacfe 100644 --- a/listenarr.application/Downloads/Common/DownloadClientGateway.cs +++ b/listenarr.application/Downloads/Common/DownloadClientGateway.cs @@ -93,7 +93,11 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli { var adapter = ResolveAdapter(client); var items = await adapter.GetQueueAsync(client, ct); - var tasks = items.Select(item => TranslateQueueItemPathsAsync(client, item)); + // Resolved once for the batch. Translating each item used to query for the client's + // mappings itself, inside this fan-out, against a scoped repository shared by everything + // else in the scope. + var mappings = await remotePathMappingService.GetPathMappingByClientAsync(client); + var tasks = items.Select(item => TranslateQueueItemPathsAsync(mappings, client, item)); return [.. await Task.WhenAll(tasks)]; } @@ -137,7 +141,9 @@ public async Task GetQueueItemAsync( var adapter = ResolveAdapter(client); var item = await adapter.GetImportItemAsync(client, download, queueItem, null, ct); - return await TranslateQueueItemPathsAsync(client, item); + // Single item, so the lookup here is one query either way. + var mappings = await remotePathMappingService.GetPathMappingByClientAsync(client); + return await TranslateQueueItemPathsAsync(mappings, client, item); } public async Task> FetchDownloadsAsync(DownloadClientConfiguration client, List downloads, CancellationToken ct = default) @@ -165,7 +171,8 @@ public async Task> FetchDownloadsAsync(DownloadClientConfiguratio ex); } - var tasks = items.Select(item => TranslateQueueItemPathsAsync(client, item)); + var mappings = await remotePathMappingService.GetPathMappingByClientAsync(client); + var tasks = items.Select(item => TranslateQueueItemPathsAsync(mappings, client, item)); items = [.. await Task.WhenAll(tasks)]; foreach (QueueItem item in items) @@ -228,20 +235,24 @@ private List GetExternalIds(List downloads) /// Make sure all paths are locally accessible after processing and /// that a proper list of sanitized source files is produced /// + /// Remote path mappings already resolved for this client /// Download client configuration to use for path mapping /// Queue item to translate/sanitize /// - private async Task TranslateQueueItemPathsAsync(DownloadClientConfiguration client, QueueItem item) + private async Task TranslateQueueItemPathsAsync( + IReadOnlyList mappings, + DownloadClientConfiguration client, + QueueItem item) { if (!string.IsNullOrEmpty(item.RemotePath)) { - item.LocalPath = await remotePathMappingService.TranslatePathAsync(client, item.RemotePath); + item.LocalPath = remotePathMappingService.TranslatePath(mappings, client, item.RemotePath); EnsureNativePath(item.LocalPath, client.Name); } if (!string.IsNullOrEmpty(item.ContentPath)) { - item.ContentPath = await remotePathMappingService.TranslatePathAsync(client, item.ContentPath); + item.ContentPath = remotePathMappingService.TranslatePath(mappings, client, item.ContentPath); EnsureNativePath(item.ContentPath, client.Name); } @@ -256,7 +267,7 @@ private async Task TranslateQueueItemPathsAsync(DownloadClientConfigu List sourceFiles = []; foreach (string file in item.SourceFiles) { - var sourceFile = await remotePathMappingService.TranslatePathAsync(client, file); + var sourceFile = remotePathMappingService.TranslatePath(mappings, client, file); EnsureNativePath(sourceFile, client.Name); sourceFiles.Add(sourceFile); } diff --git a/listenarr.application/Downloads/Contracts/IFileMover.cs b/listenarr.application/Downloads/Contracts/IFileMover.cs index 78d4688db..ce289bd05 100644 --- a/listenarr.application/Downloads/Contracts/IFileMover.cs +++ b/listenarr.application/Downloads/Contracts/IFileMover.cs @@ -18,6 +18,28 @@ namespace Listenarr.Application.Downloads.Contracts { + public enum FilePublicationOutcome + { + Success, + Skipped, + Blocked, + Failed + } + + public sealed record FilePublicationPreparationResult( + FilePublicationOutcome Outcome, + FileAction RequestedAction, + FileAction EffectiveAction, + FilePublicationSourceDisposition SourceDisposition, + IAudiobookFileRegistrationLease? RegistrationLease = null, + string? ReasonCode = null, + string? Message = null) + { + public bool IsSuccess => + Outcome == FilePublicationOutcome.Success + && RegistrationLease != null; + } + /// /// Handles file manipulation within a destination hierarchy that has already /// been established by the caller. Implementations must not create missing @@ -112,6 +134,21 @@ Task PerformActionOn( string? expectedRegisteredPhysicalObjectIdentity, FilePublicationSourceProof expectedSourceProof); + /// + /// Publishes through the explicitly selected durable or additive-only + /// execution mode and reports the effective action and source disposition. + /// + Task + PrepareActionForRegistrationDetailedAsync( + FilePublicationPlan plan, + string source, + string destination, + Guid operationId, + string? expectedRegisteredPhysicalObjectIdentity, + FilePublicationSourceProof expectedSourceProof, + bool isCompanionFile = false, + int? companionAudiobookId = null); + /// /// Completes a staged move by retiring only the verified source generation /// while preserving the destination generation held by the registration lease. diff --git a/listenarr.application/Downloads/Contracts/IFilePublicationCapabilityResolver.cs b/listenarr.application/Downloads/Contracts/IFilePublicationCapabilityResolver.cs new file mode 100644 index 000000000..fa175df9a --- /dev/null +++ b/listenarr.application/Downloads/Contracts/IFilePublicationCapabilityResolver.cs @@ -0,0 +1,69 @@ +namespace Listenarr.Application.Downloads.Contracts; + +public enum FilePublicationExecutionMode +{ + Durable = 0, + AdditiveCopyRetainSource = 1, + Blocked = 2 +} + +public enum FilePublicationSourceDisposition +{ + NotApplicable = 0, + Retained = 1, + Retired = 2, + Unchanged = 3 +} + +public sealed record FilePublicationPlan( + FileAction RequestedAction, + FileAction EffectiveAction, + FilePublicationExecutionMode Mode, + FilePublicationSourceDisposition SourceDisposition, + string? ReasonCode = null, + string? Message = null) +{ + public bool IsAllowed => Mode != FilePublicationExecutionMode.Blocked; + + public static FilePublicationPlan Durable(FileAction action) => + new( + action, + action, + FilePublicationExecutionMode.Durable, + action == FileAction.Move + ? FilePublicationSourceDisposition.Retired + : FilePublicationSourceDisposition.Unchanged); + + public static FilePublicationPlan Additive(FileAction requestedAction) => + new( + requestedAction, + FileAction.Copy, + FilePublicationExecutionMode.AdditiveCopyRetainSource, + FilePublicationSourceDisposition.Retained, + "durable_identity_unavailable", + requestedAction == FileAction.Move + ? "The destination was copied successfully, but the source was retained because exact source retirement cannot be proven on this storage." + : "The file was copied using compatibility publication because durable filesystem identity is unavailable."); + + public static FilePublicationPlan Blocked( + FileAction requestedAction, + string reasonCode, + string message) => + new( + requestedAction, + requestedAction, + FilePublicationExecutionMode.Blocked, + FilePublicationSourceDisposition.Unchanged, + reasonCode, + message); +} + +public interface IFilePublicationCapabilityResolver +{ + Task ResolveAsync( + FileAction requestedAction, + string source, + string destination, + FilePublicationSourceProof sourceProof, + CancellationToken cancellationToken = default); +} diff --git a/listenarr.application/Downloads/Contracts/IFilePublicationSourceCapability.cs b/listenarr.application/Downloads/Contracts/IFilePublicationSourceCapability.cs index f86e44b8d..f16c97a94 100644 --- a/listenarr.application/Downloads/Contracts/IFilePublicationSourceCapability.cs +++ b/listenarr.application/Downloads/Contracts/IFilePublicationSourceCapability.cs @@ -8,6 +8,12 @@ public enum FilePublicationSourceCapabilityFailureKind Unsupported = 3 } +public enum FilePublicationSourceAuthority +{ + DurableObjectIdentity = 0, + ContentOnly = 1 +} + /// /// Exact source evidence used to derive and later revalidate a durable file-publication /// operation. Physical generation alone is insufficient because a downloader may rewrite @@ -16,8 +22,13 @@ public enum FilePublicationSourceCapabilityFailureKind public readonly record struct FilePublicationSourceProof( string PhysicalObjectIdentity, long Length, - string Sha256) + string Sha256, + FilePublicationSourceAuthority Authority = + FilePublicationSourceAuthority.DurableObjectIdentity) { + public bool HasDurablePhysicalObjectIdentity => + Authority == FilePublicationSourceAuthority.DurableObjectIdentity; + public void Validate() { ArgumentException.ThrowIfNullOrWhiteSpace(PhysicalObjectIdentity); diff --git a/listenarr.application/Downloads/Contracts/IRemotePathMappingService.cs b/listenarr.application/Downloads/Contracts/IRemotePathMappingService.cs index 5261976b1..63e568b2d 100644 --- a/listenarr.application/Downloads/Contracts/IRemotePathMappingService.cs +++ b/listenarr.application/Downloads/Contracts/IRemotePathMappingService.cs @@ -66,4 +66,17 @@ public interface IRemotePathMappingService /// A matching mapping exists but its local side is unavailable or unsafe on this host. /// Task TranslatePathAsync(DownloadClientConfiguration client, string remotePath); + + /// + /// Translates a remote path using mappings the caller has already resolved. + /// + /// + /// For callers translating many paths for one client. Resolving the mappings once and + /// translating from them keeps a parallel batch off the scoped repository, and so off the + /// scoped DbContext behind it, which permits one operation at a time. + /// + string TranslatePath( + IReadOnlyList mappings, + DownloadClientConfiguration client, + string remotePath); } diff --git a/listenarr.application/Downloads/Import/DownloadImportService.DirectoryOwnership.cs b/listenarr.application/Downloads/Import/DownloadImportService.DirectoryOwnership.cs index d0134095d..cdd1d3a19 100644 --- a/listenarr.application/Downloads/Import/DownloadImportService.DirectoryOwnership.cs +++ b/listenarr.application/Downloads/Import/DownloadImportService.DirectoryOwnership.cs @@ -5,7 +5,50 @@ namespace Listenarr.Application.Downloads.Import; public partial class DownloadImportService { - private async Task PerformOwnedFileActionAsync( + private Task ResolvePublicationPlanAsync( + FileAction requestedAction, + string source, + string destination, + FilePublicationSourceProof sourceProof, + CancellationToken cancellationToken) + { + return filePublicationCapabilityResolver == null + ? Task.FromResult(sourceProof.HasDurablePhysicalObjectIdentity + ? FilePublicationPlan.Durable(requestedAction) + : FilePublicationPlan.Additive(requestedAction)) + : filePublicationCapabilityResolver.ResolveAsync( + requestedAction, + source, + destination, + sourceProof, + cancellationToken); + } + + private static ImportResult CreateBlockedImportResult( + FilePublicationPlan publicationPlan, + string source, + string destination) + { + var blocked = ImportResult.ImportFailure( + publicationPlan.RequestedAction, + source, + destination); + blocked.Message = publicationPlan.Message; + return blocked; + } + + private static ImportSourceDisposition ToImportSourceDisposition( + FilePublicationPlan publicationPlan) => + publicationPlan.SourceDisposition switch + { + FilePublicationSourceDisposition.Retained => + ImportSourceDisposition.Retained, + FilePublicationSourceDisposition.Retired => + ImportSourceDisposition.Retired, + _ => ImportSourceDisposition.Unchanged + }; + + private async Task PerformOwnedFileActionAsync( FileAction action, string source, string destination, @@ -17,6 +60,25 @@ private async Task PerformOwnedFileActionAsync( CancellationToken cancellationToken) { expectedSourceProof.Validate(); + var publicationPlan = filePublicationCapabilityResolver == null + ? expectedSourceProof.HasDurablePhysicalObjectIdentity + ? FilePublicationPlan.Durable(action) + : FilePublicationPlan.Additive(action) + : await filePublicationCapabilityResolver.ResolveAsync( + action, + source, + destination, + expectedSourceProof, + cancellationToken); + if (!publicationPlan.IsAllowed) + { + logger.LogWarning( + "Blocked companion publication for {Source}: {Reason}", + LogRedaction.SanitizeFilePath(source), + LogRedaction.SanitizeText(publicationPlan.Message)); + return null; + } + if (!await EnsureOwnedImportDestinationAsync( source, destination, @@ -24,31 +86,54 @@ private async Task PerformOwnedFileActionAsync( semantics, operationId, audiobookId, + publicationPlan, cancellationToken)) { - return false; + return null; } cancellationToken.ThrowIfCancellationRequested(); - return action == FileAction.Move - ? await fileMover.PerformActionOn( - action, + var preparation = await fileMover + .PrepareActionForRegistrationDetailedAsync( + publicationPlan, source, destination, operationId, - audiobookId, - FileMutationOwner.CompanionFile, - expectedSourceProof) - : await fileMover.PerformActionOn( - action, + expectedRegisteredPhysicalObjectIdentity: null, + expectedSourceProof, + isCompanionFile: true, + companionAudiobookId: audiobookId); + using var lease = preparation.RegistrationLease; + if (lease == null || !lease.PrepareCleanupRecovery(audiobookId)) + { + return null; + } + + var completion = lease.CompletePublication(); + if (completion + == RegistrationPublicationCompletion.CommittedCleanupPending) + { + logger.LogWarning( + "Companion publication committed, but cleanup remains pending for {Path}", + LogRedaction.SanitizeFilePath(destination)); + return null; + } + + if (publicationPlan.EffectiveAction == FileAction.Move + && !await fileMover.CompletePreparedMoveAsync( source, destination, - operationId, - expectedSourceProof); + lease, + operationId)) + { + return null; + } + + return publicationPlan; } - private async Task PrepareOwnedFileActionForRegistrationAsync( - FileAction action, + private async Task PrepareOwnedFileActionForRegistrationAsync( + FilePublicationPlan publicationPlan, string source, string destination, string managedBoundary, @@ -67,31 +152,56 @@ private async Task PerformOwnedFileActionAsync( semantics, operationId, audiobookId, + publicationPlan, cancellationToken)) { - return null; + return new FilePublicationPreparationResult( + FilePublicationOutcome.Blocked, + publicationPlan.RequestedAction, + publicationPlan.EffectiveAction, + publicationPlan.SourceDisposition, + ReasonCode: "destination_ownership_unavailable", + Message: "The import destination could not be prepared safely."); } cancellationToken.ThrowIfCancellationRequested(); - if (action == FileAction.HardlinkCopy - && !string.IsNullOrWhiteSpace( - expectedRegisteredPhysicalObjectIdentity)) + if (publicationPlan.Mode == FilePublicationExecutionMode.Durable) { - return await fileMover.PrepareActionForRegistrationAsync( - action, - source, - destination, - operationId, - expectedRegisteredPhysicalObjectIdentity, - expectedSourceProof); + var lease = publicationPlan.EffectiveAction == FileAction.HardlinkCopy + && !string.IsNullOrWhiteSpace( + expectedRegisteredPhysicalObjectIdentity) + ? await fileMover.PrepareActionForRegistrationAsync( + publicationPlan.EffectiveAction, + source, + destination, + operationId, + expectedRegisteredPhysicalObjectIdentity, + expectedSourceProof) + : await fileMover.PrepareActionForRegistrationAsync( + publicationPlan.EffectiveAction, + source, + destination, + operationId, + expectedRegisteredPhysicalObjectIdentity: null, + expectedSourceProof); + return new FilePublicationPreparationResult( + lease == null + ? FilePublicationOutcome.Blocked + : FilePublicationOutcome.Success, + publicationPlan.RequestedAction, + publicationPlan.EffectiveAction, + publicationPlan.SourceDisposition, + lease); } - return await fileMover.PrepareActionForRegistrationAsync( - action, + return await fileMover.PrepareActionForRegistrationDetailedAsync( + publicationPlan, source, destination, operationId, - expectedRegisteredPhysicalObjectIdentity: null, + publicationPlan.EffectiveAction == FileAction.HardlinkCopy + ? expectedRegisteredPhysicalObjectIdentity + : null, expectedSourceProof); } @@ -121,6 +231,7 @@ private async Task EnsureOwnedImportDestinationAsync( FileSystemPathSemantics semantics, Guid operationId, int audiobookId, + FilePublicationPlan? publicationPlan, CancellationToken cancellationToken) { var destinationDirectory = Path.GetDirectoryName(destination) @@ -156,14 +267,26 @@ AudiobookFileOwnershipCheckOutcome.Available or return false; } - await directoryOwnershipStore.EnsureCreatedHierarchyAsync( - destinationDirectory, - managedBoundary, - semantics, - "download-import", - operationId, - audiobookId, - cancellationToken); + if (publicationPlan?.Mode + == FilePublicationExecutionMode.AdditiveCopyRetainSource) + { + await directoryOwnershipStore.EnsureAdditiveHierarchyAsync( + destinationDirectory, + managedBoundary, + semantics, + cancellationToken); + } + else + { + await directoryOwnershipStore.EnsureCreatedHierarchyAsync( + destinationDirectory, + managedBoundary, + semantics, + "download-import", + operationId, + audiobookId, + cancellationToken); + } return true; } } diff --git a/listenarr.application/Downloads/Import/DownloadImportService.Naming.cs b/listenarr.application/Downloads/Import/DownloadImportService.Naming.cs index 98981f94e..8ff17fd66 100644 --- a/listenarr.application/Downloads/Import/DownloadImportService.Naming.cs +++ b/listenarr.application/Downloads/Import/DownloadImportService.Naming.cs @@ -1,3 +1,5 @@ +using System.Globalization; + namespace Listenarr.Application.Downloads.Import; public partial class DownloadImportService @@ -50,10 +52,16 @@ private static AudioMetadata BuildNamingMetadata( Series = FirstNonEmpty( audiobook.Series, extractedMetadata?.Series), + // Parsed with InvariantCulture: the source value always uses '.' as the + // decimal separator, so parsing under the server's culture would read a + // position of "1.5" as 15 wherever '.' is the group separator. SeriesPosition = !string.IsNullOrWhiteSpace(audiobook.SeriesNumber) - && decimal.TryParse(audiobook.SeriesNumber, out var seriesPosition) + && decimal.TryParse(audiobook.SeriesNumber, NumberStyles.Number, CultureInfo.InvariantCulture, out var seriesPosition) ? seriesPosition : extractedMetadata?.SeriesPosition, + SeriesPositionRaw = FirstNonEmpty( + audiobook.SeriesNumber, + extractedMetadata?.SeriesPositionRaw), Year = !string.IsNullOrWhiteSpace(audiobook.PublishYear) && int.TryParse(audiobook.PublishYear, out var year) ? year @@ -95,6 +103,26 @@ private static AudioMetadata BuildNamingMetadata( }; } + /// + /// The {SeriesNumber} token for a file being imported. + /// + /// Prefers the position exactly as the source gave it. A real but non-numeric position + /// (an omnibus at "1-4") does not survive the decimal parse, and falling through to the + /// chapter number would write that into the filename as if it were the series number. + /// + /// + /// A parsed position is formatted with InvariantCulture, matching FileNamingService: + /// ToString() under the server's culture would put a comma into the filename. + /// + /// + private static string SeriesNumberToken( + AudioMetadata metadata, + int? fallbackChapterNumber) => + FirstNonEmpty( + metadata.SeriesPositionRaw, + metadata.SeriesPosition?.ToString(CultureInfo.InvariantCulture), + fallbackChapterNumber?.ToString()); + private static string ChooseAuthorFromMetadata(AudioMetadata? metadata) { if (metadata == null) diff --git a/listenarr.application/Downloads/Import/DownloadImportService.Registration.cs b/listenarr.application/Downloads/Import/DownloadImportService.Registration.cs index 4a333e50a..dc006dc94 100644 --- a/listenarr.application/Downloads/Import/DownloadImportService.Registration.cs +++ b/listenarr.application/Downloads/Import/DownloadImportService.Registration.cs @@ -1,7 +1,84 @@ +using Listenarr.Domain.Common; +using Microsoft.Extensions.Logging; + namespace Listenarr.Application.Downloads.Import; public partial class DownloadImportService { + private async Task PrepareRegisterAndCompletePublicationAsync( + FilePublicationPlan publicationPlan, + string source, + string destination, + string destinationOwnershipBoundary, + FileSystemPathSemantics destinationSemantics, + Guid operationId, + string? expectedRegisteredPhysicalObjectIdentity, + FilePublicationSourceProof sourceProof, + Audiobook audiobook, + AudiobookFileOwnershipCheckResult ownership, + CancellationToken cancellationToken) + { + var preparation = await PrepareOwnedFileActionForRegistrationAsync( + publicationPlan, + source, + destination, + destinationOwnershipBoundary, + destinationSemantics, + operationId, + expectedRegisteredPhysicalObjectIdentity, + sourceProof, + audiobook.Id, + cancellationToken); + using var registrationLease = preparation.RegistrationLease; + if (registrationLease == null) + { + return false; + } + + var registered = publicationPlan.Mode + == FilePublicationExecutionMode.AdditiveCopyRetainSource + ? await audiobookFileService.RegisterCompatibilityPublicationAsync( + audiobook, + ownership, + registrationLease, + "download", + cancellationToken) + : await RegisterPublishedImportAsync( + audiobook, + ownership, + registrationLease, + "download", + cancellationToken); + if (!registered) + { + return false; + } + + if (publicationPlan.EffectiveAction == FileAction.Move + && !await fileMover.CompletePreparedMoveAsync( + source, + destination, + registrationLease, + operationId)) + { + await audiobookFileService.RollbackPublishedGenerationIfStaleAsync( + audiobook, + registrationLease); + return false; + } + + var completion = registrationLease.CompletePublication(); + if (completion == RegistrationPublicationCompletion.CommittedCleanupPending) + { + logger.LogWarning( + "Download import committed for audiobook {AudiobookId}, but registration-publication cleanup remains pending for {Destination}", + audiobook.Id, + LogRedaction.SanitizeFilePath(destination)); + } + + return true; + } + private Task RegisterPublishedImportAsync( Audiobook audiobook, AudiobookFileOwnershipCheckResult initialOwnership, diff --git a/listenarr.application/Downloads/Import/DownloadImportService.cs b/listenarr.application/Downloads/Import/DownloadImportService.cs index ee86492cb..8cd80b9f7 100644 --- a/listenarr.application/Downloads/Import/DownloadImportService.cs +++ b/listenarr.application/Downloads/Import/DownloadImportService.cs @@ -38,7 +38,9 @@ public partial class DownloadImportService( IFileRegistrationRecoveryService fileRegistrationRecoveryService, IMoveQueueService moveQueueService, ILibraryDirectoryOwnershipStore directoryOwnershipStore, - ILogger logger) : IDownloadImportService + ILogger logger, + IFilePublicationCapabilityResolver? filePublicationCapabilityResolver = null) + : IDownloadImportService { private async Task> ImportDownloadFilesCoreAsync( Audiobook audiobook, @@ -205,7 +207,8 @@ await ResolvePublishableSourceProofAsync( destinationSemantics, ct); destination = destinationReservation.Path; - if (!await PerformOwnedFileActionAsync( + var companionPublication = + await PerformOwnedFileActionAsync( completedFileAction, file, destination, @@ -222,14 +225,28 @@ await ResolvePublishableSourceProofAsync( destinationSemantics), sourceProof.Value, audiobook.Id, - ct)) + ct); + if (companionPublication == null) { results.Add(ImportResult.ImportFailure(completedFileAction, file, destination)); continue; } ImportDestinationPlanner.Commit(destinationReservation, usedDestinations); - results.Add(ImportResult.ImportSuccess(completedFileAction, file, destination)); + results.Add(ImportResult.ImportSuccess( + completedFileAction, + companionPublication.EffectiveAction, + companionPublication.SourceDisposition + == FilePublicationSourceDisposition.Retained + ? ImportSourceDisposition.Retained + : companionPublication.SourceDisposition + == FilePublicationSourceDisposition.Retired + ? ImportSourceDisposition.Retired + : ImportSourceDisposition.Unchanged, + file, + destination, + warningCode: companionPublication.ReasonCode, + message: companionPublication.Message)); } catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) { @@ -294,7 +311,7 @@ await ResolvePublishableSourceProofAsync( { "Publisher", string.IsNullOrWhiteSpace(namingMetadata.Publisher) ? string.Empty : namingMetadata.Publisher }, { "Language", string.IsNullOrWhiteSpace(namingMetadata.Language) ? string.Empty : namingMetadata.Language }, { "Asin", string.IsNullOrWhiteSpace(namingMetadata.Asin) ? string.Empty : namingMetadata.Asin }, - { "SeriesNumber", namingMetadata.SeriesPosition?.ToString() ?? effectiveChapterNumber?.ToString() ?? string.Empty }, + { "SeriesNumber", SeriesNumberToken(namingMetadata, effectiveChapterNumber) }, { "Year", namingMetadata.Year?.ToString() ?? string.Empty }, { "Quality", (namingMetadata.BitRate.HasValue ? $"{namingMetadata.BitRate}kbps" : null) ?? namingMetadata.Format ?? string.Empty }, { "DiskNumber", effectiveDiskNumber?.ToString() ?? string.Empty }, @@ -360,6 +377,16 @@ await ResolvePublishableSourceProofAsync( AudiobookFileOwnershipCheckOutcome.Available or AudiobookFileOwnershipCheckOutcome.AlreadyOwnedByAudiobook) { + if (destinationReservation.ReusesExistingFile + && !sourceProof.Value.HasDurablePhysicalObjectIdentity + && ownership.Outcome + == AudiobookFileOwnershipCheckOutcome.Available) + { + // Matching bytes are not an ownership claim. + // Preserve the existing path and plan another suffix. + usedDestinations.Add(destination); + continue; + } break; } @@ -400,9 +427,23 @@ AudiobookFileOwnershipCheckOutcome.Available or sourceProof.Value, destination, destinationSemantics); - using var registrationLease = - await PrepareOwnedFileActionForRegistrationAsync( - completedFileAction, + var publicationPlan = await ResolvePublicationPlanAsync( + completedFileAction, + file, + destination, + sourceProof.Value, + ct); + if (!publicationPlan.IsAllowed) + { + results.Add(CreateBlockedImportResult( + publicationPlan, + file, + destination)); + continue; + } + + if (!await PrepareRegisterAndCompletePublicationAsync( + publicationPlan, file, destination, destinationOwnershipBoundary, @@ -410,14 +451,8 @@ await PrepareOwnedFileActionForRegistrationAsync( operationId, ownership.ExistingFile?.PhysicalObjectIdentity, sourceProof.Value, - audiobook.Id, - ct); - if (registrationLease == null - || !await RegisterPublishedImportAsync( audiobook, ownership, - registrationLease, - "download", ct)) { results.Add(ImportResult.ImportFailure( @@ -427,42 +462,18 @@ await PrepareOwnedFileActionForRegistrationAsync( continue; } - if (completedFileAction == FileAction.Move - && !await fileMover.CompletePreparedMoveAsync( - file, - destination, - registrationLease, - operationId)) - { - await audiobookFileService - .RollbackPublishedGenerationIfStaleAsync( - audiobook, - registrationLease); - results.Add(ImportResult.ImportFailure( - completedFileAction, - file, - destination)); - continue; - } - - var completion = registrationLease.CompletePublication(); - if (completion - == RegistrationPublicationCompletion.CommittedCleanupPending) - { - logger.LogWarning( - "Download import committed for audiobook {AudiobookId}, but registration-publication cleanup remains pending for {Destination}", - audiobook.Id, - LogRedaction.SanitizeFilePath(destination)); - } - ImportDestinationPlanner.Commit( destinationReservation, usedDestinations); results.Add(ImportResult.ImportSuccess( completedFileAction, + publicationPlan.EffectiveAction, + ToImportSourceDisposition(publicationPlan), file, destination, - wasRegisteredToAudiobook: true)); + wasRegisteredToAudiobook: true, + publicationPlan.ReasonCode, + publicationPlan.Message)); } catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) { diff --git a/listenarr.application/Downloads/Queue/DownloadOrphanCleanupService.cs b/listenarr.application/Downloads/Queue/DownloadOrphanCleanupService.cs index b08662039..a95c088fc 100644 --- a/listenarr.application/Downloads/Queue/DownloadOrphanCleanupService.cs +++ b/listenarr.application/Downloads/Queue/DownloadOrphanCleanupService.cs @@ -48,16 +48,14 @@ public async Task RemoveOrphansAsync( return; } + // A snapshot that reaches this point is guaranteed live: the UsedCachedSnapshot + // and IsUnavailable guards above already excluded every unreachable path (the + // poller surfaces timeout/cancel/error as cached or unavailable, never as a live + // empty snapshot). An empty live queue therefore means the items really are gone, + // so an empty snapshot must be allowed to terminalize orphans - otherwise deleting + // your only active torrent strands its Download record forever and blocks re-grabs + // with 409. The per-item grace period below still protects fresh adds. var clientQueue = clientQueueResult.QueueItems; - if (clientQueue.Count == 0 && clientDownloads.Any()) - { - logger.LogWarning( - "Skipping orphan cleanup for client {ClientName}: client returned 0 queue items but {Count} downloads are tracked. Client may be temporarily unreachable.", - client.Name, - clientDownloads.Count); - return; - } - var liveClientItemIds = BuildLiveClientItemIds(clientQueue, mappedQueueItems); var now = DateTime.UtcNow; var cleanupCandidates = clientDownloads diff --git a/listenarr.application/Metadata/Core/AudiobookMetadataRefreshService.cs b/listenarr.application/Metadata/Core/AudiobookMetadataRefreshService.cs new file mode 100644 index 000000000..8b3a84cf8 --- /dev/null +++ b/listenarr.application/Metadata/Core/AudiobookMetadataRefreshService.cs @@ -0,0 +1,134 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Metadata.Core +{ + /// + public class AudiobookMetadataRefreshService : IAudiobookMetadataRefreshService + { + private readonly IAudiobookMetadataService _metadataService; + private readonly MetadataConverters _metadataConverters; + private readonly IAudiobookRepository _audiobookRepository; + private readonly ILogger _logger; + + public AudiobookMetadataRefreshService( + IAudiobookMetadataService metadataService, + MetadataConverters metadataConverters, + IAudiobookRepository audiobookRepository, + ILogger logger) + { + _metadataService = metadataService; + _metadataConverters = metadataConverters; + _audiobookRepository = audiobookRepository; + _logger = logger; + } + + public async Task TryPopulateMissingMetadataAsync(Audiobook audiobook, string? region = null, CancellationToken cancellationToken = default) + { + if (audiobook == null || string.IsNullOrWhiteSpace(audiobook.Asin)) + { + return false; + } + + var resolvedRegion = string.IsNullOrWhiteSpace(region) ? "us" : region.Trim(); + + AudibleBookResponse? response; + try + { + response = await _metadataService.GetAudibleMetadataAsync(audiobook.Asin, resolvedRegion, cache: false); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Auto metadata refresh lookup failed for audiobook {AudiobookId} ASIN {Asin}", audiobook.Id, audiobook.Asin); + return false; + } + + if (response == null) + { + _logger.LogInformation("Auto metadata refresh found no upstream data for audiobook {AudiobookId} ASIN {Asin}", audiobook.Id, audiobook.Asin); + return false; + } + + var converted = _metadataConverters.ConvertAudibleToMetadata(response, audiobook.Asin, "Audible"); + if (!FillMissingFields(audiobook, converted)) + { + return false; + } + + try + { + await _audiobookRepository.UpdateAsync(audiobook); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to save auto-populated metadata for audiobook {AudiobookId}", audiobook.Id); + return false; + } + + _logger.LogInformation("Auto-populated missing metadata for audiobook {AudiobookId} ({Title}) from ASIN {Asin}", audiobook.Id, audiobook.Title, audiobook.Asin); + return true; + } + + /// + /// Fills only fields that are currently empty on the audiobook. Never overwrites values the + /// user (or a prior metadata fetch) already set. Returns true if anything changed. + /// + internal static bool FillMissingFields(Audiobook audiobook, AudibleBookMetadata metadata) + { + var changed = false; + + if (string.IsNullOrWhiteSpace(audiobook.Title) && !string.IsNullOrWhiteSpace(metadata.Title)) { audiobook.Title = metadata.Title; changed = true; } + if (string.IsNullOrWhiteSpace(audiobook.Subtitle) && !string.IsNullOrWhiteSpace(metadata.Subtitle)) { audiobook.Subtitle = metadata.Subtitle; changed = true; } + if (string.IsNullOrWhiteSpace(audiobook.Publisher) && !string.IsNullOrWhiteSpace(metadata.Publisher)) { audiobook.Publisher = metadata.Publisher; changed = true; } + if (string.IsNullOrWhiteSpace(audiobook.PublishYear) && !string.IsNullOrWhiteSpace(metadata.PublishYear)) { audiobook.PublishYear = metadata.PublishYear; changed = true; } + if (string.IsNullOrWhiteSpace(audiobook.PublishedDate) && !string.IsNullOrWhiteSpace(metadata.PublishedDate)) { audiobook.PublishedDate = metadata.PublishedDate; changed = true; } + if (string.IsNullOrWhiteSpace(audiobook.Description) && !string.IsNullOrWhiteSpace(metadata.Description)) { audiobook.Description = metadata.Description; changed = true; } + if (string.IsNullOrWhiteSpace(audiobook.Language) && !string.IsNullOrWhiteSpace(metadata.Language)) { audiobook.Language = metadata.Language; changed = true; } + if (string.IsNullOrWhiteSpace(audiobook.ImageUrl) && !string.IsNullOrWhiteSpace(metadata.ImageUrl)) { audiobook.ImageUrl = metadata.ImageUrl; changed = true; } + + if ((audiobook.Runtime == null || audiobook.Runtime == 0) && metadata.Runtime.HasValue && metadata.Runtime.Value > 0) + { + audiobook.Runtime = metadata.Runtime; + changed = true; + } + + if (IsEmpty(audiobook.Authors) && metadata.Authors is { Count: > 0 }) + { + audiobook.Authors = metadata.Authors.ToList(); + changed = true; + } + + if (IsEmpty(audiobook.Narrators) && metadata.Narrators is { Count: > 0 }) + { + audiobook.Narrators = metadata.Narrators.ToList(); + changed = true; + } + + if (IsEmpty(audiobook.Genres) && metadata.Genres is { Count: > 0 }) + { + audiobook.Genres = metadata.Genres.ToList(); + changed = true; + } + + return changed; + } + + private static bool IsEmpty(List? values) => values == null || values.Count == 0; + } +} diff --git a/listenarr.application/Search/Indexers/Common/IndexerSearchWorkflow.cs b/listenarr.application/Search/Indexers/Common/IndexerSearchWorkflow.cs index 85e789977..a3435153b 100644 --- a/listenarr.application/Search/Indexers/Common/IndexerSearchWorkflow.cs +++ b/listenarr.application/Search/Indexers/Common/IndexerSearchWorkflow.cs @@ -63,8 +63,8 @@ public async Task> SearchIndexersAsync( if (!indexers.Any()) { - _logger.LogWarning("No indexers configured, returning mock results for query: {Query}", query); - return GenerateMockIndexerResults(query); + _logger.LogWarning("No indexers configured or enabled; search returned no results for query: {Query}", query); + return results; } var searchTasks = indexers.Select(async indexer => @@ -78,7 +78,16 @@ public async Task> SearchIndexersAsync( _logger.LogInformation("Found {Count} results from indexer {Name}", indexerResults.Count, indexer.Name); return indexerResults; } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + catch (OperationCanceledException ex) + { + // No workflow-level cancellation token flows into this search, so an + // OperationCanceledException here is an HttpClient per-request timeout + // (TaskCanceledException derives from OperationCanceledException). Contain it + // to this indexer so a single slow indexer can't abort every other one's results. + _logger.LogWarning(ex, "Timed out searching indexer {Name} for query: {Query}", indexer.Name, query); + return new List(); + } + catch (Exception ex) when (ex is not OutOfMemoryException && ex is not StackOverflowException) { _logger.LogError(ex, "Error searching indexer {Name} for query: {Query}", indexer.Name, query); return new List(); @@ -254,61 +263,4 @@ private static string GetFallbackIndexerName(Indexer indexer) return "Indexer"; } } - - private List GenerateMockIndexerResults(string query) - { - return GenerateMockIndexerResults(query, "Mock Indexer", "Torrent"); - } - - private List GenerateMockIndexerResults(string query, string indexerName, string indexerType) - { - var random = new Random(); - var results = new List(); - var isUsenet = indexerType.Equals("Usenet", StringComparison.OrdinalIgnoreCase); - - _logger.LogInformation("Generating {Count} mock {Type} results for indexer {IndexerName}", 5, indexerType, indexerName); - - for (int i = 0; i < 5; i++) - { - var result = new IndexerSearchResult - { - Id = Guid.NewGuid().ToString(), - Title = $"{query} - Quality {i + 1}", - Artist = "Various Authors", - Album = $"{query} Series", - Category = "Audiobook", - Size = random.Next(200_000_000, 1_500_000_000), - Seeders = isUsenet ? 0 : random.Next(5, 100), - Leechers = isUsenet ? 0 : random.Next(0, 20), - Source = indexerName, - PublishedDate = DateTime.UtcNow.AddDays(-random.Next(1, 365)).ToString("o"), - Quality = i switch - { - 0 => "MP3 64kbps", - 1 => "MP3 128kbps", - 2 => "MP3 192kbps", - 3 => "M4B 128kbps", - _ => "FLAC" - }, - Format = i >= 3 ? "M4B" : "MP3", - Language = "English" - }; - - if (isUsenet) - { - result.NzbUrl = $"https://{indexerName.ToLowerInvariant()}.example.com/api/nzb/{Guid.NewGuid():N}"; - result.MagnetLink = string.Empty; - result.TorrentUrl = string.Empty; - } - else - { - result.MagnetLink = $"magnet:?xt=urn:btih:{Guid.NewGuid():N}"; - result.NzbUrl = string.Empty; - } - - results.Add(result); - } - - return results; - } } diff --git a/listenarr.application/Search/Metadata/MetadataConverters.cs b/listenarr.application/Search/Metadata/MetadataConverters.cs index b70e2b443..a319df931 100644 --- a/listenarr.application/Search/Metadata/MetadataConverters.cs +++ b/listenarr.application/Search/Metadata/MetadataConverters.cs @@ -143,6 +143,7 @@ public AudibleBookMetadata ConvertAudnexusToMetadata(AudnexusBookResponse audnex { audnexusSeriesMemberships.Add(new AudiobookSeriesMembership { + SeriesAsin = audnexusData.SeriesPrimary.Asin, SeriesName = audnexusData.SeriesPrimary.Name, SeriesNumber = audnexusData.SeriesPrimary.Position, IsPrimary = true, @@ -154,6 +155,7 @@ public AudibleBookMetadata ConvertAudnexusToMetadata(AudnexusBookResponse audnex { audnexusSeriesMemberships.Add(new AudiobookSeriesMembership { + SeriesAsin = audnexusData.SeriesSecondary.Asin, SeriesName = audnexusData.SeriesSecondary.Name, SeriesNumber = audnexusData.SeriesSecondary.Position, IsPrimary = audnexusSeriesMemberships.Count == 0, diff --git a/listenarr.application/Search/Scoring/SearchResultScorer.cs b/listenarr.application/Search/Scoring/SearchResultScorer.cs index bfb446143..7dc4a95bd 100644 --- a/listenarr.application/Search/Scoring/SearchResultScorer.cs +++ b/listenarr.application/Search/Scoring/SearchResultScorer.cs @@ -34,10 +34,24 @@ public class SearchResultScorer public int QualityNotAllowedPenalty { get; set; } = -20; public int ForbiddenWordRejectionFlag { get; set; } = -1; // sentinel for rejection + private readonly IReadOnlyDictionary? _resolvedIndexers; + public SearchResultScorer(IIndexerRepository? indexerRepository, ILogger logger) + : this(indexerRepository, logger, resolvedIndexers: null) + { + } + + // resolvedIndexers lets a caller scoring a whole batch resolve each indexer once up front + // and pass the results in. The repository is scoped, and so is the DbContext behind it, so + // results scored in parallel must not each run their own lookup. + public SearchResultScorer( + IIndexerRepository? indexerRepository, + ILogger logger, + IReadOnlyDictionary? resolvedIndexers) { _indexerRepository = indexerRepository; _logger = logger; + _resolvedIndexers = resolvedIndexers; } public async Task Score(SearchResult searchResult, QualityProfile profile) @@ -118,11 +132,16 @@ public async Task Score(SearchResult searchResult, QualityProfile // Age checks and indexer retention double ageDays = 0; int indexerRetention = 0; - if (searchResult.IndexerId.HasValue && _indexerRepository != null) + if (searchResult.IndexerId.HasValue + && (_resolvedIndexers != null || _indexerRepository != null)) { try { - var idx = await _indexerRepository.GetByIdAsync(searchResult.IndexerId.Value); + var idx = _resolvedIndexers != null + ? (_resolvedIndexers.TryGetValue(searchResult.IndexerId.Value, out var preresolved) + ? preresolved + : null) + : await _indexerRepository!.GetByIdAsync(searchResult.IndexerId.Value); if (idx != null) { indexerRetention = idx.Retention; diff --git a/listenarr.application/packages.lock.json b/listenarr.application/packages.lock.json index ed41468eb..e3fc2c20d 100644 --- a/listenarr.application/packages.lock.json +++ b/listenarr.application/packages.lock.json @@ -141,4 +141,4 @@ } } } -} \ No newline at end of file +} diff --git a/listenarr.domain/Audiobooks/AudioMetadata.cs b/listenarr.domain/Audiobooks/AudioMetadata.cs index 3a8ccfc4e..826a8f02b 100644 --- a/listenarr.domain/Audiobooks/AudioMetadata.cs +++ b/listenarr.domain/Audiobooks/AudioMetadata.cs @@ -50,6 +50,22 @@ public class AudioMetadata public string? Language { get; set; } public string? Series { get; set; } public decimal? SeriesPosition { get; set; } + + /// + /// The series position exactly as the metadata source gave it. + /// + /// Audible/Audnexus report a position as a string, and it is not always a number: + /// an omnibus can sit at "1-4", a prequel at "0", a novella at "1.5". Those values + /// are meaningful, but they do not all fit in , so a + /// position that fails to parse would otherwise be indistinguishable from a book + /// that has no series position at all. + /// + /// + /// Naming prefers this over so that a real position is + /// never silently replaced by a track number. + /// + /// + public string? SeriesPositionRaw { get; set; } public byte[]? CoverArt { get; set; } public string? CoverArtUrl { get; set; } public Dictionary AdditionalData { get; set; } = []; @@ -75,6 +91,8 @@ public void Update(AudioMetadata value) if (!SeriesPosition.HasValue && value.SeriesPosition.HasValue) SeriesPosition = value.SeriesPosition; + if (string.IsNullOrWhiteSpace(SeriesPositionRaw) && !string.IsNullOrWhiteSpace(value.SeriesPositionRaw)) + SeriesPositionRaw = value.SeriesPositionRaw; if (!TrackNumber.HasValue && value.TrackNumber.HasValue) TrackNumber = value.TrackNumber; if (!DiscNumber.HasValue && value.DiscNumber.HasValue) diff --git a/listenarr.domain/Audiobooks/Audiobook.cs b/listenarr.domain/Audiobooks/Audiobook.cs index 33f70d10a..922f9ff01 100644 --- a/listenarr.domain/Audiobooks/Audiobook.cs +++ b/listenarr.domain/Audiobooks/Audiobook.cs @@ -17,6 +17,7 @@ */ using System.ComponentModel.DataAnnotations; +using System.Globalization; namespace Listenarr.Domain.Audiobooks { @@ -97,8 +98,17 @@ public AudioMetadata CreateBasicAudioMetadata() Series = Series, // Prefer audiobook's publish year when available Year = int.TryParse(PublishYear, out var py) ? py : (int?)null, - // Series position / number - SeriesPosition = !string.IsNullOrWhiteSpace(SeriesNumber) && decimal.TryParse(SeriesNumber, out var sp) ? sp : (decimal?)null, + // Series position / number. + // Parsed with InvariantCulture: the source value always uses '.' as the + // decimal separator, so parsing under the server's culture would read a + // position of "1.5" as 15 wherever '.' is the group separator. + // SeriesPositionRaw keeps the original either way -- a position such as + // "1-4" (an omnibus) is real, but is not a decimal. + SeriesPosition = !string.IsNullOrWhiteSpace(SeriesNumber) + && decimal.TryParse(SeriesNumber, NumberStyles.Number, CultureInfo.InvariantCulture, out var sp) + ? sp + : (decimal?)null, + SeriesPositionRaw = !string.IsNullOrWhiteSpace(SeriesNumber) ? SeriesNumber.Trim() : null, // Quality string from audiobook record // Map into Bitrate/Format heuristically if useful; for now store textual quality // We'll put it into AdditionalData so FileNamingService can use Format/Bitrate/Quality diff --git a/listenarr.domain/Configuration/FileMoverOptions.cs b/listenarr.domain/Configuration/FileMoverOptions.cs index c0f3b83dd..9fc25a963 100644 --- a/listenarr.domain/Configuration/FileMoverOptions.cs +++ b/listenarr.domain/Configuration/FileMoverOptions.cs @@ -17,6 +17,12 @@ */ namespace Listenarr.Domain.Configuration { + public enum WeakPublicationMode + { + CopyAndRetainSource = 0, + Disabled = 1 + } + public class FileMoverOptions { // Enable or disable using robocopy as a fallback on Windows @@ -31,5 +37,8 @@ public class FileMoverOptions // Backoff (ms) initial and maximum public int MinBackoffMs { get; set; } = 1000; public int MaxBackoffMs { get; set; } = 8000; + + public WeakPublicationMode WeakPublicationMode { get; set; } = + WeakPublicationMode.CopyAndRetainSource; } } diff --git a/listenarr.domain/Downloads/CompatibilityFilePublicationJournal.cs b/listenarr.domain/Downloads/CompatibilityFilePublicationJournal.cs new file mode 100644 index 000000000..f7b3276bb --- /dev/null +++ b/listenarr.domain/Downloads/CompatibilityFilePublicationJournal.cs @@ -0,0 +1,57 @@ +using System.ComponentModel.DataAnnotations; + +namespace Listenarr.Domain.Downloads; + +public static class CompatibilityFilePublicationProtocol +{ + public const int Current = 1; +} + +public enum CompatibilityFilePublicationState +{ + Planned, + TargetVerified, + RegistrationCommitted, + Completed, + NeedsAttention +} + +public enum CompatibilitySourceDisposition +{ + Retained = 0, + Unchanged = 1 +} + +/// +/// Non-destructive recovery state for publication on storage that cannot expose +/// durable object generations. This journal never authorizes deletion or overwrite. +/// +public sealed class CompatibilityFilePublicationJournal +{ + [Key] + public Guid OperationId { get; set; } + public int ProtocolVersion { get; set; } = + CompatibilityFilePublicationProtocol.Current; + public FileAction RequestedAction { get; set; } + public FileAction EffectiveAction { get; set; } = FileAction.Copy; + public CompatibilitySourceDisposition SourceDisposition { get; set; } = + CompatibilitySourceDisposition.Retained; + [Required, MaxLength(4096)] + public string SourcePath { get; set; } = string.Empty; + [Required, MaxLength(4096)] + public string DestinationPath { get; set; } = string.Empty; + public long SourceLength { get; set; } + [Required, MaxLength(64)] + public string SourceSha256 { get; set; } = string.Empty; + public long? TargetLength { get; set; } + [MaxLength(64)] + public string? TargetSha256 { get; set; } + public CompatibilityFilePublicationState State { get; set; } = + CompatibilityFilePublicationState.Planned; + public int? AudiobookId { get; set; } + public bool IsCompanionFile { get; set; } + [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.domain/Downloads/FileMutationJournal.cs b/listenarr.domain/Downloads/FileMutationJournal.cs index 7d97b9754..47b10546c 100644 --- a/listenarr.domain/Downloads/FileMutationJournal.cs +++ b/listenarr.domain/Downloads/FileMutationJournal.cs @@ -15,11 +15,16 @@ public static class FileMutationOwner { // AudiobookFileId is an owner discriminator as well as an optional row ID: // null = registration publication, 0 = legacy Audiobook.FilePath, - // positive = tracked AudiobookFile, -1 = owner-bound companion file. + // positive = tracked AudiobookFile, -1 = legacy direct companion move, + // -2 = registration-backed companion publication. public const int CompanionFile = -1; + public const int RegistrationCompanionFile = -2; public static bool IsCompanionFile(int? audiobookFileId) => - audiobookFileId == CompanionFile; + audiobookFileId is CompanionFile or RegistrationCompanionFile; + + public static bool IsRegistrationCompanionFile(int? audiobookFileId) => + audiobookFileId == RegistrationCompanionFile; } public enum FileMutationJournalState diff --git a/listenarr.domain/Downloads/ImportResult.cs b/listenarr.domain/Downloads/ImportResult.cs index cda0f4a62..94a701971 100644 --- a/listenarr.domain/Downloads/ImportResult.cs +++ b/listenarr.domain/Downloads/ImportResult.cs @@ -19,6 +19,14 @@ namespace Listenarr.Domain.Downloads { + public enum ImportSourceDisposition + { + Unknown, + Unchanged, + Retained, + Retired + } + public class ImportResult { public bool Success { get; set; } @@ -26,6 +34,10 @@ public class ImportResult public string? FinalPath { get; set; } public string? Message { get; set; } public FileAction Action { get; set; } + public FileAction RequestedAction { get; set; } + public FileAction EffectiveAction { get; set; } + public ImportSourceDisposition SourceDisposition { get; set; } + public string? WarningCode { get; set; } public bool WasRegisteredToAudiobook { get; set; } public DateTime? Timestamp { get; set; } = DateTime.UtcNow; @@ -40,18 +52,50 @@ public static ImportResult ImportSuccess(FileAction action, string sourcePath, s { Success = true, Action = action, + RequestedAction = action, + EffectiveAction = action, + SourceDisposition = action == FileAction.Move + ? ImportSourceDisposition.Retired + : ImportSourceDisposition.Unchanged, SourcePath = sourcePath, FinalPath = finalPath, WasRegisteredToAudiobook = wasRegisteredToAudiobook }; } + public static ImportResult ImportSuccess( + FileAction requestedAction, + FileAction effectiveAction, + ImportSourceDisposition sourceDisposition, + string sourcePath, + string finalPath, + bool wasRegisteredToAudiobook = false, + string? warningCode = null, + string? message = null) + { + return new ImportResult + { + Success = true, + Action = effectiveAction, + RequestedAction = requestedAction, + EffectiveAction = effectiveAction, + SourceDisposition = sourceDisposition, + SourcePath = sourcePath, + FinalPath = finalPath, + WasRegisteredToAudiobook = wasRegisteredToAudiobook, + WarningCode = warningCode, + Message = message + }; + } + public static ImportResult ImportFailure(FileAction action, string sourcePath, string finalPath) { return new ImportResult { Success = false, Action = action, + RequestedAction = action, + EffectiveAction = action, SourcePath = sourcePath, FinalPath = finalPath, Message = $"Unable to perform {action} on {sourcePath} to {finalPath}" diff --git a/listenarr.domain/packages.lock.json b/listenarr.domain/packages.lock.json index 6afd6786b..aa0136914 100644 --- a/listenarr.domain/packages.lock.json +++ b/listenarr.domain/packages.lock.json @@ -3,4 +3,4 @@ "dependencies": { "net10.0": {} } -} \ No newline at end of file +} diff --git a/listenarr.infrastructure/Configuration/OperationalOptionsValidators.cs b/listenarr.infrastructure/Configuration/OperationalOptionsValidators.cs index 096f389ad..0ff235fd5 100644 --- a/listenarr.infrastructure/Configuration/OperationalOptionsValidators.cs +++ b/listenarr.infrastructure/Configuration/OperationalOptionsValidators.cs @@ -25,6 +25,8 @@ public ValidateOptionsResult Validate(string? name, FileMoverOptions options) failures.Add("FileMover:MinBackoffMs cannot be negative."); if (options.MaxBackoffMs < options.MinBackoffMs) failures.Add("FileMover:MaxBackoffMs must be greater than or equal to MinBackoffMs."); + if (!Enum.IsDefined(options.WeakPublicationMode)) + failures.Add("FileMover:WeakPublicationMode must be CopyAndRetainSource or Disabled."); return failures.Count == 0 ? ValidateOptionsResult.Success diff --git a/listenarr.infrastructure/Configuration/Paths/RemotePathMappingService.cs b/listenarr.infrastructure/Configuration/Paths/RemotePathMappingService.cs index 8b900ea76..a0e34b1a3 100644 --- a/listenarr.infrastructure/Configuration/Paths/RemotePathMappingService.cs +++ b/listenarr.infrastructure/Configuration/Paths/RemotePathMappingService.cs @@ -123,7 +123,23 @@ public async Task TranslatePathAsync(DownloadClientConfiguration client, return remotePath; } - var mappings = await GetPathMappingByClientAsync(client); + return TranslatePath(await GetPathMappingByClientAsync(client), client, remotePath); + } + + // The mapping lookup and the translation are separated so a caller translating many paths + // for one client can resolve the mappings once. The repository is scoped and so is the + // DbContext behind it, so translating a batch in parallel while each call did its own + // lookup meant concurrent queries on a context that permits one at a time. + public string TranslatePath( + IReadOnlyList mappings, + DownloadClientConfiguration client, + string remotePath) + { + if (string.IsNullOrEmpty(remotePath)) + { + return remotePath; + } + foreach (var mapping in mappings) { if (!TryGetRemoteSemantics( diff --git a/listenarr.infrastructure/DependencyInjection/DownloadClients/DownloadClientRegistrationExtensions.cs b/listenarr.infrastructure/DependencyInjection/DownloadClients/DownloadClientRegistrationExtensions.cs index eb931dfe7..8fee6b0f1 100644 --- a/listenarr.infrastructure/DependencyInjection/DownloadClients/DownloadClientRegistrationExtensions.cs +++ b/listenarr.infrastructure/DependencyInjection/DownloadClients/DownloadClientRegistrationExtensions.cs @@ -24,21 +24,24 @@ internal static class DownloadClientRegistrationExtensions { public static IServiceCollection AddDownloadClientHttpClients(this IServiceCollection services) { + // The retry policy is stateless, so one instance shared by every client is correct and is + // how Polly is meant to be used. A circuit breaker is not: its open/closed state and its + // failure count live inside the policy instance. Sharing one gives all these clients a + // single global breaker rather than one each, so a run of failures against any one of them + // stops polling for all of them. Each client gets its own. var retryPolicy = HttpPolicyExtensions.HandleTransientHttpError() .WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt))); - var circuitBreakerPolicy = HttpPolicyExtensions.HandleTransientHttpError() - .CircuitBreakerAsync(3, TimeSpan.FromSeconds(30)); services.AddHttpClient("DownloadClient") .ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(30)) .ConfigurePrimaryHttpMessageHandler(CreateHandler) .AddPolicyHandler(retryPolicy) - .AddPolicyHandler(circuitBreakerPolicy); + .AddPolicyHandler(CreateCircuitBreakerPolicy()); - AddAdapterClient(services, DownloadClientTypes.Qbittorrent, useCookies: true, retryPolicy, circuitBreakerPolicy); - AddAdapterClient(services, DownloadClientTypes.Transmission, useCookies: false, retryPolicy, circuitBreakerPolicy); - AddAdapterClient(services, DownloadClientTypes.Sabnzbd, useCookies: false, retryPolicy, circuitBreakerPolicy); - AddAdapterClient(services, DownloadClientTypes.Nzbget, useCookies: false, retryPolicy, circuitBreakerPolicy); + AddAdapterClient(services, DownloadClientTypes.Qbittorrent, useCookies: true, retryPolicy); + AddAdapterClient(services, DownloadClientTypes.Transmission, useCookies: false, retryPolicy); + AddAdapterClient(services, DownloadClientTypes.Sabnzbd, useCookies: false, retryPolicy); + AddAdapterClient(services, DownloadClientTypes.Nzbget, useCookies: false, retryPolicy); return services; } @@ -264,18 +267,23 @@ private static IServiceCollection AddNzbgetWorkflows(this IServiceCollection ser return services; } + // A new breaker per call. Returning a fresh instance is the whole point: one shared instance + // would put every download client behind a single circuit. + internal static IAsyncPolicy CreateCircuitBreakerPolicy() => + HttpPolicyExtensions.HandleTransientHttpError() + .CircuitBreakerAsync(3, TimeSpan.FromSeconds(30)); + private static void AddAdapterClient( IServiceCollection services, string name, bool useCookies, - IAsyncPolicy retryPolicy, - IAsyncPolicy circuitBreakerPolicy) + IAsyncPolicy retryPolicy) { services.AddHttpClient(name) .ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(30)) .ConfigurePrimaryHttpMessageHandler(() => CreateHandler(useCookies)) .SetHandlerLifetime(TimeSpan.FromMinutes(5)) - .AddPolicyHandler(circuitBreakerPolicy) + .AddPolicyHandler(CreateCircuitBreakerPolicy()) .AddPolicyHandler(retryPolicy); } diff --git a/listenarr.infrastructure/DependencyInjection/Downloads/DownloadRegistrationExtensions.cs b/listenarr.infrastructure/DependencyInjection/Downloads/DownloadRegistrationExtensions.cs index 9ce5e6fc7..f9a278c5e 100644 --- a/listenarr.infrastructure/DependencyInjection/Downloads/DownloadRegistrationExtensions.cs +++ b/listenarr.infrastructure/DependencyInjection/Downloads/DownloadRegistrationExtensions.cs @@ -76,6 +76,8 @@ public static IServiceCollection AddDownloadServices( provider.GetRequiredService()); services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(); services.AddScoped(); services.AddOptions() .Bind(configuration.GetSection("FileMover")) diff --git a/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs b/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs index a179b2691..5625b1f2e 100644 --- a/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs +++ b/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs @@ -42,6 +42,9 @@ public static IServiceCollection AddLibraryServices(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(provider => + provider.GetRequiredService()); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/listenarr.infrastructure/DependencyInjection/Metadata/MetadataRegistrationExtensions.cs b/listenarr.infrastructure/DependencyInjection/Metadata/MetadataRegistrationExtensions.cs index 5386cfdf3..51451a927 100644 --- a/listenarr.infrastructure/DependencyInjection/Metadata/MetadataRegistrationExtensions.cs +++ b/listenarr.infrastructure/DependencyInjection/Metadata/MetadataRegistrationExtensions.cs @@ -42,6 +42,7 @@ public static IServiceCollection AddMetadataServices(this IServiceCollection ser services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddHttpClient() .AddPolicyHandler(CreateExternalMetadataRetryPolicy()); services.AddSingleton(); diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QBittorrentHelpers.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QBittorrentHelpers.cs index 2ee964c35..f7410d402 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QBittorrentHelpers.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QBittorrentHelpers.cs @@ -69,5 +69,37 @@ public static void LogCategoryFiltering(ILogger logger, string? category) logger.LogInformation("Fetching qBittorrent queue filtered by category: {Category}", category); } } + + /// + /// Builds the base URL used for every qBittorrent WebAPI call, including an optional + /// "urlBase" path prefix from client settings (e.g. "/qbittorrent") for instances that + /// sit behind a reverse proxy at a path prefix. + /// Unlike Transmission's urlBase (which replaces the whole RPC path to match Transmission's + /// own configurable --rpc-url-base setting), qBittorrent has no equivalent server-side base + /// path setting, so this is a plain prefix prepended before the fixed "/api/v2/..." routes - + /// it must match whatever prefix the reverse proxy strips before forwarding to qBittorrent. + /// + /// The download client configuration providing host/port and settings. + /// The authority (scheme://host:port) with the normalized urlBase prefix appended, if configured. + public static string BuildBaseUrl(DownloadClientConfiguration client) + { + var authority = DownloadClientUriBuilder.BuildAuthority(client); + var prefix = ResolveUrlBasePrefix(client); + return prefix.Length == 0 ? authority : authority + prefix; + } + + private static string ResolveUrlBasePrefix(DownloadClientConfiguration client) + { + if (client.Settings?.TryGetValue("urlBase", out var urlBaseObj) is true) + { + var trimmed = urlBaseObj?.ToString()?.Trim().TrimEnd('/'); + if (!string.IsNullOrEmpty(trimmed)) + { + return trimmed.StartsWith('/') ? trimmed : "/" + trimmed; + } + } + + return string.Empty; + } } } diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentAddWorkflow.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentAddWorkflow.cs index 1739841a0..8e1b5a4b8 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentAddWorkflow.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentAddWorkflow.cs @@ -28,7 +28,7 @@ public async Task AddAsync( throw new DownloadClientSubmissionException("qBittorrent requires a prepared torrent submission."); } - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + var baseUrl = QBittorrentHelpers.BuildBaseUrl(client); using var httpClient = httpClientFactory.CreateClient(clientType); try diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentAuthSession.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentAuthSession.cs index 19e986d69..b818fb68d 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentAuthSession.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentAuthSession.cs @@ -24,7 +24,7 @@ public QbittorrentAuthSession(ILogger logger) public async Task LoginAsync(HttpClient httpClient, DownloadClientConfiguration client, CancellationToken cancellationToken = default) { - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + var baseUrl = QBittorrentHelpers.BuildBaseUrl(client); using var loginData = new FormUrlEncodedContent( [ diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentConnectionTester.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentConnectionTester.cs index da741b586..ca8b3d306 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentConnectionTester.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentConnectionTester.cs @@ -37,7 +37,7 @@ public QbittorrentConnectionTester(IHttpClientFactory httpClientFactory, ILogger { try { - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + var baseUrl = QBittorrentHelpers.BuildBaseUrl(client); using var http = _httpClientFactory.CreateClient(_clientType); using var resp = await http.GetAsync($"{baseUrl}/api/v2/app/version", ct); diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportItemResolver.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportItemResolver.cs index 3fcb32df1..1251a7e00 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportItemResolver.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportItemResolver.cs @@ -118,7 +118,7 @@ public async Task GetImportItemAsync( string hash, CancellationToken ct) { - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + var baseUrl = QBittorrentHelpers.BuildBaseUrl(client); try { diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportMarkerWorkflow.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportMarkerWorkflow.cs index abf1cb2e8..56acc4bd8 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportMarkerWorkflow.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportMarkerWorkflow.cs @@ -33,7 +33,7 @@ public async Task MarkItemAsImportedAsync(DownloadClientConfiguration clie return true; // No-op is success } - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + var baseUrl = QBittorrentHelpers.BuildBaseUrl(client); try { using var httpClient = httpClientFactory.CreateClient(clientType); diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentItemFetchWorkflow.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentItemFetchWorkflow.cs index 45a624f1d..c1cf24784 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentItemFetchWorkflow.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentItemFetchWorkflow.cs @@ -26,7 +26,7 @@ public async Task> GetItemsAsync(DownloadClientConfigur var items = new List(); if (client == null) return items; - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + var baseUrl = QBittorrentHelpers.BuildBaseUrl(client); var categoryFilter = QBittorrentHelpers.BuildCategoryParameter(client.Settings, "&"); try @@ -87,14 +87,31 @@ public async Task> GetItemsAsync(DownloadClientConfigur foreach (var torrent in torrents) { - items.Add(QbittorrentResponseMapper.MapDownloadClientItem( - torrent, - client, - removeCompletedDownloads, - globalMaxRatioEnabled, - globalMaxRatio, - globalMaxSeedingTimeEnabled, - globalMaxSeedingTime)); + // Same per-item isolation as the queue fetch. This list is what completion and + // import decisions are made from, so a torrent lost here is not just a missing + // row in a view: everything after it stops being considered for import at all. + try + { + items.Add(QbittorrentResponseMapper.MapDownloadClientItem( + torrent, + client, + removeCompletedDownloads, + globalMaxRatioEnabled, + globalMaxRatio, + globalMaxSeedingTimeEnabled, + globalMaxSeedingTime)); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + var hash = torrent.TryGetValue("hash", out var hashEl) && hashEl.ValueKind == JsonValueKind.String + ? hashEl.GetString() ?? string.Empty + : string.Empty; + logger.LogWarning( + ex, + "Skipping unreadable qBittorrent torrent {TorrentHash} for client {ClientId}; the rest of the item list is unaffected", + LogRedaction.SanitizeText(hash), + LogRedaction.SanitizeText(client.Id)); + } } } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentQueueFetchWorkflow.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentQueueFetchWorkflow.cs index bd3c62a0a..eaed382a3 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentQueueFetchWorkflow.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentQueueFetchWorkflow.cs @@ -24,7 +24,7 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli if (client == null) return items; var isMonitorPoll = ids.Count > 0; - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + var baseUrl = QBittorrentHelpers.BuildBaseUrl(client); try { @@ -98,17 +98,35 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli foreach (var torrent in torrents) { - var hash = torrent.TryGetValue("hash", out var hashEl) ? hashEl.GetString() ?? string.Empty : string.Empty; + var hash = torrent.TryGetValue("hash", out var hashEl) && hashEl.ValueKind == JsonValueKind.String + ? hashEl.GetString() ?? string.Empty + : string.Empty; - List> files = []; - using var filesResp = await httpClient.GetAsync($"{baseUrl}/api/v2/torrents/files?hash={hash}", ct); - if (filesResp.IsSuccessStatusCode) + // One torrent that cannot be read must not take the rest of the response with + // it. Without this, an exception raised while mapping torrent N escapes the + // loop and is caught only by the handler below, so torrents N..end are dropped + // while the poll still reports itself as a healthy live snapshot: the queue + // simply appears shorter, with nothing to say a row was lost. + try { - var filesJson = await filesResp.Content.ReadAsStringAsync(ct); - files = JsonSerializer.Deserialize>>(filesJson) ?? []; - } + List> files = []; + using var filesResp = await httpClient.GetAsync($"{baseUrl}/api/v2/torrents/files?hash={hash}", ct); + if (filesResp.IsSuccessStatusCode) + { + var filesJson = await filesResp.Content.ReadAsStringAsync(ct); + files = JsonSerializer.Deserialize>>(filesJson) ?? []; + } - items.Add(QbittorrentResponseMapper.MapQueueItem(torrent, client, files)); + items.Add(QbittorrentResponseMapper.MapQueueItem(torrent, client, files)); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning( + ex, + "Skipping unreadable qBittorrent torrent {TorrentHash} for client {ClientId}; the rest of the queue is unaffected", + LogRedaction.SanitizeText(hash), + LogRedaction.SanitizeText(client.Id)); + } } } catch (DownloadClientAdapterPollingException) diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentRemovalWorkflow.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentRemovalWorkflow.cs index 3d2691d5c..24a333a60 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentRemovalWorkflow.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentRemovalWorkflow.cs @@ -34,7 +34,7 @@ public async Task RemoveAsync(DownloadClientConfiguration client, string i ArgumentNullException.ThrowIfNull(client); if (string.IsNullOrEmpty(id)) throw new ArgumentNullException(nameof(id)); - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + var baseUrl = QBittorrentHelpers.BuildBaseUrl(client); try { diff --git a/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdConnectionTester.cs b/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdConnectionTester.cs index 2ef850f95..b71235ca3 100644 --- a/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdConnectionTester.cs +++ b/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdConnectionTester.cs @@ -8,6 +8,7 @@ * (at your option) any later version. */ using System.Net; +using System.Text.Json; using Microsoft.Extensions.Logging; namespace Listenarr.Infrastructure.DownloadClients.Sabnzbd @@ -51,7 +52,20 @@ internal sealed class SabnzbdConnectionTester( return (false, $"SABnzbd: returned {resp.StatusCode}"); } - return (true, "SABnzbd: connected"); + // Version check passed. If a category is configured, verify it exists in + // SABnzbd: unknown categories are silently reassigned to Default, which + // hides jobs from category-scoped reads and strands them unimported. + // This is an advisory, not a hard failure, so the connection tests green. + var configuredCategory = DownloadClientCategoryFilter.GetConfiguredCategory(client); + if (string.IsNullOrWhiteSpace(configuredCategory)) + { + return (true, "SABnzbd: connected"); + } + + var categoryWarning = await CheckCategoryExistsAsync(requestContext, http, configuredCategory, ct); + return categoryWarning is null + ? (true, "SABnzbd: connected") + : (true, categoryWarning); } catch (HttpRequestException httpEx) { @@ -69,5 +83,58 @@ internal sealed class SabnzbdConnectionTester( return (false, "SABnzbd: connection failed"); } } + + private async Task CheckCategoryExistsAsync( + SabnzbdRequestContext requestContext, + HttpClient http, + string configuredCategory, + CancellationToken ct) + { + try + { + var url = requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "get_cats", + ["output"] = "json" + }); + var resp = await http.GetAsync(url, ct); + if (!resp.IsSuccessStatusCode) + { + // Best effort: an unavailable category list must not fail an + // otherwise healthy connection. + return null; + } + + var json = await resp.Content.ReadAsStringAsync(ct); + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("categories", out var categories) || + categories.ValueKind != JsonValueKind.Array) + { + return null; + } + + foreach (var category in categories.EnumerateArray()) + { + var name = category.ValueKind == JsonValueKind.String ? category.GetString() : null; + if (string.Equals(name?.Trim(), configuredCategory.Trim(), StringComparison.OrdinalIgnoreCase)) + { + return null; + } + } + + return $"SABnzbd: connected, but category '{configuredCategory}' does not exist in SABnzbd. " + + "Jobs will fall into Default and may not import. Create the category in SABnzbd (Config > Categories)."; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogDebug(ex, "SABnzbd get_cats probe failed (non-fatal)"); + return null; + } + } } } diff --git a/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdQueueFetchWorkflow.cs b/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdQueueFetchWorkflow.cs index 19c398831..3e438944e 100644 --- a/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdQueueFetchWorkflow.cs +++ b/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdQueueFetchWorkflow.cs @@ -112,7 +112,7 @@ public async Task> GetQueueAsync( { try { - var queueItem = SabnzbdResponseMapper.MapQueueSlotToQueueItem(client, slot, configuredCategory ?? string.Empty, speed); + var queueItem = SabnzbdResponseMapper.MapQueueSlotToQueueItem(client, slot, configuredCategory ?? string.Empty, speed, monitoredIdSet); if (queueItem != null) { items.Add(queueItem); @@ -141,7 +141,7 @@ public async Task> GetQueueAsync( var historyLimit = isMonitorPoll ? MonitorHistoryLimit : DisplayHistoryLimit; var historyFailureIsFatal = isMonitorPoll && missingTrackedIds.Count > 0; - await AddHistoryItemsAsync(client, requestContext, configuredCategory, items, http, historyLimit, historyFailureIsFatal, ct); + await AddHistoryItemsAsync(client, requestContext, configuredCategory, items, http, historyLimit, historyFailureIsFatal, monitoredIdSet, ct); } catch (DownloadClientAdapterPollingException) { @@ -167,6 +167,7 @@ private async Task AddHistoryItemsAsync( HttpClient http, int historyLimit, bool historyFailureIsFatal, + ISet monitoredIdSet, CancellationToken ct) { var existingNzoIds = new HashSet(items.Select(i => i.Id), StringComparer.OrdinalIgnoreCase); @@ -220,7 +221,7 @@ private async Task AddHistoryItemsAsync( { try { - var historyItem = SabnzbdResponseMapper.MapHistorySlotToQueueItem(client, slot, configuredCategory ?? string.Empty, existingNzoIds); + var historyItem = SabnzbdResponseMapper.MapHistorySlotToQueueItem(client, slot, configuredCategory ?? string.Empty, existingNzoIds, monitoredIdSet); if (historyItem != null) { items.Add(historyItem); diff --git a/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs b/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs index bb5f289c2..58386ad9c 100644 --- a/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs +++ b/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs @@ -27,14 +27,19 @@ internal static class SabnzbdResponseMapper DownloadClientConfiguration client, JsonElement slot, string configuredCategory, - double speed) + double speed, + ISet? monitoredIds = null) { var nzoId = GetString(slot, "nzo_id"); var filename = GetString(slot, "filename", "Unknown"); var status = GetString(slot, "status", "Unknown"); var category = GetString(slot, "cat"); - if (!DownloadClientCategoryFilter.Matches(configuredCategory, category)) + // Category filtering scopes untracked discovery; it must never hide a job we + // grabbed. SABnzbd silently reassigns unknown categories to Default, so a slot + // whose nzo_id we track is reconciled by download ID regardless of its category. + var isTracked = monitoredIds is not null && !string.IsNullOrEmpty(nzoId) && monitoredIds.Contains(nzoId); + if (!isTracked && !DownloadClientCategoryFilter.Matches(configuredCategory, category)) return null; var sizeMb = GetDouble(slot, "mb"); @@ -49,6 +54,17 @@ internal static class SabnzbdResponseMapper var mappedStatus = MapQueueStatus(status); var storagePath = GetString(slot, "storage"); var explicitContentPath = string.IsNullOrWhiteSpace(storagePath) ? null : storagePath; + + // SABnzbd reports an item as "Completed" in the active queue briefly before + // archiving it to history with the real storage path - active-queue slots don't + // reliably expose it (see comment below). Reporting completion here anyway lets + // QueueItemConverter mark the download Completed with no DownloadPath, which + // permanently blocks import. Excluding it instead makes the caller treat this + // download as missing from the active queue, which triggers a same-cycle history + // lookup - and history has the storage path by then. + if (mappedStatus == "completed" && explicitContentPath == null) + return null; + var remotePath = explicitContentPath ?? (string.IsNullOrWhiteSpace(client.DownloadPath) ? null : client.DownloadPath); @@ -85,14 +101,18 @@ internal static class SabnzbdResponseMapper DownloadClientConfiguration client, JsonElement slot, string configuredCategory, - ISet existingNzoIds) + ISet existingNzoIds, + ISet? monitoredIds = null) { var nzoId = GetString(slot, "nzo_id"); if (string.IsNullOrEmpty(nzoId) || existingNzoIds.Contains(nzoId)) return null; var histCategory = GetString(slot, "category"); - if (!DownloadClientCategoryFilter.Matches(configuredCategory, histCategory)) + // See MapQueueSlotToQueueItem: a tracked job reassigned to Default by SABnzbd + // must still be reconciled by download ID, not hidden by the category filter. + var isTracked = monitoredIds is not null && monitoredIds.Contains(nzoId); + if (!isTracked && !DownloadClientCategoryFilter.Matches(configuredCategory, histCategory)) return null; var histStatus = GetString(slot, "status"); diff --git a/listenarr.infrastructure/Downloads/Cleanup/MovedDownloadCleanupBackgroundService.cs b/listenarr.infrastructure/Downloads/Cleanup/MovedDownloadCleanupBackgroundService.cs new file mode 100644 index 000000000..1b9de5bee --- /dev/null +++ b/listenarr.infrastructure/Downloads/Cleanup/MovedDownloadCleanupBackgroundService.cs @@ -0,0 +1,79 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Downloads.Cleanup; + +/// +/// Background service that handles moved downloads to remove them from the client. +/// Runs every 10 seconds to check for moved downloads. +/// +public class MovedDownloadCleanupService( + IMovedDownloadCleanupProcessor processor, + ILogger logger, + IWorkerCycleRunner cycleRunner, + IServiceScopeFactory scopeFactory) : BackgroundService +{ + private TimeSpan _pollingInterval = TimeSpan.FromSeconds(10); + + public override async Task StartAsync(CancellationToken cancellationToken) + { + logger.LogInformation("MovedDownloadCleanupService starting"); + + try + { + using var scope = scopeFactory.CreateScope(); + var configurationService = scope.ServiceProvider + .GetRequiredService(); + var settings = await configurationService.GetApplicationSettingsAsync(); + if (settings.PollingIntervalSeconds > 0) + { + _pollingInterval = TimeSpan.FromSeconds( + settings.PollingIntervalSeconds); + } + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + logger.LogInformation("MovedDownloadCleanupService startup canceled"); + } + catch (OperationCanceledException ex) + { + logger.LogWarning( + ex, + "MovedDownloadCleanupService settings load canceled/timed out during startup; using default interval"); + } + catch (Exception ex) + when (ex is not (OperationCanceledException + or OutOfMemoryException + or StackOverflowException)) + { + logger.LogWarning( + ex, + "Failed to load polling interval from settings, using default"); + } + + await base.StartAsync(cancellationToken); + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + logger.LogInformation("MovedDownloadCleanupService stopping"); + await base.StopAsync(cancellationToken); + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + logger.LogInformation( + "MovedDownloadCleanupService background task started"); + + await cycleRunner.RunPeriodicAsync( + nameof(MovedDownloadCleanupService), + initialDelay: null, + intervalProvider: () => _pollingInterval, + runCycle: processor.RunCycleAsync, + cancellationToken); + + logger.LogInformation( + "MovedDownloadCleanupService background task stopped"); + } +} diff --git a/listenarr.infrastructure/Downloads/Cleanup/MovedDownloadCleanupService.cs b/listenarr.infrastructure/Downloads/Cleanup/MovedDownloadCleanupService.cs index 94f0ad511..ea9f9c339 100644 --- a/listenarr.infrastructure/Downloads/Cleanup/MovedDownloadCleanupService.cs +++ b/listenarr.infrastructure/Downloads/Cleanup/MovedDownloadCleanupService.cs @@ -22,69 +22,6 @@ namespace Listenarr.Infrastructure.Downloads.Cleanup { - /// - /// Background service that handles moved downloads to remove them from client - /// Runs every 10 seconds to check for moved downloads - /// - public class MovedDownloadCleanupService( - IMovedDownloadCleanupProcessor processor, - ILogger logger, - IWorkerCycleRunner cycleRunner, - IServiceScopeFactory scopeFactory) : BackgroundService - { - private TimeSpan _pollingInterval = TimeSpan.FromSeconds(10); - - public override async Task StartAsync(CancellationToken cancellationToken) - { - logger.LogInformation("MovedDownloadCleanupService starting"); - - try - { - using var scope = scopeFactory.CreateScope(); - var configurationService = scope.ServiceProvider.GetRequiredService(); - var settings = await configurationService.GetApplicationSettingsAsync(); - if (settings.PollingIntervalSeconds > 0) - { - _pollingInterval = TimeSpan.FromSeconds(settings.PollingIntervalSeconds); - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - logger.LogInformation("MovedDownloadCleanupService startup canceled"); - } - catch (OperationCanceledException ex) - { - logger.LogWarning(ex, "MovedDownloadCleanupService settings load canceled/timed out during startup; using default interval"); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - logger.LogWarning(ex, "Failed to load polling interval from settings, using default"); - } - - await base.StartAsync(cancellationToken); - } - - public override async Task StopAsync(CancellationToken cancellationToken) - { - logger.LogInformation("MovedDownloadCleanupService stopping"); - await base.StopAsync(cancellationToken); - } - - protected override async Task ExecuteAsync(CancellationToken cancellationToken) - { - logger.LogInformation("MovedDownloadCleanupService background task started"); - - await cycleRunner.RunPeriodicAsync( - nameof(MovedDownloadCleanupService), - initialDelay: null, - intervalProvider: () => _pollingInterval, - runCycle: processor.RunCycleAsync, - cancellationToken); - - logger.LogInformation("MovedDownloadCleanupService background task stopped"); - } - } - public class MovedDownloadCleanupProcessor( IServiceScopeFactory scopeFactory, ILogger logger) : IMovedDownloadCleanupProcessor @@ -107,9 +44,12 @@ private sealed record ImportProof( ImportProofKind Kind, string CorrelationId, string? ProcessingJobId, - DateTime? ProvenAt) + DateTime? ProvenAt, + bool SourceRetained = false) { - public bool AllowsDestructiveCleanup => Kind is not ImportProofKind.LegacyMovedState; + public bool AllowsDestructiveCleanup => + Kind is not ImportProofKind.LegacyMovedState + && !SourceRetained; } /// @@ -386,7 +326,14 @@ private static async Task ResolveImportProofAsync( ImportProofKind.CompletedProcessingJob, completedJob.GetOrCreateCorrelationId(), completedJob.Id, - completedJob.CompletedAt); + completedJob.CompletedAt, + completedJob.JobData.TryGetValue( + "SourceRetained", + out var retainedValue) + && bool.TryParse( + retainedValue?.ToString(), + out var sourceRetained) + && sourceRetained); } if (download.LastImportedAt.HasValue) @@ -452,7 +399,8 @@ private static Dictionary BuildCleanupDetails( { ["ImportProof"] = proof.Kind.ToString(), ["RemovalPolicy"] = removalPolicy, - ["DeleteFiles"] = deleteFiles + ["DeleteFiles"] = deleteFiles, + ["SourceRetained"] = proof.SourceRetained }; if (!string.IsNullOrWhiteSpace(proof.ProcessingJobId)) diff --git a/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs b/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs index 84bc08f68..1e2792a4e 100644 --- a/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs +++ b/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs @@ -348,6 +348,10 @@ await historyRepository.AddAsync(new History { JobId = job.Id, result.Action, + result.RequestedAction, + result.EffectiveAction, + result.SourceDisposition, + result.WarningCode, result.SourcePath, result.FinalPath, result.WasRegisteredToAudiobook @@ -355,6 +359,9 @@ await historyRepository.AddAsync(new History }, cancellationToken); } + job.JobData["SourceRetained"] = results.Any(result => + result.SourceDisposition + == ImportSourceDisposition.Retained); job.SetCheckpoint("FilesImported", results.Count); await downloadProcessingJobService.UpdateJobAsync(job); } diff --git a/listenarr.infrastructure/Downloads/Submission/GenericUsenetSourceResolver.cs b/listenarr.infrastructure/Downloads/Submission/GenericUsenetSourceResolver.cs index 7e402490e..88374f993 100644 --- a/listenarr.infrastructure/Downloads/Submission/GenericUsenetSourceResolver.cs +++ b/listenarr.infrastructure/Downloads/Submission/GenericUsenetSourceResolver.cs @@ -48,7 +48,15 @@ public async Task ResolveAsync( candidate.SourceDescriptor.FileName ?? $"{SanitizeFileName(candidate.Title)}.nzb"); } + // Beyond filesystem-invalid characters, '"' and '\\' must also be stripped: this + // filename is later passed as the multipart Content-Disposition "filename" parameter + // when submitting to a download client (e.g. SABnzbd), and both characters are valid + // on Linux/macOS filesystems but break .NET's ContentDispositionHeaderValue quoting, + // throwing ArgumentException and silently failing the whole download. See + // https://github.com/Listenarrs/Listenarr/issues/808. private static string SanitizeFileName(string value) => string.Concat(value.Select(character => - Path.GetInvalidFileNameChars().Contains(character) ? '_' : character)); + Path.GetInvalidFileNameChars().Contains(character) || character is '"' or '\\' + ? '_' + : character)); } diff --git a/listenarr.infrastructure/Ffmpeg/Metadata/FfprobeTagMetadataMapper.cs b/listenarr.infrastructure/Ffmpeg/Metadata/FfprobeTagMetadataMapper.cs index a41b6f8ee..2c24bac76 100644 --- a/listenarr.infrastructure/Ffmpeg/Metadata/FfprobeTagMetadataMapper.cs +++ b/listenarr.infrastructure/Ffmpeg/Metadata/FfprobeTagMetadataMapper.cs @@ -24,6 +24,19 @@ public static void Apply(AudioMetadata metadata, JsonElement tags) metadata.TrackNumber ??= ParseNumericTag(tags, "track", "TRACK", "tracknumber", "TRACKNUMBER"); metadata.DiscNumber ??= ParseNumericTag(tags, "disc", "DISC", "discnumber", "DISCNUMBER"); metadata.Year ??= ParseNumericTag(tags, "date", "DATE", "year", "YEAR"); + + // Embedded audiobook identifiers. Audible/OpenAudible-tagged m4b files carry + // the ASIN (and sometimes an ISBN) in these tags; reading them here lets a scan + // adopt the identifier onto a bare audiobook and auto-populate its metadata. + if (string.IsNullOrWhiteSpace(metadata.Asin)) + { + metadata.Asin = GetTag(tags, "ASIN", "asin", "AUDIBLE_ASIN", "audible_asin"); + } + + if (string.IsNullOrWhiteSpace(metadata.Isbn)) + { + metadata.Isbn = GetTag(tags, "ISBN", "isbn"); + } } private static string FirstNonEmpty(params string?[] candidates) diff --git a/listenarr.infrastructure/FileSystem/CompatibilityFilePublicationJournalStore.cs b/listenarr.infrastructure/FileSystem/CompatibilityFilePublicationJournalStore.cs new file mode 100644 index 000000000..cff16da54 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/CompatibilityFilePublicationJournalStore.cs @@ -0,0 +1,170 @@ +using Microsoft.EntityFrameworkCore; +using Listenarr.Domain.Audiobooks.Enumerations; +using Listenarr.Infrastructure.Persistence; + +namespace Listenarr.Infrastructure.FileSystem; + +internal sealed record CompatibilityFilePublicationClaim( + Guid OperationId, + FileAction RequestedAction, + string SourcePath, + string DestinationPath, + long SourceLength, + string SourceSha256, + bool IsCompanionFile); + +internal sealed class CompatibilityFilePublicationJournalStore( + IDbContextFactory dbContextFactory, + TimeProvider timeProvider) +{ + public CompatibilityFilePublicationJournal? Get(Guid operationId) => + GetAsync(operationId, CancellationToken.None) + .GetAwaiter() + .GetResult(); + + public async Task GetAsync( + Guid operationId, + CancellationToken cancellationToken) + { + await using var context = await dbContextFactory.CreateDbContextAsync( + cancellationToken); + return await context.CompatibilityFilePublicationJournals + .AsNoTracking() + .SingleOrDefaultAsync( + journal => journal.OperationId == operationId, + cancellationToken); + } + + public async Task GetOrCreateAsync( + CompatibilityFilePublicationClaim claim, + CancellationToken cancellationToken) + { + var existing = await GetAsync(claim.OperationId, cancellationToken); + if (existing != null) + { + ValidateClaim(existing, claim); + return existing; + } + + var now = timeProvider.GetUtcNow().UtcDateTime; + var journal = new CompatibilityFilePublicationJournal + { + OperationId = claim.OperationId, + RequestedAction = claim.RequestedAction, + EffectiveAction = FileAction.Copy, + SourceDisposition = CompatibilitySourceDisposition.Retained, + SourcePath = Path.GetFullPath(claim.SourcePath), + DestinationPath = Path.GetFullPath(claim.DestinationPath), + SourceLength = claim.SourceLength, + SourceSha256 = claim.SourceSha256, + IsCompanionFile = claim.IsCompanionFile, + CreatedAt = now, + UpdatedAt = now + }; + + await using var context = await dbContextFactory.CreateDbContextAsync( + cancellationToken); + context.CompatibilityFilePublicationJournals.Add(journal); + try + { + await context.SaveChangesAsync(cancellationToken); + return journal; + } + catch (UniqueConstraintViolationException) + { + var raced = await GetAsync(claim.OperationId, cancellationToken) + ?? throw new InvalidOperationException( + "The compatibility publication claim raced but could not be reloaded."); + ValidateClaim(raced, claim); + return raced; + } + } + + public CompatibilityFilePublicationJournal Advance( + Guid operationId, + CompatibilityFilePublicationState state, + long? targetLength = null, + string? targetSha256 = null, + int? audiobookId = null, + string? error = null) => + AdvanceAsync( + operationId, + state, + targetLength, + targetSha256, + audiobookId, + error, + CancellationToken.None).GetAwaiter().GetResult(); + + public async Task AdvanceAsync( + Guid operationId, + CompatibilityFilePublicationState state, + long? targetLength, + string? targetSha256, + int? audiobookId, + string? error, + CancellationToken cancellationToken) + { + await using var context = await dbContextFactory.CreateDbContextAsync( + cancellationToken); + var journal = await context.CompatibilityFilePublicationJournals + .SingleOrDefaultAsync( + candidate => candidate.OperationId == operationId, + cancellationToken) + ?? throw new InvalidOperationException( + "The compatibility publication journal no longer exists."); + + if (journal.ProtocolVersion != CompatibilityFilePublicationProtocol.Current) + { + throw new InvalidOperationException( + "The compatibility publication journal protocol is unsupported."); + } + if (journal.State == CompatibilityFilePublicationState.NeedsAttention + && state != CompatibilityFilePublicationState.NeedsAttention) + { + throw new InvalidOperationException( + "A compatibility publication requiring attention cannot advance."); + } + if (state != CompatibilityFilePublicationState.NeedsAttention + && state < journal.State) + { + throw new InvalidOperationException( + "A compatibility publication cannot move to an earlier state."); + } + + journal.State = state; + journal.TargetLength = targetLength ?? journal.TargetLength; + journal.TargetSha256 = targetSha256 ?? journal.TargetSha256; + journal.AudiobookId = audiobookId ?? journal.AudiobookId; + journal.Error = error; + journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; + await context.SaveChangesAsync(cancellationToken); + return journal; + } + + private static void ValidateClaim( + CompatibilityFilePublicationJournal journal, + CompatibilityFilePublicationClaim claim) + { + if (journal.ProtocolVersion != CompatibilityFilePublicationProtocol.Current + || 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 + || journal.IsCompanionFile != claim.IsCompanionFile + || !string.Equals( + journal.SourceSha256, + claim.SourceSha256, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "The compatibility publication operation ID is already bound to another claim."); + } + } +} diff --git a/listenarr.infrastructure/FileSystem/FileMover.Actions.cs b/listenarr.infrastructure/FileSystem/FileMover.Actions.cs index 2d50b9b19..ddff1c3aa 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.Actions.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.Actions.cs @@ -17,7 +17,9 @@ public partial class FileMover destination, operationId, expectedRegisteredPhysicalObjectIdentity: null, - expectedSourceProof: null); + expectedSourceProof: null, + isCompanionFile: false, + companionAudiobookId: null); } public Task PrepareActionForRegistrationAsync( @@ -35,7 +37,9 @@ public partial class FileMover destination, operationId, expectedRegisteredPhysicalObjectIdentity, - expectedSourceProof: null); + expectedSourceProof: null, + isCompanionFile: false, + companionAudiobookId: null); } public Task PrepareActionForRegistrationAsync( @@ -53,7 +57,9 @@ public partial class FileMover destination, operationId, expectedRegisteredPhysicalObjectIdentity, - expectedSourceProof); + expectedSourceProof, + isCompanionFile: false, + companionAudiobookId: null); } private async Task @@ -63,7 +69,9 @@ private async Task string destination, Guid operationId, string? expectedRegisteredPhysicalObjectIdentity, - FilePublicationSourceProof? expectedSourceProof) + FilePublicationSourceProof? expectedSourceProof, + bool isCompanionFile, + int? companionAudiobookId) { if (action is not ( FileAction.Move or @@ -103,7 +111,9 @@ FileAction.Copy or destination, operationId, expectedRegisteredPhysicalObjectIdentity, - expectedSourceProof); + expectedSourceProof, + isCompanionFile, + companionAudiobookId); if (markerless.Handled) { return markerless.Lease; @@ -314,7 +324,9 @@ private async Task PerformMarkerlessCopyOrHardlinkAsync( destination, operationId, expectedRegisteredPhysicalObjectIdentity: null, - expectedSourceProof); + expectedSourceProof, + isCompanionFile: false, + companionAudiobookId: null); if (!markerless.Handled) { LogMutation( @@ -408,12 +420,30 @@ await MarkMarkerlessRegistrationNeedsAttentionAsync( private void LogMutation(FileMutationOutcome outcome, FileAction action, string source, string? destination, string? reason = null) { var result = new FileMutationResult(outcome, action, source, destination, reason); - _logger.LogInformation( - "File mutation {Outcome}: {Action} {Source} -> {Destination}. Reason: {Reason}", + var arguments = new object?[] + { result.Outcome, result.Action, LogRedaction.SanitizeFilePath(result.SourcePath), LogRedaction.SanitizeFilePath(result.DestinationPath ?? string.Empty), - LogRedaction.SanitizeText(result.Reason ?? string.Empty)); + LogRedaction.SanitizeText(result.Reason ?? string.Empty) + }; + const string template = + "File mutation {Outcome}: {Action} {Source} -> {Destination}. Reason: {Reason}"; + switch (outcome) + { + case FileMutationOutcome.Blocked: + _logger.LogWarning(template, arguments); + break; + case FileMutationOutcome.Failed: + _logger.LogError(template, arguments); + break; + case FileMutationOutcome.Skipped: + _logger.LogDebug(template, arguments); + break; + default: + _logger.LogInformation(template, arguments); + break; + } } } diff --git a/listenarr.infrastructure/FileSystem/FileMover.CompatibilityPathLocks.cs b/listenarr.infrastructure/FileSystem/FileMover.CompatibilityPathLocks.cs new file mode 100644 index 000000000..d66c004c2 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/FileMover.CompatibilityPathLocks.cs @@ -0,0 +1,30 @@ +namespace Listenarr.Infrastructure.FileSystem; + +public partial class FileMover +{ + private static FileMoveEndpoint? ResolveCompatibilityFileMoveEndpoint( + string path) + { + try + { + var fullPath = Path.GetFullPath(path); + if (!TryResolvePhysicalPath(fullPath, out var physical) + || physical.EncounteredLink) + { + return null; + } + + var resolvedPath = physical.ResolvedPath; + return new FileMoveEndpoint( + resolvedPath.ToUpperInvariant(), + resolvedPath); + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException or ArgumentException + or InvalidOperationException or NotSupportedException + or PathTooLongException) + { + return null; + } + } +} diff --git a/listenarr.infrastructure/FileSystem/FileMover.CompatibilityRegistration.cs b/listenarr.infrastructure/FileSystem/FileMover.CompatibilityRegistration.cs new file mode 100644 index 000000000..c5fac59ba --- /dev/null +++ b/listenarr.infrastructure/FileSystem/FileMover.CompatibilityRegistration.cs @@ -0,0 +1,388 @@ +using Microsoft.Extensions.Logging; +using Listenarr.Domain.Audiobooks.Enumerations; + +namespace Listenarr.Infrastructure.FileSystem; + +public partial class FileMover +{ + private async Task + PrepareCompatibilityActionForRegistrationAsync( + FilePublicationPlan plan, + string source, + string destination, + Guid operationId, + string? expectedRegisteredPhysicalObjectIdentity, + FilePublicationSourceProof expectedSourceProof, + bool isCompanionFile) + { + if (_weakPublicationMode == WeakPublicationMode.Disabled) + { + return CompatibilityBlocked( + plan, + "compatibility_publication_disabled", + "Compatibility publication is disabled by FileMover:WeakPublicationMode."); + } + if (_compatibilityFilePublicationJournalStore == null) + { + return CompatibilityBlocked( + plan, + "compatibility_journal_unavailable", + "Compatibility publication requires durable database journal storage."); + } + if (operationId == Guid.Empty) + { + return CompatibilityBlocked( + plan, + "operation_id_required", + "Compatibility publication requires a non-empty operation ID."); + } + if (!string.IsNullOrWhiteSpace(expectedRegisteredPhysicalObjectIdentity)) + { + return CompatibilityBlocked( + plan, + "durable_target_claim_conflict", + "A path-only publication cannot replace an existing durable target claim."); + } + if (plan.EffectiveAction != FileAction.Copy + || plan.SourceDisposition != FilePublicationSourceDisposition.Retained) + { + throw new InvalidOperationException( + "Compatibility publication is additive copy-and-retain only."); + } + if (IsKnownReadOnlyMutationEndpoint( + FileAction.Copy, + source, + destination)) + { + return CompatibilityBlocked( + plan, + "destination_read_only", + "The compatibility publication destination is read-only."); + } + + using var gate = await TryAcquireFileMoveGateAsync( + source, + destination, + allowExistingAliasForRecovery: true, + allowWeakPathOnlyCompatibility: true); + if (gate == null) + { + return CompatibilityBlocked( + plan, + "publication_lock_unavailable", + "The compatibility publication endpoints could not be locked safely."); + } + + var cancellationToken = CancellationToken.None; + var journal = await _compatibilityFilePublicationJournalStore.GetOrCreateAsync( + new CompatibilityFilePublicationClaim( + operationId, + plan.RequestedAction, + gate.SourcePath, + gate.DestinationPath, + expectedSourceProof.Length, + expectedSourceProof.Sha256, + isCompanionFile), + cancellationToken); + if (journal.State == CompatibilityFilePublicationState.NeedsAttention) + { + return CompatibilityBlocked( + plan, + "publication_needs_attention", + journal.Error + ?? "The compatibility publication requires manual attention."); + } + + if (journal.State == CompatibilityFilePublicationState.Planned) + { + if (File.Exists(gate.DestinationPath)) + { + await MarkCompatibilityNeedsAttentionAsync( + journal.OperationId, + "A compatibility destination appeared before target verification. It was preserved without overwrite.", + cancellationToken); + return CompatibilityBlocked( + plan, + "ambiguous_existing_target", + "The destination appeared during compatibility publication and was preserved for manual review."); + } + + using var sourceStream = OpenCompatibilityRead(gate.SourcePath); + if (!await CompatibilityStreamMatchesAsync( + sourceStream, + expectedSourceProof.Length, + expectedSourceProof.Sha256, + cancellationToken)) + { + await MarkCompatibilityNeedsAttentionAsync( + journal.OperationId, + "The compatibility source content changed before publication.", + cancellationToken); + return CompatibilityBlocked( + plan, + "source_content_changed", + "The source changed before compatibility publication."); + } + + await using var created = new FileStream( + gate.DestinationPath, + FileMode.CreateNew, + FileAccess.ReadWrite, + FileShare.Read, + bufferSize: 128 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + sourceStream.Position = 0; + await sourceStream.CopyToAsync(created, 128 * 1024, cancellationToken); + await created.FlushAsync(cancellationToken); + created.Flush(flushToDisk: true); + if (!await CompatibilityStreamMatchesAsync( + created, + expectedSourceProof.Length, + expectedSourceProof.Sha256, + cancellationToken)) + { + await MarkCompatibilityNeedsAttentionAsync( + journal.OperationId, + "The compatibility destination failed content verification and was preserved.", + cancellationToken); + return CompatibilityBlocked( + plan, + "target_verification_failed", + "The copied destination could not be verified and was preserved for manual review."); + } + PreserveCompatibilityMetadata(gate.SourcePath, gate.DestinationPath); + + journal = await _compatibilityFilePublicationJournalStore.AdvanceAsync( + journal.OperationId, + CompatibilityFilePublicationState.TargetVerified, + expectedSourceProof.Length, + expectedSourceProof.Sha256, + audiobookId: null, + error: null, + cancellationToken); + } + + if (!CompatibilityTargetMatches( + gate.DestinationPath, + journal.SourceLength, + journal.SourceSha256)) + { + await MarkCompatibilityNeedsAttentionAsync( + journal.OperationId, + "The verified compatibility destination changed before registration.", + cancellationToken); + return CompatibilityBlocked( + plan, + "verified_target_changed", + "The verified compatibility destination changed before registration."); + } + + var capturedOperationId = journal.OperationId; + var capturedPath = gate.DestinationPath; + var capturedLength = journal.SourceLength; + var capturedHash = journal.SourceSha256; + var lease = PathOnlyAudiobookFileRegistrationLease.Open( + capturedPath, + capturedLength, + capturedHash, + commitRegistration: audiobookId => + CommitCompatibilityRegistration( + capturedOperationId, + capturedPath, + capturedLength, + capturedHash, + audiobookId), + completePublication: () => + CompleteCompatibilityPublication( + capturedOperationId, + capturedPath, + capturedLength, + capturedHash)); + + LogMutation( + FileMutationOutcome.Success, + plan.RequestedAction, + source, + destination, + plan.Message); + return new FilePublicationPreparationResult( + FilePublicationOutcome.Success, + plan.RequestedAction, + FileAction.Copy, + FilePublicationSourceDisposition.Retained, + lease, + plan.ReasonCode, + plan.Message); + } + + private bool CommitCompatibilityRegistration( + Guid operationId, + string destination, + long length, + string sha256, + int audiobookId) + { + var current = _compatibilityFilePublicationJournalStore!.Get(operationId); + if (current?.State == CompatibilityFilePublicationState.Completed) + { + return CompatibilityTargetMatches(destination, length, sha256); + } + if (current?.State + == CompatibilityFilePublicationState.RegistrationCommitted) + { + return CompatibilityTargetMatches(destination, length, sha256) + && current.AudiobookId == audiobookId; + } + if (!CompatibilityTargetMatches(destination, length, sha256)) + { + _compatibilityFilePublicationJournalStore!.Advance( + operationId, + CompatibilityFilePublicationState.NeedsAttention, + error: "The compatibility destination changed before registration commit."); + return false; + } + + _compatibilityFilePublicationJournalStore!.Advance( + operationId, + CompatibilityFilePublicationState.RegistrationCommitted, + length, + sha256, + audiobookId); + return true; + } + + private bool CompleteCompatibilityPublication( + Guid operationId, + string destination, + long length, + string sha256) + { + var current = _compatibilityFilePublicationJournalStore!.Get(operationId); + if (current?.State == CompatibilityFilePublicationState.Completed) + { + return CompatibilityTargetMatches(destination, length, sha256); + } + if (!CompatibilityTargetMatches(destination, length, sha256)) + { + _compatibilityFilePublicationJournalStore!.Advance( + operationId, + CompatibilityFilePublicationState.NeedsAttention, + error: "The compatibility destination changed before completion."); + return false; + } + + _compatibilityFilePublicationJournalStore!.Advance( + operationId, + CompatibilityFilePublicationState.Completed, + length, + sha256); + return true; + } + + private static bool CompatibilityTargetMatches( + string destination, + long length, + string sha256) + { + try + { + using var target = OpenCompatibilityRead(destination); + return CompatibilityStreamMatchesAsync( + target, + length, + sha256, + CancellationToken.None) + .GetAwaiter() + .GetResult(); + } + catch (Exception exception) when (exception is not ( + OutOfMemoryException or StackOverflowException)) + { + return false; + } + } + + private static FileStream OpenCompatibilityRead(string path) => new( + Path.GetFullPath(path), + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 128 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + + private static async Task CompatibilityStreamMatchesAsync( + FileStream stream, + long length, + string sha256, + CancellationToken cancellationToken) + { + if (stream.Length != length) + { + return false; + } + stream.Position = 0; + var actual = Convert.ToHexString( + await System.Security.Cryptography.SHA256.HashDataAsync( + stream, + cancellationToken)); + stream.Position = 0; + return string.Equals(actual, sha256, StringComparison.Ordinal); + } + + private static void PreserveCompatibilityMetadata( + string source, + string destination) + { + try + { + File.SetLastWriteTimeUtc( + destination, + File.GetLastWriteTimeUtc(source)); + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException or NotSupportedException) + { + // Content publication is authoritative. Weak storage may not support + // timestamp preservation, so metadata copying remains best effort. + } + } + + private async Task MarkCompatibilityNeedsAttentionAsync( + Guid operationId, + string error, + CancellationToken cancellationToken) + { + await _compatibilityFilePublicationJournalStore!.AdvanceAsync( + operationId, + CompatibilityFilePublicationState.NeedsAttention, + targetLength: null, + targetSha256: null, + audiobookId: null, + error, + cancellationToken); + _logger.LogWarning( + "Compatibility file publication requires attention for operation {OperationId}: {Reason}", + operationId, + error); + } + + private FilePublicationPreparationResult CompatibilityBlocked( + FilePublicationPlan plan, + string reasonCode, + string message) + { + LogMutation( + FileMutationOutcome.Blocked, + plan.RequestedAction, + source: string.Empty, + destination: null, + message); + return new FilePublicationPreparationResult( + FilePublicationOutcome.Blocked, + plan.RequestedAction, + plan.EffectiveAction, + plan.SourceDisposition, + ReasonCode: reasonCode, + Message: message); + } +} diff --git a/listenarr.infrastructure/FileSystem/FileMover.DetailedPublication.cs b/listenarr.infrastructure/FileSystem/FileMover.DetailedPublication.cs new file mode 100644 index 000000000..7f30592f4 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/FileMover.DetailedPublication.cs @@ -0,0 +1,95 @@ +namespace Listenarr.Infrastructure.FileSystem; + +public partial class FileMover +{ + public async Task + PrepareActionForRegistrationDetailedAsync( + FilePublicationPlan plan, + string source, + string destination, + Guid operationId, + string? expectedRegisteredPhysicalObjectIdentity, + FilePublicationSourceProof expectedSourceProof, + bool isCompanionFile = false, + int? companionAudiobookId = null) + { + ArgumentNullException.ThrowIfNull(plan); + expectedSourceProof.Validate(); + if (isCompanionFile != companionAudiobookId.HasValue + || companionAudiobookId <= 0) + { + throw new ArgumentException( + "Untracked companion publication requires a positive audiobook owner, and non-companion publication must not provide one.", + nameof(companionAudiobookId)); + } + if (!plan.IsAllowed) + { + LogMutation( + FileMutationOutcome.Blocked, + plan.RequestedAction, + source, + destination, + plan.Message); + return new FilePublicationPreparationResult( + FilePublicationOutcome.Blocked, + plan.RequestedAction, + plan.EffectiveAction, + plan.SourceDisposition, + ReasonCode: plan.ReasonCode, + Message: plan.Message); + } + + if (plan.Mode == FilePublicationExecutionMode.AdditiveCopyRetainSource) + { + return await PrepareCompatibilityActionForRegistrationAsync( + plan, + source, + destination, + operationId, + expectedRegisteredPhysicalObjectIdentity, + expectedSourceProof, + isCompanionFile); + } + + if (!expectedSourceProof.HasDurablePhysicalObjectIdentity) + { + const string message = + "Durable publication requires a durable source object identity."; + LogMutation( + FileMutationOutcome.Blocked, + plan.RequestedAction, + source, + destination, + message); + return new FilePublicationPreparationResult( + FilePublicationOutcome.Blocked, + plan.RequestedAction, + plan.EffectiveAction, + plan.SourceDisposition, + ReasonCode: "durable_source_identity_unavailable", + Message: message); + } + + var lease = await PrepareActionForRegistrationCoreAsync( + plan.EffectiveAction, + source, + destination, + operationId, + expectedRegisteredPhysicalObjectIdentity, + expectedSourceProof, + isCompanionFile, + companionAudiobookId); + return new FilePublicationPreparationResult( + lease == null + ? FilePublicationOutcome.Blocked + : FilePublicationOutcome.Success, + plan.RequestedAction, + plan.EffectiveAction, + plan.SourceDisposition, + lease, + lease == null ? "durable_publication_blocked" : null, + lease == null + ? "The durable publication could not be prepared safely." + : plan.Message); + } +} diff --git a/listenarr.infrastructure/FileSystem/FileMover.FileMoveLocks.cs b/listenarr.infrastructure/FileSystem/FileMover.FileMoveLocks.cs index a3ab81b36..674f0f419 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.FileMoveLocks.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.FileMoveLocks.cs @@ -103,18 +103,23 @@ private sealed record FileMoveEndpoint(string LockIdentity, string ResolvedPath) private async Task TryAcquireFileMoveGateAsync( string sourceFile, string destinationFile, - bool allowExistingAliasForRecovery = false) + bool allowExistingAliasForRecovery = false, + bool allowWeakPathOnlyCompatibility = false) { - if (!allowExistingAliasForRecovery + if (!allowWeakPathOnlyCompatibility + && !allowExistingAliasForRecovery && await IsFilesystemAliasAsync(sourceFile, destinationFile)) { LogBlockedAlias(sourceFile, destinationFile); return null; } - var sourceEndpoint = await ResolveFileMoveEndpointAsync(sourceFile); - var destinationEndpoint = await ResolveFileMoveEndpointAsync( - destinationFile); + var sourceEndpoint = allowWeakPathOnlyCompatibility + ? ResolveCompatibilityFileMoveEndpoint(sourceFile) + : await ResolveFileMoveEndpointAsync(sourceFile); + var destinationEndpoint = allowWeakPathOnlyCompatibility + ? ResolveCompatibilityFileMoveEndpoint(destinationFile) + : await ResolveFileMoveEndpointAsync(destinationFile); if (sourceEndpoint == null || destinationEndpoint == null) { _logger.LogWarning( @@ -130,8 +135,14 @@ await AfterFileMoveEndpointsResolvedForTestAsync( destinationFile); } - if (!allowExistingAliasForRecovery + if (string.Equals( + sourceEndpoint.LockIdentity, + destinationEndpoint.LockIdentity, + StringComparison.Ordinal) + || (!allowWeakPathOnlyCompatibility + && !allowExistingAliasForRecovery && await IsFilesystemAliasAsync(sourceFile, destinationFile)) + ) { LogBlockedAlias(sourceFile, destinationFile); return null; @@ -171,8 +182,12 @@ await lockDirectory.OpenOrCreateExclusiveLockFileAsync( lockName)); } - var currentSource = await ResolveFileMoveEndpointAsync(sourceFile); - var currentDestination = await ResolveFileMoveEndpointAsync(destinationFile); + var currentSource = allowWeakPathOnlyCompatibility + ? ResolveCompatibilityFileMoveEndpoint(sourceFile) + : await ResolveFileMoveEndpointAsync(sourceFile); + var currentDestination = allowWeakPathOnlyCompatibility + ? ResolveCompatibilityFileMoveEndpoint(destinationFile) + : await ResolveFileMoveEndpointAsync(destinationFile); if (currentSource == null || currentDestination == null || !string.Equals( @@ -193,15 +208,21 @@ await lockDirectory.OpenOrCreateExclusiveLockFileAsync( var destinationParentPath = Path.GetDirectoryName(currentDestination.ResolvedPath) ?? throw new IOException("The file-move destination has no parent."); - sourceParent = PinnedDirectoryCreation.OpenPinnedHierarchyNoFollow( - sourceParentPath, - createMissing: false); - destinationParent = PinnedDirectoryCreation.OpenPinnedHierarchyNoFollow( - destinationParentPath, - createMissing: false); - var pinnedSource = await ResolveFileMoveEndpointAsync(sourceFile); - var pinnedDestination = await ResolveFileMoveEndpointAsync( - destinationFile); + if (!allowWeakPathOnlyCompatibility) + { + sourceParent = PinnedDirectoryCreation.OpenPinnedHierarchyNoFollow( + sourceParentPath, + createMissing: false); + destinationParent = PinnedDirectoryCreation.OpenPinnedHierarchyNoFollow( + destinationParentPath, + createMissing: false); + } + var pinnedSource = allowWeakPathOnlyCompatibility + ? ResolveCompatibilityFileMoveEndpoint(sourceFile) + : await ResolveFileMoveEndpointAsync(sourceFile); + var pinnedDestination = allowWeakPathOnlyCompatibility + ? ResolveCompatibilityFileMoveEndpoint(destinationFile) + : await ResolveFileMoveEndpointAsync(destinationFile); if (pinnedSource == null || pinnedDestination == null || !string.Equals( @@ -212,8 +233,9 @@ await lockDirectory.OpenOrCreateExclusiveLockFileAsync( pinnedDestination.LockIdentity, currentDestination.LockIdentity, StringComparison.Ordinal) - || !sourceParent.VisiblePathMatches() - || !destinationParent.VisiblePathMatches()) + || (!allowWeakPathOnlyCompatibility + && (!sourceParent!.VisiblePathMatches() + || !destinationParent!.VisiblePathMatches()))) { throw new IOException( "A file-move endpoint changed while its physical parents were pinned."); @@ -228,7 +250,8 @@ await lockDirectory.OpenOrCreateExclusiveLockFileAsync( currentDestination, sourceParent, destinationParent); - if (!allowExistingAliasForRecovery + if (!allowWeakPathOnlyCompatibility + && !allowExistingAliasForRecovery && await IsFilesystemAliasAsync(sourceFile, destinationFile)) { lease.Dispose(); diff --git a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessMove.Proofs.cs b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessMove.Proofs.cs index 6d848f8f2..177d8d622 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessMove.Proofs.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessMove.Proofs.cs @@ -6,6 +6,25 @@ namespace Listenarr.Infrastructure.FileSystem; public partial class FileMover { + private static async Task + CaptureContentOnlySourceProofAsync( + PinnedDirectoryCreation.PinnedFileEntry source, + CancellationToken cancellationToken) + { + await using var stream = source.OpenReadStream( + bufferSize: 128 * 1024, + asynchronous: false); + var length = stream.Length; + stream.Position = 0; + var sha256 = Convert.ToHexString( + await SHA256.HashDataAsync(stream, cancellationToken)); + return new FilePublicationSourceProof( + $"content-only:{sha256}", + length, + sha256, + FilePublicationSourceAuthority.ContentOnly); + } + private static async Task CaptureMarkerlessSourceProofAsync( PinnedDirectoryCreation.PinnedFileEntry source, @@ -75,6 +94,8 @@ private async Task EnsureMarkerlessSourceHashAsync( private static bool MatchesExpectedSourceProof( MarkerlessSourceProof actual, FilePublicationSourceProof expected) => + expected.HasDurablePhysicalObjectIdentity + && PinnedDirectoryCreation.ArePersistedObjectIdentitiesDurablyEquivalent( actual.PhysicalObjectIdentity, expected.PhysicalObjectIdentity) diff --git a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.Target.cs b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.Target.cs index a1753b7bd..b03abb06b 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.Target.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.Target.cs @@ -19,9 +19,14 @@ private async Task PublishMarkerlessRegistrationTargetAsync gate.DestinationName, requireDeleteAccess: false); + var sourceSharesDestinationVolume = sourceEntry != null + && !ForceCrossVolumeForTest + && sourceEntry.IsOnSameVolume(gate.DestinationParent); var requiresGenerationPreservingLink = action == FileAction.HardlinkCopy - || (action == FileAction.Move && !OperatingSystem.IsWindows()); + || (action == FileAction.Move + && !OperatingSystem.IsWindows() + && sourceSharesDestinationVolume); if (existingTarget != null) { @@ -83,7 +88,7 @@ await MarkMarkerlessRegistrationNeedsAttentionAsync( string targetIdentity; PinnedDirectoryCreation.PinnedFileEntry? publishedHardlink = null; if (requiresGenerationPreservingLink - && sourceEntry.IsOnSameVolume(gate.DestinationParent)) + && sourceSharesDestinationVolume) { try { diff --git a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.Validation.cs b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.Validation.cs index e5dfa1765..1a4fc5ce4 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.Validation.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.Validation.cs @@ -115,10 +115,17 @@ or NotSupportedException or PathTooLongException private async Task ValidateMarkerlessRegistrationJournalAsync( FileMutationJournal journal, FileAction action, - FileMoveGateLease gate) + FileMoveGateLease gate, + bool isCompanionFile, + int? companionAudiobookId) { if (journal.ProtocolVersion != FileMutationProtocol.Current || journal.Action != action + || journal.AudiobookFileId != (isCompanionFile + ? FileMutationOwner.RegistrationCompanionFile + : null) + || (isCompanionFile + && journal.AudiobookId != companionAudiobookId) || !await JournalPathsMatchGateAsync(journal, gate)) { throw new InvalidOperationException( diff --git a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.cs b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.cs index a69350bbf..4fbb1e025 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.cs @@ -16,7 +16,9 @@ private async Task string destination, Guid operationId, string? expectedRegisteredPhysicalObjectIdentity, - FilePublicationSourceProof? expectedSourceProof) + FilePublicationSourceProof? expectedSourceProof, + bool isCompanionFile, + int? companionAudiobookId) { if (_fileMutationJournalStore == null) { @@ -61,20 +63,6 @@ private async Task return new MarkerlessRegistrationPreparation(true, null); } - if (action == FileAction.Move - && !OperatingSystem.IsWindows() - && (ForceCrossVolumeForTest - || !initialSource.IsOnSameVolume(gate.DestinationParent))) - { - LogMutation( - FileMutationOutcome.Blocked, - action, - source, - destination, - "Unix cross-volume registration moves require source retirement that cannot be generation-fenced without a library-side namespace claim"); - return new MarkerlessRegistrationPreparation(true, null); - } - var proof = await CaptureMarkerlessSourceProofAsync( initialSource, cancellationToken, @@ -120,7 +108,11 @@ private async Task gate.DestinationParent.GetDirectoryObjectIdentity(), proof.PhysicalObjectIdentity, proof.Length, - proof.Sha256), + proof.Sha256, + AudiobookId: companionAudiobookId, + AudiobookFileId: isCompanionFile + ? FileMutationOwner.RegistrationCompanionFile + : null), cancellationToken); if (initialDestination != null) { @@ -135,7 +127,12 @@ private async Task } else { - await ValidateMarkerlessRegistrationJournalAsync(journal, action, gate); + await ValidateMarkerlessRegistrationJournalAsync( + journal, + action, + gate, + isCompanionFile, + companionAudiobookId); if (!JournalParentGenerationsMatchGate(journal, gate)) { await MarkMarkerlessRegistrationNeedsAttentionAsync( diff --git a/listenarr.infrastructure/FileSystem/FileMover.SourceCapability.cs b/listenarr.infrastructure/FileSystem/FileMover.SourceCapability.cs index 529857f73..5e10de23d 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.SourceCapability.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.SourceCapability.cs @@ -55,10 +55,30 @@ public async Task CheckAsync( FilePublicationSourceCapabilityFailureKind.Unavailable); } - var proof = await CaptureMarkerlessSourceProofAsync( - entry, - cancellationToken, - includeSha256: true); + FilePublicationSourceProof sourceProof; + try + { + if (ForceContentOnlySourceProofForTest) + { + throw new PlatformNotSupportedException( + "Durable source identity was disabled for this test."); + } + var proof = await CaptureMarkerlessSourceProofAsync( + entry, + cancellationToken, + includeSha256: true); + sourceProof = new FilePublicationSourceProof( + proof.PhysicalObjectIdentity, + proof.Length, + proof.Sha256!); + } + catch (Exception exception) when (exception is + PlatformNotSupportedException or NotSupportedException) + { + sourceProof = await CaptureContentOnlySourceProofAsync( + entry, + cancellationToken); + } if (!anchor.VisiblePathMatches() || !entry.VisiblePathMatches()) { @@ -68,10 +88,7 @@ public async Task CheckAsync( } return FilePublicationSourceCapabilityResult.SupportedForProof( - new FilePublicationSourceProof( - proof.PhysicalObjectIdentity, - proof.Length, - proof.Sha256!)); + sourceProof); } catch (Exception exception) when ( FileSystemSafety.IsProvenMissingPathException(exception)) diff --git a/listenarr.infrastructure/FileSystem/FileMover.TestHooks.cs b/listenarr.infrastructure/FileSystem/FileMover.TestHooks.cs index 6f40df8c8..6064d7954 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.TestHooks.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.TestHooks.cs @@ -102,6 +102,7 @@ internal Func< } internal bool DisableNativeFileRenameForTest { get; init; } internal bool ForceCrossVolumeForTest { get; init; } + internal bool ForceContentOnlySourceProofForTest { get; init; } internal Action? BeforeFileMoveDurabilityBarrierForTest { get; init; } internal string? FileMoveLockDirectoryForTest { get; init; } } diff --git a/listenarr.infrastructure/FileSystem/FileMover.cs b/listenarr.infrastructure/FileSystem/FileMover.cs index a2b92735b..b3ae96a4d 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.cs @@ -43,10 +43,13 @@ public partial class FileMover : IFileMover private readonly ILogger _logger; private readonly IFileSystemSemanticsResolver _semanticsResolver; private readonly IFileMutationJournalStore? _fileMutationJournalStore; + private readonly CompatibilityFilePublicationJournalStore? + _compatibilityFilePublicationJournalStore; private readonly IApplicationPathService _applicationPathService; private readonly Func _readOnlyFileSystemProbe; private readonly IRootFolderRepository? _rootFolderRepository; private readonly IRootFolderStorageHealthResolver? _rootFolderStorageHealthResolver; + private readonly WeakPublicationMode _weakPublicationMode; public FileMover( ILogger logger, @@ -70,12 +73,19 @@ public FileMover( ?? FileSystemMutationCapabilityProbe.ProbeReadOnlyDirectory; _rootFolderRepository = rootFolderRepository; _rootFolderStorageHealthResolver = rootFolderStorageHealthResolver; + _weakPublicationMode = options?.Value.WeakPublicationMode + ?? WeakPublicationMode.CopyAndRetainSource; _fileMutationJournalStore = dbContextFactory == null ? null : new EfFileMutationJournalStore( dbContextFactory, timeProvider ?? TimeProvider.System, _semanticsResolver); + _compatibilityFilePublicationJournalStore = dbContextFactory == null + ? null + : new CompatibilityFilePublicationJournalStore( + dbContextFactory, + timeProvider ?? TimeProvider.System); } } diff --git a/listenarr.infrastructure/FileSystem/FilePublicationCapabilityResolver.cs b/listenarr.infrastructure/FileSystem/FilePublicationCapabilityResolver.cs new file mode 100644 index 000000000..aaafa21db --- /dev/null +++ b/listenarr.infrastructure/FileSystem/FilePublicationCapabilityResolver.cs @@ -0,0 +1,118 @@ +using Listenarr.Domain.Common; +using Listenarr.Domain.Audiobooks.Enumerations; +using Microsoft.Extensions.Options; + +namespace Listenarr.Infrastructure.FileSystem; + +internal sealed class FilePublicationCapabilityResolver( + IRootFolderRepository rootFolderRepository, + IRootFolderStorageHealthResolver storageHealthResolver, + IOptions? options = null) + : IFilePublicationCapabilityResolver +{ + public async Task ResolveAsync( + FileAction requestedAction, + string source, + string destination, + FilePublicationSourceProof sourceProof, + CancellationToken cancellationToken = default) + { + sourceProof.Validate(); + if (requestedAction is not ( + FileAction.Move or FileAction.Copy or FileAction.HardlinkCopy)) + { + return FilePublicationPlan.Blocked( + requestedAction, + "unsupported_action", + "The requested action cannot publish an audiobook file."); + } + + var destinationRoot = await FindContainingRootAsync( + destination, + cancellationToken); + if (destinationRoot == null) + { + return FilePublicationPlan.Blocked( + requestedAction, + "destination_root_unavailable", + "The destination is not inside a configured root with persisted path semantics."); + } + + var destinationHealth = await storageHealthResolver.ResolveAsync( + destinationRoot, + cancellationToken); + if (!destinationHealth.CanMutateFilesystem + && !destinationHealth.CanPublishNewFiles) + { + return FilePublicationPlan.Blocked( + requestedAction, + "destination_publication_unavailable", + destinationHealth.Message + ?? "The destination does not authorize new file publication."); + } + + var sourceCanBeRetired = sourceProof.HasDurablePhysicalObjectIdentity; + if (requestedAction == FileAction.Move) + { + var sourceRoot = await FindContainingRootAsync( + source, + cancellationToken); + if (sourceRoot != null) + { + var sourceHealth = await storageHealthResolver.ResolveAsync( + sourceRoot, + cancellationToken); + sourceCanBeRetired &= sourceHealth.CanMutateFilesystem; + } + } + + if (sourceProof.HasDurablePhysicalObjectIdentity + && destinationHealth.CanMutateFilesystem + && (requestedAction != FileAction.Move || sourceCanBeRetired)) + { + return FilePublicationPlan.Durable(requestedAction); + } + + return options?.Value.WeakPublicationMode == WeakPublicationMode.Disabled + ? FilePublicationPlan.Blocked( + requestedAction, + "compatibility_publication_disabled", + "Compatibility publication is disabled by FileMover:WeakPublicationMode.") + : FilePublicationPlan.Additive(requestedAction); + } + + private async Task FindContainingRootAsync( + string path, + CancellationToken cancellationToken) + { + var fullPath = Path.GetFullPath(path); + RootFolder? best = null; + var bestLength = -1; + foreach (var root in await rootFolderRepository.GetAllAsync()) + { + cancellationToken.ThrowIfCancellationRequested(); + var persisted = RootFolderPathSemantics.ResolvePersisted(root); + if (!persisted.HasValue + || persisted.Value.DetectAmbiguousCaseMatches + || !FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( + root.Path, + out var rootPath, + out _) + || !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/PathOnlyAudiobookFileRegistrationLease.cs b/listenarr.infrastructure/FileSystem/PathOnlyAudiobookFileRegistrationLease.cs new file mode 100644 index 000000000..cd75866df --- /dev/null +++ b/listenarr.infrastructure/FileSystem/PathOnlyAudiobookFileRegistrationLease.cs @@ -0,0 +1,264 @@ +using System.Buffers; +using System.Security.Cryptography; + +namespace Listenarr.Infrastructure.FileSystem; + +internal sealed class PathOnlyAudiobookFileRegistrationLease : + IAudiobookFileRegistrationLease, + IAudiobookFileRegistrationIdentityVerifier, + IAudiobookFileRegistrationPublicationProbe +{ + private readonly FileStream _pinnedRead; + private readonly long _expectedLength; + private readonly string _expectedSha256; + private readonly Func? _commitRegistration; + private readonly Func? _completePublication; + private int? _audiobookId; + private bool _cleanupRecoveryPrepared; + private bool _registrationCommitted; + private bool _publicationCompleted; + private bool _disposed; + + private PathOnlyAudiobookFileRegistrationLease( + FileStream pinnedRead, + string publicPath, + long expectedLength, + string expectedSha256, + Func? commitRegistration, + Func? completePublication) + { + _pinnedRead = pinnedRead; + _expectedLength = expectedLength; + _expectedSha256 = expectedSha256; + _commitRegistration = commitRegistration; + _completePublication = completePublication; + PublicPath = publicPath; + MetadataPath = OperatingSystem.IsLinux() + ? FormattableString.Invariant( + $"/proc/{Environment.ProcessId}/fd/{pinnedRead.SafeFileHandle.DangerousGetHandle().ToInt32()}") + : publicPath; + PhysicalObjectIdentity = $"content-pinned:{expectedSha256}"; + } + + public string PublicPath { get; } + public string MetadataPath { get; } + public string PhysicalObjectIdentity { get; } + public bool HasDurablePhysicalObjectIdentity => false; + public string? SourcePhysicalObjectIdentity => null; + + internal static PathOnlyAudiobookFileRegistrationLease Open( + string publicPath, + long expectedLength, + string expectedSha256, + Func? commitRegistration = null, + Func? completePublication = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(publicPath); + ArgumentException.ThrowIfNullOrWhiteSpace(expectedSha256); + var canonicalPath = Path.GetFullPath(publicPath); + var stream = new FileStream( + canonicalPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 128 * 1024, + FileOptions.SequentialScan); + try + { + if (stream.Length != expectedLength + || !HashMatches(stream, expectedSha256)) + { + throw new InvalidOperationException( + "The path-only publication changed before registration."); + } + if (OperatingSystem.IsLinux() + && !File.Exists(FormattableString.Invariant( + $"/proc/{Environment.ProcessId}/fd/{stream.SafeFileHandle.DangerousGetHandle().ToInt32()}"))) + { + throw new PlatformNotSupportedException( + "The Linux proc filesystem is unavailable for path-only metadata extraction."); + } + + return new PathOnlyAudiobookFileRegistrationLease( + stream, + canonicalPath, + expectedLength, + expectedSha256, + commitRegistration, + completePublication); + } + catch + { + stream.Dispose(); + throw; + } + } + + public Stream OpenMetadataReadStream() + { + ObjectDisposedException.ThrowIf(_disposed, this); + return new FileStream( + MetadataPath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + bufferSize: 128 * 1024, + FileOptions.SequentialScan); + } + + public Stream OpenMetadataWriteStream() => throw new NotSupportedException( + "Path-only registration leases do not authorize metadata writes."); + + public async Task MatchesContentAsync( + Stream candidateStream, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(candidateStream); + await using var publishedStream = OpenMetadataReadStream(); + if (candidateStream.CanSeek) + { + if (candidateStream.Length != publishedStream.Length) + { + return false; + } + candidateStream.Position = 0; + } + + var candidateBuffer = ArrayPool.Shared.Rent(128 * 1024); + var publishedBuffer = ArrayPool.Shared.Rent(128 * 1024); + try + { + while (true) + { + var candidateRead = await candidateStream.ReadAsync( + candidateBuffer.AsMemory(0, candidateBuffer.Length), + cancellationToken); + var publishedRead = await publishedStream.ReadAsync( + publishedBuffer.AsMemory(0, publishedBuffer.Length), + cancellationToken); + if (candidateRead != publishedRead) + { + return false; + } + if (candidateRead == 0) + { + return true; + } + if (!candidateBuffer.AsSpan(0, candidateRead).SequenceEqual( + publishedBuffer.AsSpan(0, publishedRead))) + { + return false; + } + } + } + finally + { + ArrayPool.Shared.Return(candidateBuffer); + ArrayPool.Shared.Return(publishedBuffer); + } + } + + public bool MatchesPhysicalObjectIdentity(string expectedPhysicalObjectIdentity) => + false; + + public bool MatchesCurrentPublication() => + ProbeCurrentPublication() == RegistrationPublicationMatchOutcome.Match; + + public RegistrationPublicationMatchOutcome ProbeCurrentPublication() + { + ObjectDisposedException.ThrowIf(_disposed, this); + try + { + using var visible = new FileStream( + PublicPath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + bufferSize: 128 * 1024, + FileOptions.SequentialScan); + return visible.Length == _expectedLength + && HashMatches(visible, _expectedSha256) + ? RegistrationPublicationMatchOutcome.Match + : RegistrationPublicationMatchOutcome.Mismatch; + } + catch (Exception exception) when (exception is + FileNotFoundException or DirectoryNotFoundException) + { + return RegistrationPublicationMatchOutcome.Mismatch; + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException + or System.ComponentModel.Win32Exception) + { + return RegistrationPublicationMatchOutcome.Unavailable; + } + } + + public bool PrepareCleanupRecovery(int audiobookId) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (audiobookId <= 0) + { + throw new ArgumentOutOfRangeException(nameof(audiobookId)); + } + if (_audiobookId.HasValue && _audiobookId.Value != audiobookId) + { + throw new InvalidOperationException( + "The registration lease is already bound to another audiobook."); + } + + _audiobookId = audiobookId; + _cleanupRecoveryPrepared = true; + return true; + } + + public RegistrationPublicationCompletion CompletePublication() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_publicationCompleted) + { + return RegistrationPublicationCompletion.Completed; + } + if (_commitRegistration != null && !_cleanupRecoveryPrepared) + { + throw new InvalidOperationException( + "Compatibility recovery must be prepared before publication is completed."); + } + if (!_registrationCommitted && _commitRegistration != null) + { + if (!_commitRegistration(_audiobookId + ?? throw new InvalidOperationException( + "The registration lease has no audiobook owner."))) + { + return RegistrationPublicationCompletion.CommittedCleanupPending; + } + _registrationCommitted = true; + } + if (_completePublication != null && !_completePublication()) + { + return RegistrationPublicationCompletion.CommittedCleanupPending; + } + + _publicationCompleted = true; + return RegistrationPublicationCompletion.Completed; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + _pinnedRead.Dispose(); + _disposed = true; + } + + private static bool HashMatches(Stream stream, string expectedSha256) + { + stream.Position = 0; + var actual = Convert.ToHexString(SHA256.HashData(stream)); + stream.Position = 0; + return string.Equals(actual, expectedSha256, StringComparison.Ordinal); + } +} diff --git a/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs b/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs index e217793e0..0b4eb22fb 100644 --- a/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs +++ b/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs @@ -65,7 +65,9 @@ public Stream OpenMetadataWriteStream() "Pinned path-only registration leases do not authorize metadata writes."); } - return _file.OpenIndependentWriteStream( + // Read+write, because the only consumer is a tag library that parses the container it + // is about to rewrite through this same stream. + return _file.OpenIndependentReadWriteStream( bufferSize: 128 * 1024, asynchronous: false); } @@ -186,7 +188,9 @@ internal static PinnedAudiobookFileRegistrationLease Create( internal static PinnedAudiobookFileRegistrationLease CreatePinnedPathOnly( PinnedDirectoryCreation.PinnedFileEntry file, - string publicPath) + string publicPath, + Func? commitRegistration = null, + Func? completePublication = null) { ArgumentNullException.ThrowIfNull(file); ArgumentException.ThrowIfNullOrWhiteSpace(publicPath); @@ -230,8 +234,8 @@ internal static PinnedAudiobookFileRegistrationLease CreatePinnedPathOnly( hasDurablePhysicalObjectIdentity: false, sourcePhysicalObjectIdentity: null, prepareCleanupRecovery: null, - completePublication: null, - commitRegistration: null); + completePublication, + commitRegistration); stableHandle = null; return result; } diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileMove.Native.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileMove.Native.cs index 40c265ff9..a0f2e5cce 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileMove.Native.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileMove.Native.cs @@ -101,12 +101,15 @@ private static SafeFileHandle OpenRelativeFileUnix( private static SafeFileHandle OpenRelativeFileForWriteUnix( SafeFileHandle parentHandle, string fileName, - string fullPath) + string fullPath, + bool readable = false) { var fd = OpenAt( parentHandle.DangerousGetHandle().ToInt32(), fileName, - UnixOpenFlags.OpenWriteNoFollow(), + readable + ? UnixOpenFlags.OpenReadWriteNoFollow() + : UnixOpenFlags.OpenWriteNoFollow(), mode: 0); if (fd >= 0) { diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileOpenWindows.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileOpenWindows.cs index e0b4a7644..d3acdc071 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileOpenWindows.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileOpenWindows.cs @@ -259,7 +259,8 @@ private static SafeFileHandle OpenRelativeFileStableDeleteWindows( private static SafeFileHandle OpenRelativeFileForWriteWindows( SafeFileHandle parentHandle, string fileName, - string fullPath) + string fullPath, + bool readable = false) { var nameBuffer = Marshal.StringToHGlobalUni(fileName); var unicodeStringPointer = IntPtr.Zero; @@ -281,7 +282,8 @@ private static SafeFileHandle OpenRelativeFileForWriteWindows( }; var status = NtCreateFile( out var rawHandle, - GenericWrite | FileReadAttributes | Synchronize, + (readable ? GenericRead : 0u) + | GenericWrite | FileReadAttributes | Synchronize, ref attributes, out _, IntPtr.Zero, diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileStreams.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileStreams.cs index ff5b2cb4d..f154d01a4 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileStreams.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileStreams.cs @@ -23,21 +23,28 @@ internal FileStream OpenIndependentReadStream(int bufferSize, bool asynchronous) asynchronous); } - internal FileStream OpenIndependentWriteStream(int bufferSize, bool asynchronous) + // Tag libraries rewrite a container in place: they parse the existing box or frame + // structure through the same stream they then write back to, so a write-only handle + // fails while still reading. The pinning is unaffected by the wider access mode — the + // handle is still opened relative to the pinned parent, still refuses to follow a + // link, and is still checked against the validated file object below. + internal FileStream OpenIndependentReadWriteStream(int bufferSize, bool asynchronous) { ThrowIfDisposed(); var handle = OperatingSystem.IsWindows() ? OpenRelativeFileForWriteWindows( _parentHandle, _fileName, - FullPath) + FullPath, + readable: true) : OpenRelativeFileForWriteUnix( _parentHandle, _fileName, - FullPath); + FullPath, + readable: true); return OpenVerifiedIndependentStream( handle, - FileAccess.Write, + FileAccess.ReadWrite, bufferSize, asynchronous); } diff --git a/listenarr.infrastructure/FileSystem/RootFolderStorageConfirmationService.cs b/listenarr.infrastructure/FileSystem/RootFolderStorageConfirmationService.cs index 824cb778f..0cbd63023 100644 --- a/listenarr.infrastructure/FileSystem/RootFolderStorageConfirmationService.cs +++ b/listenarr.infrastructure/FileSystem/RootFolderStorageConfirmationService.cs @@ -281,6 +281,8 @@ private static async Task EnsureNoExternalRecoveryOwnerTouchesRootAsync( || (journal.AudiobookId != null && journal.AudiobookFileId != null && (journal.AudiobookFileId == FileMutationOwner.CompanionFile + || journal.AudiobookFileId + == FileMutationOwner.RegistrationCompanionFile ? journal.State != FileMutationJournalState.Completed : journal.State != FileMutationJournalState.OwnerMetadataReconciled))) .ToListAsync(cancellationToken); diff --git a/listenarr.infrastructure/FileSystem/RootFolderStorageHealthResolver.cs b/listenarr.infrastructure/FileSystem/RootFolderStorageHealthResolver.cs index bf1d9b1d7..95ba805d4 100644 --- a/listenarr.infrastructure/FileSystem/RootFolderStorageHealthResolver.cs +++ b/listenarr.infrastructure/FileSystem/RootFolderStorageHealthResolver.cs @@ -213,7 +213,22 @@ private async Task ValidateFilesystemSemanticsAsyn if (observation.State != RootFolderStorageState.Healthy) { - return observation; + var limitedReadOnly = _readOnlyFileSystemProbe(canonicalPath); + if (limitedReadOnly != false) + { + return ApplyMutationCapability( + canonicalPath, + observation, + limitedReadOnly); + } + + return observation with + { + CanPublishNewFiles = + observation.State == RootFolderStorageState.Limited + && observation.Reason == RootFolderStorageReason.IdentityUnsupported + && !observation.CanConfirmCurrentFolder + }; } var mutationCapability = ApplyMutationCapability( @@ -236,6 +251,7 @@ private async Task ValidateFilesystemSemanticsAsyn "Listenarr can read and scan this storage, but automatic case-sensitivity detection is not stable enough to authorize filesystem mutations. Select Sensitive or Insensitive explicitly to enable moves, deletes, and other writes.", CanConfirmCurrentFolder = false, CanMutateFilesystem = false, + CanPublishNewFiles = false, ConfirmationToken = null, Detail = "Automatic case sensitivity was inferred from an existing directory entry rather than an authoritative filesystem capability." @@ -252,7 +268,10 @@ private static RootFolderStorageObservation ApplyMutationCapability( { if (isReadOnly == false) { - return observation; + return observation with + { + CanPublishNewFiles = observation.CanMutateFilesystem + }; } return observation with @@ -266,6 +285,7 @@ private static RootFolderStorageObservation ApplyMutationCapability( : "Listenarr can read and scan this storage, but it cannot verify that filesystem mutations are available safely.", CanConfirmCurrentFolder = false, CanMutateFilesystem = false, + CanPublishNewFiles = false, ConfirmationToken = null, Detail = isReadOnly == true ? "The filesystem reports the ST_RDONLY mount flag." @@ -314,6 +334,7 @@ private static RootFolderStorageObservation SemanticsUnavailable( : "The folder at this location changed and Listenarr cannot verify its path rules safely. Review the root folder settings.", CanConfirmCurrentFolder = false, CanMutateFilesystem = false, + CanPublishNewFiles = false, ConfirmationToken = null, Detail = detail }; diff --git a/listenarr.infrastructure/FileSystem/UnixOpenFlags.cs b/listenarr.infrastructure/FileSystem/UnixOpenFlags.cs index b43ac61e5..d63764dd2 100644 --- a/listenarr.infrastructure/FileSystem/UnixOpenFlags.cs +++ b/listenarr.infrastructure/FileSystem/UnixOpenFlags.cs @@ -76,6 +76,20 @@ internal static int OpenWriteNoFollow() return LinuxWriteOnly | noFollow | LinuxCloseOnExec; } + // For a caller that has to read the file back through the same descriptor it writes to. + // Tag libraries do this: they parse the existing container through the stream they are + // about to rewrite, so a write-only descriptor fails partway through the parse. + internal static int OpenReadWriteNoFollow() + { + if (IsMacOSHost()) + { + return MacReadWrite | MacNoFollow | MacCloseOnExec; + } + + var (_, noFollow) = GetCurrentLinuxDirectorySafetyFlags(); + return LinuxReadWrite | noFollow | LinuxCloseOnExec; + } + internal static int CreateWriteExclusiveNoFollow() { if (IsMacOSHost()) diff --git a/listenarr.infrastructure/Library/Files/TagLibAudioTagWriter.cs b/listenarr.infrastructure/Library/Files/TagLibAudioTagWriter.cs index 5ae3d6d64..2b1dc5515 100644 --- a/listenarr.infrastructure/Library/Files/TagLibAudioTagWriter.cs +++ b/listenarr.infrastructure/Library/Files/TagLibAudioTagWriter.cs @@ -90,7 +90,11 @@ public Task WriteAsinTagAsync( private static void ApplyAsinTag(TagLib.File file, string asin) { - if (file.Tag is TagLib.Mpeg4.AppleTag appleTag) + // An MPEG-4 file's Tag is a CombinedTag wrapping the Apple tag, never the AppleTag + // itself, so a type test on file.Tag matches nothing here and the save that follows + // writes an unchanged file while still reporting success. The tag has to be asked + // for by type. Only MPEG-4 answers to Apple, so mp3 and flac fall through as before. + if (file.GetTag(TagLib.TagTypes.Apple, create: true) is TagLib.Mpeg4.AppleTag appleTag) appleTag.SetDashBox("com.apple.iTunes", "ASIN", asin); else if (file.GetTag(TagLib.TagTypes.Id3v2) is TagLib.Id3v2.Tag id3Tag) { diff --git a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Hierarchy.cs b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Hierarchy.cs index a42eb5084..74e5adcab 100644 --- a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Hierarchy.cs +++ b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Hierarchy.cs @@ -1,9 +1,89 @@ +using System.ComponentModel; using Listenarr.Domain.Common; namespace Listenarr.Infrastructure.Library.Moving; internal sealed partial class EfLibraryDirectoryOwnershipStore { + public Task EnsureAdditiveHierarchyAsync( + string destinationDirectory, + string managedBoundary, + FileSystemPathSemantics semantics, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(destinationDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(managedBoundary); + EnsureResolved(semantics); + + var destination = FileSystemPathIdentity.Canonicalize( + destinationDirectory, + semantics.Syntax); + var boundary = FileSystemPathIdentity.Canonicalize( + managedBoundary, + semantics.Syntax); + if (!FileSystemPathIdentity.IsSameOrInside( + destination, + boundary, + semantics)) + { + throw new InvalidOperationException( + "The additive directory destination is outside its managed boundary."); + } + + var hierarchy = new List(); + var currentPath = destination; + while (!FileSystemPathIdentity.AreEquivalent( + currentPath, + boundary, + semantics)) + { + hierarchy.Add(currentPath); + currentPath = Path.GetDirectoryName(currentPath) + ?? throw new InvalidOperationException( + "The additive directory hierarchy escaped its managed boundary."); + } + hierarchy.Reverse(); + + var current = PinnedDirectoryCreation.OpenPinnedBoundary(boundary); + try + { + foreach (var directory in hierarchy) + { + cancellationToken.ThrowIfCancellationRequested(); + var childName = Path.GetFileName(directory); + PinnedDirectoryCreation.PinnedDirectoryAnchor next; + try + { + next = current.OpenExistingChild(childName); + } + catch (Win32Exception exception) when ( + exception.NativeErrorCode is 2 or 3) + { + using var creation = current.TryCreateChild(childName); + next = creation.Created + ? creation.OpenCreatedDirectoryAnchor() + : current.OpenExistingChild(childName); + } + + if (!next.VisiblePathMatches()) + { + next.Dispose(); + throw new IOException( + "An additive directory component changed while it was being pinned."); + } + + current.Dispose(); + current = next; + } + } + finally + { + current.Dispose(); + } + + return Task.CompletedTask; + } + public async Task> EnsureCreatedHierarchyAsync( string destinationDirectory, string managedBoundary, diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.ExternalRecovery.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.ExternalRecovery.cs index 4f8ac9c83..a75ac90d2 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.ExternalRecovery.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.ExternalRecovery.cs @@ -316,6 +316,8 @@ private static async Task && audiobookIds.Contains(journal.AudiobookId.Value) && journal.AudiobookFileId != null && (journal.AudiobookFileId == FileMutationOwner.CompanionFile + || journal.AudiobookFileId + == FileMutationOwner.RegistrationCompanionFile ? journal.State != FileMutationJournalState.Completed : journal.State != FileMutationJournalState.OwnerMetadataReconciled)) .Select(journal => journal.AudiobookId) diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataRepair.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataRepair.cs index 522c41cc7..af10ed3e6 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataRepair.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataRepair.cs @@ -168,6 +168,8 @@ private async Task EnsureMetadataRepairRowMutationAllowedAsync( journal => journal.AudiobookId == audiobookId && journal.AudiobookFileId != null && (journal.AudiobookFileId == FileMutationOwner.CompanionFile + || journal.AudiobookFileId + == FileMutationOwner.RegistrationCompanionFile ? journal.State != FileMutationJournalState.Completed : journal.State != FileMutationJournalState.OwnerMetadataReconciled), cancellationToken)) diff --git a/listenarr.infrastructure/Library/Scanning/AudiobookScanService.Metadata.cs b/listenarr.infrastructure/Library/Scanning/AudiobookScanService.Metadata.cs index b581f87a4..0b069d74b 100644 --- a/listenarr.infrastructure/Library/Scanning/AudiobookScanService.Metadata.cs +++ b/listenarr.infrastructure/Library/Scanning/AudiobookScanService.Metadata.cs @@ -65,8 +65,15 @@ private async Task EnrichWithMetadataAsync( pinnedAuthority, discovery, candidate); + // The lease deliberately separates stable byte access from public media + // identity, and the single-path overload collapses the two. On Linux the + // metadata path is a /proc descriptor link with no extension, so collapsing + // it makes the probe's audio-extension guard reject the candidate before + // ffprobe runs. var metadata = await metadataService.ExtractFileMetadataAsync( - pinnedMetadataFile.MetadataPath); + new MetadataFileSource( + pinnedMetadataFile.MetadataPath, + candidate)); if (metadata != null && ScanFileDiscovery.MetadataMatchesAudiobook(metadata, audiobook)) { diff --git a/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.Metadata.cs b/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.Metadata.cs index 39d70adfa..d1380504a 100644 --- a/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.Metadata.cs +++ b/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.Metadata.cs @@ -145,6 +145,21 @@ private static bool SegmentMatchesExpectedTitle( return true; } + // Tolerate a differing leading article ("The"/"A"/"An") on either side, so a + // folder named "Language of Emotions" still matches the expected title + // "The Language of Emotions" (and vice versa). This stays a full-title equality + // modulo the article -- it never widens into substring or author matching, so + // the same-author / different-book boundary guards remain intact. + var articleFreeSegment = StripLeadingArticle(normalizedSegment); + if (titleTokens.Any(token => + string.Equals( + StripLeadingArticle(token), + articleFreeSegment, + StringComparison.Ordinal))) + { + return true; + } + var components = segment .Split( [" - ", " – ", " — "], @@ -167,6 +182,21 @@ private static bool SegmentMatchesExpectedTitle( return false; } + private static readonly string[] LeadingArticlePrefixes = ["the ", "a ", "an "]; + + private static string StripLeadingArticle(string normalizedToken) + { + foreach (var prefix in LeadingArticlePrefixes) + { + if (normalizedToken.StartsWith(prefix, StringComparison.Ordinal)) + { + return normalizedToken[prefix.Length..]; + } + } + + return normalizedToken; + } + private static string? TryFindIdentifierBoundary( string candidate, string canonicalRoot, diff --git a/listenarr.infrastructure/Library/Scanning/UnmatchedScanProcessor.Grouping.cs b/listenarr.infrastructure/Library/Scanning/UnmatchedScanProcessor.Grouping.cs index 8a637eb72..9c0bddc97 100644 --- a/listenarr.infrastructure/Library/Scanning/UnmatchedScanProcessor.Grouping.cs +++ b/listenarr.infrastructure/Library/Scanning/UnmatchedScanProcessor.Grouping.cs @@ -414,15 +414,27 @@ private static void ApplyEmbeddedTags( /// /// Extracts a normalized title stem from a filename for grouping purposes. - /// Strips leading track numbers, trailing Part/CD/Disc numbers, year and - /// series decorations in brackets. Files that resolve to the same stem are - /// treated as parts of the same audiobook. Returns the folder name as fallback - /// when the stem would otherwise be empty (for example purely numeric filenames). + /// Strips a trailing "N of M" index, leading track numbers, trailing + /// Part/CD/Disc numbers, year and series decorations in brackets. Files that + /// resolve to the same stem are treated as parts of the same audiobook. Returns + /// the folder name as fallback when the stem would otherwise be empty (for + /// example purely numeric filenames). /// private static string ExtractTitleStem(string filePath, string folderPath) { var name = Path.GetFileNameWithoutExtension(filePath); + // Strip a trailing "N of M" index: "Title 001 of 498", "Title - 3 of 12". + // + // This runs BEFORE the leading-track strip on purpose. A file named "001 of 498" + // with no title in it would otherwise lose its leading "001 " first, leaving + // "of 498" — a different stem in every file, and no longer numeric, so the + // folder-name fallback at the end never gets the chance to group them. + // + // Unlike a bare "(N)", this form is not ambiguous with a series marker: it + // carries its own total, so it says the file is one part of a set rather than + // one entry among separate works. Nothing is titled "Book 1 of 12". + name = Regex.Replace(name, @"[\s\-_]*\d+\s*of\s*\d+$", "", RegexOptions.IgnoreCase); // Strip leading track/disc number prefix: "01 - ", "Track 01 - ", "1. " name = Regex.Replace(name, @"^(track\s*)?\d+[\s\-_\.]+", "", RegexOptions.IgnoreCase); // Strip trailing Part/CD/Disc/Chapter number: "- Part 1", "CD2", "Disc 2", "pt00" diff --git a/listenarr.infrastructure/Persistence/CompatibilityFilePublicationRecoveryService.cs b/listenarr.infrastructure/Persistence/CompatibilityFilePublicationRecoveryService.cs new file mode 100644 index 000000000..0d74e659a --- /dev/null +++ b/listenarr.infrastructure/Persistence/CompatibilityFilePublicationRecoveryService.cs @@ -0,0 +1,169 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Persistence; + +internal interface ICompatibilityFilePublicationRecoveryService +{ + Task ReconcileAsync(CancellationToken cancellationToken = default); +} + +internal sealed class CompatibilityFilePublicationRecoveryService( + IDbContextFactory dbContextFactory, + TimeProvider timeProvider, + ILogger logger) + : ICompatibilityFilePublicationRecoveryService +{ + public async Task ReconcileAsync( + CancellationToken cancellationToken = default) + { + await using var readContext = await dbContextFactory.CreateDbContextAsync( + cancellationToken); + var operationIds = await readContext.CompatibilityFilePublicationJournals + .AsNoTracking() + .Where(journal => + journal.State != CompatibilityFilePublicationState.Completed + && journal.State != CompatibilityFilePublicationState.NeedsAttention) + .OrderBy(journal => journal.CreatedAt) + .ThenBy(journal => journal.OperationId) + .Select(journal => journal.OperationId) + .ToListAsync(cancellationToken); + + foreach (var operationId in operationIds) + { + cancellationToken.ThrowIfCancellationRequested(); + await ReconcileOperationAsync(operationId, cancellationToken); + } + } + + private async Task ReconcileOperationAsync( + Guid operationId, + CancellationToken cancellationToken) + { + await using var context = await dbContextFactory.CreateDbContextAsync( + cancellationToken); + var journal = await context.CompatibilityFilePublicationJournals + .SingleOrDefaultAsync( + candidate => candidate.OperationId == operationId, + cancellationToken); + if (journal == null + || journal.State is CompatibilityFilePublicationState.Completed + or CompatibilityFilePublicationState.NeedsAttention) + { + return; + } + + if (journal.ProtocolVersion + != CompatibilityFilePublicationProtocol.Current) + { + MarkNeedsAttention( + journal, + "The compatibility publication protocol is unsupported."); + } + else if (journal.State == CompatibilityFilePublicationState.Planned) + { + if (File.Exists(journal.DestinationPath)) + { + MarkNeedsAttention( + journal, + "A destination exists for an unverified compatibility publication. It was preserved without overwrite or deletion."); + } + else if (!ContentMatches( + journal.SourcePath, + journal.SourceLength, + journal.SourceSha256)) + { + MarkNeedsAttention( + journal, + "The planned compatibility source is missing or changed."); + } + else + { + return; + } + } + else if (!ContentMatches( + journal.DestinationPath, + journal.TargetLength ?? journal.SourceLength, + journal.TargetSha256 ?? journal.SourceSha256)) + { + MarkNeedsAttention( + journal, + "The verified compatibility destination is missing or changed."); + } + else if (journal.State + == CompatibilityFilePublicationState.RegistrationCommitted) + { + var hasOwner = journal.IsCompanionFile + || (journal.AudiobookId is int audiobookId + && await context.AudiobookFiles + .AsNoTracking() + .AnyAsync( + file => file.AudiobookId == audiobookId + && (file.Path == journal.DestinationPath + || file.CanonicalPath == journal.DestinationPath), + cancellationToken)); + if (!hasOwner) + { + MarkNeedsAttention( + journal, + "The committed compatibility destination no longer has its expected audiobook owner."); + } + else + { + journal.State = CompatibilityFilePublicationState.Completed; + journal.Error = null; + } + } + else + { + // TargetVerified is intentionally resumable only by the original import, + // which still owns the metadata and destination-planning context. + return; + } + + journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; + await context.SaveChangesAsync(cancellationToken); + } + + private void MarkNeedsAttention( + CompatibilityFilePublicationJournal journal, + string reason) + { + journal.State = CompatibilityFilePublicationState.NeedsAttention; + journal.Error = reason; + logger.LogWarning( + "Compatibility file publication {OperationId} requires attention: {Reason}", + journal.OperationId, + reason); + } + + private static bool ContentMatches( + string path, + long length, + string sha256) + { + try + { + using var file = new FileStream( + Path.GetFullPath(path), + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 128 * 1024, + FileOptions.SequentialScan); + if (file.Length != length) + { + return false; + } + var actual = Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData(file)); + return string.Equals(actual, sha256, StringComparison.Ordinal); + } + catch (Exception exception) when (exception is not ( + OutOfMemoryException or StackOverflowException)) + { + return false; + } + } +} diff --git a/listenarr.infrastructure/Persistence/Configurations/CompatibilityFilePublicationJournalConfiguration.cs b/listenarr.infrastructure/Persistence/Configurations/CompatibilityFilePublicationJournalConfiguration.cs new file mode 100644 index 000000000..a981438f8 --- /dev/null +++ b/listenarr.infrastructure/Persistence/Configurations/CompatibilityFilePublicationJournalConfiguration.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Listenarr.Infrastructure.Persistence.Configurations; + +internal sealed class CompatibilityFilePublicationJournalConfiguration + : IEntityTypeConfiguration +{ + public void Configure( + EntityTypeBuilder builder) + { + builder.ToTable("CompatibilityFilePublicationJournals"); + 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.SourceSha256) + .IsRequired() + .HasMaxLength(64); + builder.Property(journal => journal.TargetSha256) + .HasMaxLength(64); + builder.Property(journal => journal.Error) + .HasMaxLength(2048); + builder.HasIndex(journal => journal.State); + builder.HasIndex(journal => journal.AudiobookId); + } +} diff --git a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Protocol.cs b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Protocol.cs index 1c3f39614..f76b0b3ff 100644 --- a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Protocol.cs +++ b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Protocol.cs @@ -65,7 +65,24 @@ await db.FileMutationJournals cancellationToken); } + // Name every journal, not just the first. This disables filesystem mutations for the whole + // application until an operator resolves them, and there is no in-app route to do that, so + // this message is the entire brief they get. Reporting one at a time turns a single repair + // into one restart per affected journal, with no way to know how many are left. + const int listed = 10; + var identifiers = string.Join( + ", ", + unsupported + .Take(listed) + .Select(journal => $"{journal.OperationId} ({journal.State})")); + var remainder = unsupported.Count > listed + ? $", and {unsupported.Count - listed} more" + : string.Empty; + throw new InvalidOperationException( - $"File-mutation journal {unsupported[0].OperationId} uses legacy recovery protocol state {unsupported[0].State} and requires operator repair before filesystem mutations can resume."); + $"{unsupported.Count} file-mutation journal(s) use a legacy recovery protocol and require " + + $"operator repair before filesystem mutations can resume: {identifiers}{remainder}. " + + "Each was interrupted before this build's durable parent-directory generation fencing " + + "existed and cannot be resumed automatically."); } } diff --git a/listenarr.infrastructure/Persistence/FileRenameRecoveryProbe.cs b/listenarr.infrastructure/Persistence/FileRenameRecoveryProbe.cs index 6a88788b0..81c438176 100644 --- a/listenarr.infrastructure/Persistence/FileRenameRecoveryProbe.cs +++ b/listenarr.infrastructure/Persistence/FileRenameRecoveryProbe.cs @@ -22,6 +22,8 @@ public async Task HasBlockingAsync( journal.AudiobookId == audiobookId && journal.AudiobookFileId != null && (journal.AudiobookFileId == FileMutationOwner.CompanionFile + || journal.AudiobookFileId + == FileMutationOwner.RegistrationCompanionFile ? journal.State != FileMutationJournalState.Completed : journal.State != FileMutationJournalState.OwnerMetadataReconciled), cancellationToken); diff --git a/listenarr.infrastructure/Persistence/FileRenameRecoveryReconciler.cs b/listenarr.infrastructure/Persistence/FileRenameRecoveryReconciler.cs index 20c371344..7fef6124c 100644 --- a/listenarr.infrastructure/Persistence/FileRenameRecoveryReconciler.cs +++ b/listenarr.infrastructure/Persistence/FileRenameRecoveryReconciler.cs @@ -49,6 +49,8 @@ public async Task ReconcileAsync(CancellationToken cancellationToken = default) && journal.AudiobookId != null && journal.AudiobookFileId != null && (journal.AudiobookFileId == FileMutationOwner.CompanionFile + || journal.AudiobookFileId + == FileMutationOwner.RegistrationCompanionFile ? journal.State != FileMutationJournalState.Completed : journal.State != FileMutationJournalState.OwnerMetadataReconciled)) .OrderBy(journal => journal.CreatedAt) @@ -117,13 +119,9 @@ private async Task ReconcileOperationAsync( try { resumed = FileMutationOwner.IsCompanionFile(ownerAudiobookFileId) - ? await fileMover.PerformActionOn( - FileAction.Move, - journal.SourcePath, - journal.DestinationPath, - journal.OperationId, - ownerAudiobookId, - FileMutationOwner.CompanionFile) + ? await ResumeCompanionMoveAsync( + journal, + ownerAudiobookId) : audiobookFile == null ? await fileMover.PerformActionOn( FileAction.Move, @@ -391,6 +389,49 @@ await MarkNeedsAttentionAsync( trackedAudiobook.Id); } + private async Task ResumeCompanionMoveAsync( + FileMutationJournal journal, + int audiobookId) + { + if (!FileMutationOwner.IsRegistrationCompanionFile( + journal.AudiobookFileId) + || string.IsNullOrWhiteSpace(journal.SourceSha256) + || string.IsNullOrWhiteSpace(journal.TargetPhysicalObjectIdentity)) + { + return await fileMover.PerformActionOn( + FileAction.Move, + journal.SourcePath, + journal.DestinationPath, + journal.OperationId, + audiobookId, + FileMutationOwner.CompanionFile); + } + + var preparation = await fileMover + .PrepareActionForRegistrationDetailedAsync( + FilePublicationPlan.Durable(FileAction.Move), + journal.SourcePath, + journal.DestinationPath, + journal.OperationId, + journal.TargetPhysicalObjectIdentity, + new FilePublicationSourceProof( + journal.SourcePhysicalObjectIdentity, + journal.SourceLength, + journal.SourceSha256), + isCompanionFile: true, + companionAudiobookId: audiobookId); + using var lease = preparation.RegistrationLease; + return lease != null + && lease.PrepareCleanupRecovery(audiobookId) + && lease.CompletePublication() + == RegistrationPublicationCompletion.Completed + && await fileMover.CompletePreparedMoveAsync( + journal.SourcePath, + journal.DestinationPath, + lease, + journal.OperationId); + } + private async Task MarkNeedsAttentionAsync( Guid operationId, string error, diff --git a/listenarr.infrastructure/Persistence/LibraryFilesystemStartupReconciliationService.cs b/listenarr.infrastructure/Persistence/LibraryFilesystemStartupReconciliationService.cs index b4d1fe957..f6d6f70f2 100644 --- a/listenarr.infrastructure/Persistence/LibraryFilesystemStartupReconciliationService.cs +++ b/listenarr.infrastructure/Persistence/LibraryFilesystemStartupReconciliationService.cs @@ -64,6 +64,12 @@ await RunScopedAsync( static (service, token) => service.ReconcileAsync(token), stoppingToken); + phase = "CompatibilityFilePublicationRecovery"; + 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 268626f3d..cee6b6c90 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 CompatibilityFilePublicationJournals { get; set; } = null!; public DbSet DownloadHistories { get; set; } = null!; public DbSet QualityProfiles { get; set; } = null!; public DbSet RemotePathMappings { get; set; } = null!; diff --git a/listenarr.infrastructure/Persistence/Migrations/20260821141235_AddCompatibilityFilePublication.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260821141235_AddCompatibilityFilePublication.Designer.cs new file mode 100644 index 000000000..79d04ef39 --- /dev/null +++ b/listenarr.infrastructure/Persistence/Migrations/20260821141235_AddCompatibilityFilePublication.Designer.cs @@ -0,0 +1,2633 @@ +// +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("20260821141235_AddCompatibilityFilePublication")] + partial class AddCompatibilityFilePublication + { + /// + 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("ExitCode") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("Stderr") + .HasColumnType("TEXT"); + + b.Property("Stdout") + .HasColumnType("TEXT"); + + b.Property("TimedOut") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ProcessExecutionLogs"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Abridged") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("AuthorAsins") + .HasColumnType("TEXT"); + + b.Property("Authors") + .HasColumnType("TEXT"); + + b.Property("BasePath") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Edition") + .HasColumnType("TEXT"); + + b.Property("Explicit") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastSearchTime") + .HasColumnType("TEXT"); + + b.Property("Monitored") + .HasColumnType("INTEGER"); + + b.Property("Narrators") + .HasColumnType("TEXT"); + + b.Property("OpenLibraryId") + .HasColumnType("TEXT"); + + b.Property("PublishYear") + .HasColumnType("TEXT"); + + b.Property("PublishedDate") + .HasColumnType("TEXT"); + + b.Property("Publisher") + .HasColumnType("TEXT"); + + b.Property("Quality") + .HasColumnType("TEXT"); + + b.Property("QualityProfileId") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("Series") + .HasColumnType("TEXT"); + + b.Property("SeriesNumber") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LastSearchTime"); + + b.HasIndex("Monitored"); + + b.HasIndex("QualityProfileId"); + + b.ToTable("Audiobooks"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookDeletionIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeleteFolder") + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId") + .IsUnique() + .HasFilter("\"State\" <> 'Completed'"); + + b.HasIndex("UpdatedAt"); + + b.HasIndex("AudiobookId", "State"); + + b.ToTable("AudiobookDeletionIntents", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookExternalIdentifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("Region") + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ValueRaw") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("Type", "ValueNormalized"); + + b.HasIndex("AudiobookId", "Type", "IsPrimary"); + + b.HasIndex("Type", "ValueNormalized", "Region"); + + b.ToTable("AudiobookExternalIdentifiers", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("Bitrate") + .HasColumnType("INTEGER"); + + b.Property("CanonicalPath") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("Container") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DurationSeconds") + .HasColumnType("REAL"); + + b.Property("Format") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PathCaseSensitivity") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Unknown"); + + b.Property("PathCaseSensitivityMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Auto"); + + b.Property("PathIdentityBoundary") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("PathIdentityLookupKey") + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("PathIdentityReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("PathIdentityState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Unavailable"); + + b.Property("PathIdentityVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("PathOwnershipKey") + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("PathSyntax") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PhysicalIdentityObservedAtUtc") + .HasColumnType("TEXT"); + + b.Property("PhysicalIdentityVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("PhysicalObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("PathIdentityLookupKey"); + + b.HasIndex("PathOwnershipKey") + .IsUnique() + .HasFilter("\"PathOwnershipKey\" IS NOT NULL"); + + b.ToTable("AudiobookFiles"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookSeriesMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("SeriesAsin") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SeriesNumber") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("AudiobookId", "IsPrimary"); + + b.HasIndex("AudiobookId", "SortOrder"); + + b.ToTable("AudiobookSeriesMemberships", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AuthorCacheEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("AuthorNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CatalogBooks") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastFetchedAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SimilarAuthors") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorAsin", "Region"); + + b.HasIndex("AuthorNameNormalized", "Region") + .IsUnique(); + + b.ToTable("AuthorCacheEntries"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CanonicalPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationOperationId") + .HasColumnType("TEXT"); + + b.Property("CreationWorkflow") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentity") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityUnavailableReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityVersion") + .HasColumnType("INTEGER"); + + b.Property("ManagedRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("OwnershipToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("PathCaseSensitivity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PathCaseSensitivityMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PathIdentityBoundary") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("PathIdentityLookupKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("PathOwnershipKey") + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("PathSyntax") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("StateReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ManagedRootFolderId"); + + b.HasIndex("OwnershipToken") + .IsUnique(); + + b.HasIndex("PathIdentityLookupKey"); + + b.HasIndex("PathOwnershipKey") + .IsUnique() + .HasFilter("\"PathOwnershipKey\" IS NOT NULL"); + + b.HasIndex("CreationOperationId", "State"); + + b.ToTable("LibraryDirectoryOwnerships", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipPathMigration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("OwnershipId") + .HasColumnType("INTEGER"); + + b.Property("RelocationId") + .HasColumnType("TEXT"); + + b.Property("SourceCanonicalPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourceCaseSensitivity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceCaseSensitivityMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceIdentityBoundary") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourceIdentityLookupKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("SourceOwnershipKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("SourcePathSyntax") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetCanonicalPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivityMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetIdentityBoundary") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("TargetIdentityLookupKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("TargetOwnershipKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("TargetPathSyntax") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RelocationId"); + + b.HasIndex("TargetOwnershipKey") + .IsUnique(); + + b.HasIndex("OwnershipId", "RelocationId") + .IsUnique(); + + b.ToTable("LibraryDirectoryOwnershipPathMigrations", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MonitoredAuthor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("AuthorNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastCheckedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastSuccessfulSyncAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LastCheckedAt"); + + b.HasIndex("AuthorNameNormalized", "Region", "Language") + .IsUnique(); + + b.ToTable("MonitoredAuthors"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MonitoredSeries", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastCheckedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastSuccessfulSyncAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("SeriesNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LastCheckedAt"); + + b.HasIndex("SeriesNameNormalized", "Region", "Language") + .IsUnique(); + + b.ToTable("MonitoredSeries"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveDeduplicationKey") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("AttemptCount") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("DeleteEmptySource") + .HasColumnType("INTEGER"); + + b.Property("EnqueuedAt") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("ExecutionProtocolVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("FailureKind") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("TEXT") + .HasDefaultValue("None"); + + b.Property("IdentityKeyVersion") + .HasColumnType("INTEGER"); + + b.Property("LeaseExpiresAt") + .HasColumnType("TEXT"); + + b.Property("LeaseGeneration") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LeaseOwner") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("NextAttemptAt") + .HasColumnType("TEXT"); + + b.Property("Phase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("TEXT") + .HasDefaultValue("None"); + + b.Property("RelocationId") + .HasColumnType("TEXT"); + + b.Property("RequestedPath") + .HasColumnType("TEXT"); + + b.Property("SourceCaseSensitivity") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceCaseSensitivityMode") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceCleanupBoundary") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SourceDirectoryCleanupState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(24) + .HasColumnType("TEXT") + .HasDefaultValue("Pending"); + + b.Property("SourceDirectoryObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SourceIdentityBoundary") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SourcePath") + .HasColumnType("TEXT"); + + b.Property("SourcePathSyntax") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivity") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivityMode") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetDirectoryObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetIdentityBoundary") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("TargetPathSyntax") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ActiveDeduplicationKey") + .IsUnique() + .HasFilter("\"ActiveDeduplicationKey\" IS NOT NULL"); + + b.HasIndex("RelocationId"); + + b.HasIndex("AudiobookId", "Status"); + + b.HasIndex("Status", "NextAttemptAt", "LeaseExpiresAt"); + + b.ToTable("MoveJobs"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJobCreatedDirectory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DirectoryObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("MoveJobId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MoveJobId", "Path") + .IsUnique(); + + b.ToTable("MoveJobCreatedDirectories", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJobEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CleanupProtectionVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("CleanupState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("CopyState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("EntryType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("LastWriteTimeUtc") + .HasColumnType("TEXT"); + + b.Property("Length") + .HasColumnType("INTEGER"); + + b.Property("MoveJobId") + .HasColumnType("TEXT"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Sha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SourcePhysicalObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetPhysicalObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MoveJobId", "RelativePath") + .IsUnique(); + + b.ToTable("MoveJobEntries", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveScanHandoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveScanJobId") + .HasColumnType("TEXT"); + + b.Property("AttemptGeneration") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("LeaseExpiresAt") + .HasColumnType("TEXT"); + + b.Property("LeaseGeneration") + .HasColumnType("INTEGER"); + + b.Property("LeaseOwner") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MoveJobId") + .HasColumnType("TEXT"); + + b.Property("NextAttemptAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("TargetPath") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MoveJobId") + .IsUnique(); + + b.HasIndex("Status", "NextAttemptAt", "LeaseExpiresAt"); + + b.ToTable("MoveScanHandoffs", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.QualityProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGroupNames") + .HasColumnType("TEXT") + .HasColumnName("CustomGroupNames"); + + b.Property("CutoffQuality") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("MaximumAge") + .HasColumnType("INTEGER"); + + b.Property("MaximumSize") + .HasColumnType("INTEGER"); + + b.Property("MinimumScore") + .HasColumnType("INTEGER"); + + b.Property("MinimumSeeders") + .HasColumnType("INTEGER"); + + b.Property("MinimumSize") + .HasColumnType("INTEGER"); + + b.Property("MustContain") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("MustContain"); + + b.Property("MustNotContain") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("MustNotContain"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PreferNewerReleases") + .HasColumnType("INTEGER"); + + b.Property("PreferredFormats") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("PreferredFormats"); + + b.Property("PreferredLanguages") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("PreferredLanguages"); + + b.PrimitiveCollection("PreferredWords") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Qualities") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Qualities"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("QualityProfiles"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CaseSensitivityMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Auto"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DirectoryObjectIdentity") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityUnavailableReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityVersion") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("PathIdentityKey") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PathIdentityState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Unavailable"); + + b.Property("ResolvedCaseSensitivity") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Unknown"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsDefault") + .IsUnique() + .HasDatabaseName("IX_RootFolders_SingleDefault") + .HasFilter("\"IsDefault\" = 1"); + + b.HasIndex("Name"); + + b.HasIndex("Path") + .IsUnique(); + + b.HasIndex("PathIdentityKey") + .IsUnique() + .HasFilter("\"PathIdentityKey\" IS NOT NULL"); + + b.ToTable("RootFolders", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CompletedJobs") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeleteEmptySource") + .HasColumnType("INTEGER"); + + b.Property("DesiredIsDefault") + .HasColumnType("INTEGER"); + + b.Property("DesiredName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("RootFolderId") + .HasColumnType("INTEGER"); + + b.Property("SourceCaseSensitivityMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Auto"); + + b.Property("SourcePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivityMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetDirectoryObjectIdentity") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TargetDirectoryObjectIdentityUnavailableReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("TargetDirectoryObjectIdentityVersion") + .HasColumnType("INTEGER"); + + b.Property("TargetIdentityEnrollmentState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(24) + .HasColumnType("TEXT") + .HasDefaultValue("Authorized"); + + b.Property("TargetPath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("TotalJobs") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ActiveRootFolderId") + .IsUnique() + .HasFilter("\"ActiveRootFolderId\" IS NOT NULL"); + + b.HasIndex("RootFolderId"); + + b.ToTable("RootFolderRelocations", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocationCreatedDirectory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanonicalPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentity") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityVersion") + .HasColumnType("INTEGER"); + + b.Property("OwnershipToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RelocationId") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnershipToken") + .IsUnique(); + + b.HasIndex("RelocationId", "CanonicalPath") + .IsUnique(); + + b.ToTable("RootFolderRelocationCreatedDirectories", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocationSkippedItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("RelocationId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RelocationId", "AudiobookId") + .IsUnique(); + + b.ToTable("RootFolderRelocationSkippedItems", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.SeriesCacheEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CatalogBooks") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastFetchedAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("SeriesNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesAsin", "Region"); + + b.HasIndex("SeriesNameNormalized", "Region") + .IsUnique(); + + b.ToTable("SeriesCacheEntries"); + }); + + modelBuilder.Entity("Listenarr.Domain.Configuration.ApiConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BaseUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Headers") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("HeadersJson"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ParametersJson"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RateLimitPerMinute") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ApiConfigurations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Configuration.ApplicationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowedFileExtensions") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AudnexusApiUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CompletedFileAction") + .HasColumnType("INTEGER"); + + b.Property("DefaultSearchLanguage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DefaultSearchRegion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DiscordApplicationId") + .HasColumnType("TEXT"); + + b.Property("DiscordBotAvatar") + .HasColumnType("TEXT"); + + b.Property("DiscordBotEnabled") + .HasColumnType("INTEGER"); + + b.Property("DiscordBotToken") + .HasColumnType("TEXT"); + + b.Property("DiscordBotUsername") + .HasColumnType("TEXT"); + + b.Property("DiscordChannelId") + .HasColumnType("TEXT"); + + b.Property("DiscordCommandGroupName") + .HasColumnType("TEXT"); + + b.Property("DiscordCommandSubcommandName") + .HasColumnType("TEXT"); + + b.Property("DiscordGuildId") + .HasColumnType("TEXT"); + + b.Property("DownloadCompletionStabilitySeconds") + .HasColumnType("INTEGER"); + + b.Property("EnableAmazonSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableAudibleSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverArtDownload") + .HasColumnType("INTEGER"); + + b.Property("EnableMetadataProcessing") + .HasColumnType("INTEGER"); + + b.Property("EnableNotifications") + .HasColumnType("INTEGER"); + + b.Property("EnableOpenLibrarySearch") + .HasColumnType("INTEGER"); + + b.Property("EnabledNotificationTriggers") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExtractArchives") + .HasColumnType("INTEGER"); + + b.Property("FailedDownloadAutoSearch") + .HasColumnType("INTEGER"); + + b.Property("FailedDownloadHandlingEnabled") + .HasColumnType("INTEGER"); + + b.Property("FileNamingPattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FolderNamingPattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HistoryRetentionDays") + .HasColumnType("INTEGER"); + + b.Property("ImportBlacklistExtensions") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MaxConcurrentDownloads") + .HasColumnType("INTEGER"); + + b.Property("MissingSourceMaxRetries") + .HasColumnType("INTEGER"); + + b.Property("MissingSourceRetryInitialDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("MultiFileNamingPattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutputPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("ProwlarrApiKeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("ProwlarrPort") + .HasColumnType("INTEGER"); + + b.Property("ProwlarrTagFilter") + .HasColumnType("TEXT"); + + b.Property("ProwlarrUrl") + .HasColumnType("TEXT"); + + b.Property("ShowCompletedExternalDownloads") + .HasColumnType("INTEGER"); + + b.Property("UnmatchedScanConcurrency") + .HasColumnType("INTEGER"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("WebhookUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Webhooks") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ApplicationSettings"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.CompatibilityFilePublicationJournal", b => + { + b.Property("OperationId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("EffectiveAction") + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("IsCompanionFile") + .HasColumnType("INTEGER"); + + b.Property("ProtocolVersion") + .HasColumnType("INTEGER"); + + b.Property("RequestedAction") + .HasColumnType("INTEGER"); + + b.Property("SourceDisposition") + .HasColumnType("INTEGER"); + + b.Property("SourceLength") + .HasColumnType("INTEGER"); + + b.Property("SourcePath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("TargetLength") + .HasColumnType("INTEGER"); + + b.Property("TargetSha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("OperationId"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("State"); + + b.ToTable("CompatibilityFilePublicationJournals", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.Download", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveAudiobookDeduplicationKey") + .HasColumnType("INTEGER"); + + b.Property("Album") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Artist") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DownloadPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DownloadedSize") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("ExpectedFileSize") + .HasColumnType("INTEGER"); + + b.Property("FinalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HistoryId") + .HasColumnType("INTEGER"); + + b.Property("ImportAttempts") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("ImportBlockMessages") + .HasColumnType("TEXT"); + + b.Property("ImportBlockReason") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastImportedAt") + .HasColumnType("TEXT"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Metadata"); + + b.Property("OriginalUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Progress") + .HasColumnType("TEXT"); + + b.Property("Publisher") + .HasColumnType("TEXT"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("Series") + .HasColumnType("TEXT"); + + b.Property("SeriesNumber") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TotalSize") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ActiveAudiobookDeduplicationKey") + .IsUnique() + .HasFilter("\"ActiveAudiobookDeduplicationKey\" IS NOT NULL"); + + b.HasIndex("CompletedAt"); + + b.HasIndex("DownloadClientId"); + + b.HasIndex("Status"); + + b.ToTable("Downloads"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.DownloadClientConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Password") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("RemoveCompletedDownloads") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("SettingsJson"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UseSSL") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DownloadClientConfigurations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.DownloadProcessingJob", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveDeduplicationKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT"); + + b.Property("DownloadId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("JobData") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("JobData"); + + b.Property("JobType") + .HasColumnType("INTEGER"); + + b.Property("MaxRetries") + .HasColumnType("INTEGER"); + + b.Property("NextRetryAt") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("ProcessingLog") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("SourcePath") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ActiveDeduplicationKey") + .IsUnique() + .HasFilter("\"ActiveDeduplicationKey\" IS NOT NULL"); + + b.HasIndex("Status"); + + b.HasIndex("DownloadId", "Status"); + + b.ToTable("DownloadProcessingJobs"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.FileMutationJournal", b => + { + b.Property("OperationId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("AudiobookFileId") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationParentDirectoryObjectIdentity") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("ProtocolVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("SourceLength") + .HasColumnType("INTEGER"); + + b.Property("SourceParentDirectoryObjectIdentity") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SourcePath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourcePhysicalObjectIdentity") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SourceSha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("TargetPhysicalObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("OperationId"); + + b.HasIndex("State"); + + b.HasIndex("UpdatedAt"); + + b.ToTable("FileMutationJournals", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.RemotePathMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RemotePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("RemotePathMappings"); + }); + + modelBuilder.Entity("Listenarr.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Listenarr.Domain.Identity.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastAccessed") + .HasColumnType("TEXT"); + + b.Property("RememberMe") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Listenarr.Domain.Search.Indexer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalSettings") + .HasColumnType("TEXT"); + + b.Property("AnimeCategories") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("Categories") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnableAnimeStandardSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableAutomaticSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableInteractiveSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableRss") + .HasColumnType("INTEGER"); + + b.Property("Implementation") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastTestError") + .HasColumnType("TEXT"); + + b.Property("LastTestSuccessful") + .HasColumnType("INTEGER"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("MaximumSize") + .HasColumnType("INTEGER"); + + b.Property("MinimumAge") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Retention") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Indexers"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.QualityProfile", "QualityProfile") + .WithMany() + .HasForeignKey("QualityProfileId"); + + b.Navigation("QualityProfile"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookExternalIdentifier", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.Audiobook", null) + .WithMany("ExternalIdentifiers") + .HasForeignKey("AudiobookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookFile", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.Audiobook", "Audiobook") + .WithMany("Files") + .HasForeignKey("AudiobookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Audiobook"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookSeriesMembership", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.Audiobook", "Audiobook") + .WithMany("SeriesMemberships") + .HasForeignKey("AudiobookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Audiobook"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolder", null) + .WithMany() + .HasForeignKey("ManagedRootFolderId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipPathMigration", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", "Ownership") + .WithMany("PathMigrations") + .HasForeignKey("OwnershipId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") + .WithMany("OwnershipPathMigrations") + .HasForeignKey("RelocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ownership"); + + b.Navigation("Relocation"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") + .WithMany("MoveJobs") + .HasForeignKey("RelocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Relocation"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJobCreatedDirectory", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.MoveJob", "MoveJob") + .WithMany("CreatedDirectories") + .HasForeignKey("MoveJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MoveJob"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJobEntry", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.MoveJob", "MoveJob") + .WithMany("Entries") + .HasForeignKey("MoveJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MoveJob"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveScanHandoff", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.MoveJob", "MoveJob") + .WithOne("ScanHandoff") + .HasForeignKey("Listenarr.Domain.Audiobooks.MoveScanHandoff", "MoveJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MoveJob"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocation", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolder", "RootFolder") + .WithMany("Relocations") + .HasForeignKey("RootFolderId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("RootFolder"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocationCreatedDirectory", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") + .WithMany("CreatedDirectories") + .HasForeignKey("RelocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Relocation"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocationSkippedItem", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") + .WithMany("SkippedItems") + .HasForeignKey("RelocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Relocation"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => + { + b.Navigation("ExternalIdentifiers"); + + b.Navigation("Files"); + + b.Navigation("SeriesMemberships"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", b => + { + b.Navigation("PathMigrations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => + { + b.Navigation("CreatedDirectories"); + + b.Navigation("Entries"); + + b.Navigation("ScanHandoff"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolder", b => + { + b.Navigation("Relocations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocation", b => + { + b.Navigation("CreatedDirectories"); + + b.Navigation("MoveJobs"); + + b.Navigation("OwnershipPathMigrations"); + + b.Navigation("SkippedItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260821141235_AddCompatibilityFilePublication.cs b/listenarr.infrastructure/Persistence/Migrations/20260821141235_AddCompatibilityFilePublication.cs new file mode 100644 index 000000000..70c9891ea --- /dev/null +++ b/listenarr.infrastructure/Persistence/Migrations/20260821141235_AddCompatibilityFilePublication.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Listenarr.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddCompatibilityFilePublication : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CompatibilityFilePublicationJournals", + columns: table => new + { + OperationId = table.Column(type: "TEXT", nullable: false), + ProtocolVersion = table.Column(type: "INTEGER", nullable: false), + RequestedAction = table.Column(type: "INTEGER", nullable: false), + EffectiveAction = table.Column(type: "INTEGER", nullable: false), + SourceDisposition = table.Column(type: "INTEGER", nullable: false), + SourcePath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), + DestinationPath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), + SourceLength = table.Column(type: "INTEGER", nullable: false), + SourceSha256 = table.Column(type: "TEXT", maxLength: 64, nullable: false), + TargetLength = table.Column(type: "INTEGER", nullable: true), + TargetSha256 = table.Column(type: "TEXT", maxLength: 64, nullable: true), + State = table.Column(type: "INTEGER", nullable: false), + AudiobookId = table.Column(type: "INTEGER", nullable: true), + IsCompanionFile = table.Column(type: "INTEGER", nullable: false), + Error = table.Column(type: "TEXT", maxLength: 2048, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CompatibilityFilePublicationJournals", x => x.OperationId); + }); + + migrationBuilder.CreateIndex( + name: "IX_CompatibilityFilePublicationJournals_AudiobookId", + table: "CompatibilityFilePublicationJournals", + column: "AudiobookId"); + + migrationBuilder.CreateIndex( + name: "IX_CompatibilityFilePublicationJournals_State", + table: "CompatibilityFilePublicationJournals", + column: "State"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CompatibilityFilePublicationJournals"); + } + } +} diff --git a/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs b/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs index f4a49242a..9a372bd86 100644 --- a/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs +++ b/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Listenarr.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -178,7 +178,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Timestamp"); - b.ToTable("History", (string)null); + b.ToTable("History"); }); modelBuilder.Entity("Listenarr.Domain.ActivityHistory.ProcessExecutionLog", b => @@ -216,7 +216,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("ProcessExecutionLogs", (string)null); + b.ToTable("ProcessExecutionLogs"); }); modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => @@ -323,7 +323,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("QualityProfileId"); - b.ToTable("Audiobooks", (string)null); + b.ToTable("Audiobooks"); }); modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookDeletionIntent", b => @@ -535,7 +535,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsUnique() .HasFilter("\"PathOwnershipKey\" IS NOT NULL"); - b.ToTable("AudiobookFiles", (string)null); + b.ToTable("AudiobookFiles"); }); modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookSeriesMembership", b => @@ -630,7 +630,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("AuthorNameNormalized", "Region") .IsUnique(); - b.ToTable("AuthorCacheEntries", (string)null); + b.ToTable("AuthorCacheEntries"); }); modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", b => @@ -895,7 +895,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("AuthorNameNormalized", "Region", "Language") .IsUnique(); - b.ToTable("MonitoredAuthors", (string)null); + b.ToTable("MonitoredAuthors"); }); modelBuilder.Entity("Listenarr.Domain.Audiobooks.MonitoredSeries", b => @@ -951,7 +951,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SeriesNameNormalized", "Region", "Language") .IsUnique(); - b.ToTable("MonitoredSeries", (string)null); + b.ToTable("MonitoredSeries"); }); modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => @@ -1096,7 +1096,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Status", "NextAttemptAt", "LeaseExpiresAt"); - b.ToTable("MoveJobs", (string)null); + b.ToTable("MoveJobs"); }); modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJobCreatedDirectory", b => @@ -1330,7 +1330,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("QualityProfiles", (string)null); + b.ToTable("QualityProfiles"); }); modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolder", b => @@ -1647,7 +1647,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SeriesNameNormalized", "Region") .IsUnique(); - b.ToTable("SeriesCacheEntries", (string)null); + b.ToTable("SeriesCacheEntries"); }); modelBuilder.Entity("Listenarr.Domain.Configuration.ApiConfiguration", b => @@ -1698,7 +1698,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("ApiConfigurations", (string)null); + b.ToTable("ApiConfigurations"); }); modelBuilder.Entity("Listenarr.Domain.Configuration.ApplicationSettings", b => @@ -1853,7 +1853,78 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("ApplicationSettings", (string)null); + b.ToTable("ApplicationSettings"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.CompatibilityFilePublicationJournal", b => + { + b.Property("OperationId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("EffectiveAction") + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("IsCompanionFile") + .HasColumnType("INTEGER"); + + b.Property("ProtocolVersion") + .HasColumnType("INTEGER"); + + b.Property("RequestedAction") + .HasColumnType("INTEGER"); + + b.Property("SourceDisposition") + .HasColumnType("INTEGER"); + + b.Property("SourceLength") + .HasColumnType("INTEGER"); + + b.Property("SourcePath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("TargetLength") + .HasColumnType("INTEGER"); + + b.Property("TargetSha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("OperationId"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("State"); + + b.ToTable("CompatibilityFilePublicationJournals", (string)null); }); modelBuilder.Entity("Listenarr.Domain.Downloads.Download", b => @@ -1972,7 +2043,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Status"); - b.ToTable("Downloads", (string)null); + b.ToTable("Downloads"); }); modelBuilder.Entity("Listenarr.Domain.Downloads.DownloadClientConfiguration", b => @@ -2027,7 +2098,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("DownloadClientConfigurations", (string)null); + b.ToTable("DownloadClientConfigurations"); }); modelBuilder.Entity("Listenarr.Domain.Downloads.DownloadProcessingJob", b => @@ -2101,7 +2172,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("DownloadId", "Status"); - b.ToTable("DownloadProcessingJobs", (string)null); + b.ToTable("DownloadProcessingJobs"); }); modelBuilder.Entity("Listenarr.Domain.Downloads.FileMutationJournal", b => @@ -2215,7 +2286,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("RemotePathMappings", (string)null); + b.ToTable("RemotePathMappings"); }); modelBuilder.Entity("Listenarr.Domain.Identity.User", b => @@ -2243,7 +2314,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("Users", (string)null); + b.ToTable("Users"); }); modelBuilder.Entity("Listenarr.Domain.Identity.UserSession", b => @@ -2286,7 +2357,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Username"); - b.ToTable("UserSessions", (string)null); + b.ToTable("UserSessions"); }); modelBuilder.Entity("Listenarr.Domain.Search.Indexer", b => @@ -2370,7 +2441,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("Indexers", (string)null); + b.ToTable("Indexers"); }); modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => diff --git a/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.PhysicalGeneration.cs b/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.PhysicalGeneration.cs index a37dae9f7..5051cb7c8 100644 --- a/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.PhysicalGeneration.cs +++ b/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.PhysicalGeneration.cs @@ -175,8 +175,12 @@ private static void ApplyPhysicalGeneration( return; } + // The source row may have been materialized from the database, where + // the UTC-by-contract observation time round-trips as Unspecified. target.ApplyPhysicalObjectIdentity( source.PhysicalObjectIdentity, - source.PhysicalIdentityObservedAtUtc.Value); + DateTime.SpecifyKind( + source.PhysicalIdentityObservedAtUtc.Value, + DateTimeKind.Utc)); } } diff --git a/listenarr.infrastructure/packages.lock.json b/listenarr.infrastructure/packages.lock.json index 63b7c93d4..60b34cf61 100644 --- a/listenarr.infrastructure/packages.lock.json +++ b/listenarr.infrastructure/packages.lock.json @@ -355,4 +355,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/Common/PlatformFactAttributes.cs b/tests/Common/PlatformFactAttributes.cs index 87109b060..3cd027251 100644 --- a/tests/Common/PlatformFactAttributes.cs +++ b/tests/Common/PlatformFactAttributes.cs @@ -65,6 +65,48 @@ public ReadOnlyBindMountFactAttribute() } } +public sealed class CrossVolumeFactAttribute : FactAttribute +{ + public const string DestinationPathEnvironmentVariable = + "LISTENARR_CROSS_VOLUME_DESTINATION_PATH"; + + public CrossVolumeFactAttribute() + { + if (!OperatingSystem.IsLinux()) + { + Skip = "This test requires native Linux cross-volume storage."; + return; + } + + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable( + DestinationPathEnvironmentVariable))) + { + Skip = "The native test runner did not provide a destination on another filesystem."; + } + } +} + +public sealed class NetworkStorageTheoryAttribute : TheoryAttribute +{ + public const string PathEnvironmentVariable = + "LISTENARR_NETWORK_STORAGE_PATH"; + + public NetworkStorageTheoryAttribute() + { + if (!OperatingSystem.IsLinux()) + { + Skip = "This test requires a native Linux network filesystem mount."; + return; + } + + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable( + PathEnvironmentVariable))) + { + Skip = "The native test runner did not provide a network filesystem mount."; + } + } +} + public sealed class DirectoryLinkFactAttribute : FactAttribute { public DirectoryLinkFactAttribute() diff --git a/tests/Features/Api/Features/Downloads/ManualImportCompanionImporterTests.cs b/tests/Features/Api/Features/Downloads/ManualImportCompanionImporterTests.cs index a9f6367c3..bf3830df6 100644 --- a/tests/Features/Api/Features/Downloads/ManualImportCompanionImporterTests.cs +++ b/tests/Features/Api/Features/Downloads/ManualImportCompanionImporterTests.cs @@ -8,6 +8,9 @@ namespace Listenarr.Tests.Features.Api.Features.Downloads; [Trait("Category", "Unit")] public sealed class ManualImportCompanionImporterTests : BaseTests { + private static RootFolder[] RootFoldersFor(string testRoot) => + [new RootFolder { Id = 1, Name = "library", Path = Path.Join(testRoot, "library") }]; + private static IFilePublicationSourceCapability SupportedSourceCapability() { var capability = new Mock(MockBehavior.Strict); @@ -117,17 +120,21 @@ await Assert.ThrowsAnyAsync(() => importer.ImportAsy [audiobook.Id] = destinationResolution }, importBlacklist: [], + rootFolders: RootFoldersFor(testRoot), cancellationToken: cancellation.Token)); Assert.True(File.Exists(companionSource)); Assert.False(File.Exists(Path.Join(destinationDirectory, "cover.jpg"))); mover.Verify( - service => service.PerformActionOn( - It.IsAny(), + service => service.PrepareActionForRegistrationDetailedAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny()), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); fileService.VerifyAll(); ownershipStore.VerifyAll(); @@ -175,14 +182,22 @@ public async Task ImportAsync_AudioMoveRegistrationFails_DoesNotRetireSource() var lease = new Mock(MockBehavior.Strict); lease.Setup(service => service.Dispose()); var mover = new Mock(MockBehavior.Strict); - mover.Setup(service => service.PrepareActionForRegistrationAsync( - FileAction.Move, + mover.Setup(service => service.PrepareActionForRegistrationDetailedAsync( + It.Is(plan => + plan.EffectiveAction == FileAction.Move), companionSource, It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) - .ReturnsAsync(lease.Object); + It.IsAny(), + false, + null)) + .ReturnsAsync(new FilePublicationPreparationResult( + FilePublicationOutcome.Success, + FileAction.Move, + FileAction.Move, + FilePublicationSourceDisposition.Retired, + lease.Object)); var audiobook = new Audiobook { Id = 43, @@ -268,7 +283,8 @@ public async Task ImportAsync_AudioMoveRegistrationFails_DoesNotRetireSource() { [audiobook.Id] = destinationResolution }, - importBlacklist: []); + importBlacklist: [], + rootFolders: RootFoldersFor(testRoot)); Assert.Equal(0, imported); Assert.True(File.Exists(companionSource)); @@ -312,15 +328,39 @@ public async Task ImportAsync_SelectedSourceOutsideRequestedRoot_MapsCompanionBe try { string? capturedDestination = null; - var mover = new Mock(); - mover.Setup(service => service.PerformActionOn( - FileAction.Copy, + var publicationCommitted = false; + var lease = new Mock(MockBehavior.Strict); + lease.Setup(service => service.PrepareCleanupRecovery(42)) + .Returns(true); + lease.Setup(service => service.CompletePublication()) + .Callback(() => publicationCommitted = true) + .Returns(RegistrationPublicationCompletion.Completed); + lease.Setup(service => service.Dispose()); + var mover = new Mock(MockBehavior.Strict); + mover.Setup(service => service.PrepareActionForRegistrationDetailedAsync( + It.Is(plan => + plan.EffectiveAction == FileAction.Move), companionSource, It.IsAny(), It.IsAny(), - It.IsAny())) - .Callback((_, _, destination, _, _) => + null, + It.IsAny(), + true, + 42)) + .Callback((_, _, destination, _, _, _, _, _) => capturedDestination = destination) + .ReturnsAsync(new FilePublicationPreparationResult( + FilePublicationOutcome.Success, + FileAction.Move, + FileAction.Move, + FilePublicationSourceDisposition.Retired, + lease.Object)); + mover.Setup(service => service.CompletePreparedMoveAsync( + companionSource, + It.IsAny(), + lease.Object, + It.IsAny())) + .Callback(() => Assert.True(publicationCommitted)) .ReturnsAsync(true); var audiobook = new Audiobook { @@ -382,7 +422,7 @@ public async Task ImportAsync_SelectedSourceOutsideRequestedRoot_MapsCompanionBe }; var imported = await importer.ImportAsync( - FileAction.Copy, + FileAction.Move, items, results, requestedRoot, @@ -393,8 +433,11 @@ public async Task ImportAsync_SelectedSourceOutsideRequestedRoot_MapsCompanionBe { [audiobook.Id] = destinationResolution }, - importBlacklist: []); + importBlacklist: [], + rootFolders: RootFoldersFor(testRoot)); + mover.VerifyAll(); + lease.VerifyAll(); Assert.Equal(1, imported); Assert.Equal( Path.Join(destinationDirectory, "cover.jpg"), @@ -403,6 +446,169 @@ public async Task ImportAsync_SelectedSourceOutsideRequestedRoot_MapsCompanionBe capturedDestination!, destinationDirectory, FileSystemPathSemantics.CurrentHostDefault)); + mover.Verify(service => service.PerformActionOn( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + fileService.VerifyAll(); + } + finally + { + if (Directory.Exists(testRoot)) + { + Directory.Delete(testRoot, recursive: true); + } + } + } + + // LibraryDirectoryOwnershipBoundaryAuthorizer matches the managed boundary against the + // configured root folders by equivalence, not by containment, so a boundary that is merely + // inside a root is refused. The book folder is inside a root and is not one, which is why the + // companion pass has to hand the store the same boundary the audio file's own import already + // hands it. Asserting the argument rather than the outcome is deliberate: the store is a mock + // here, so the real authorizer never runs and nothing else in this file can tell the two + // boundaries apart. + [Fact] + public async Task ImportAsync_ManagedBoundaryIsTheConfiguredRootFolderRatherThanTheBookFolder() + { + var testRoot = Path.Join( + Path.GetTempPath(), + "listenarr-tests", + $"manual-import-companion-boundary-{Guid.NewGuid():N}"); + var libraryRoot = Path.Join(testRoot, "library"); + var sourceDirectory = Path.Join(testRoot, "source"); + var destinationDirectory = Path.Join(libraryRoot, "Author", "Book"); + Directory.CreateDirectory(sourceDirectory); + Directory.CreateDirectory(destinationDirectory); + var audioSource = Path.Join(sourceDirectory, "book.m4b"); + var companionSource = Path.Join(sourceDirectory, "book.nfo"); + var audioDestination = Path.Join(destinationDirectory, "book.m4b"); + await File.WriteAllTextAsync(audioSource, "audio"); + await File.WriteAllTextAsync(companionSource, ""); + + try + { + string? capturedDirectory = null; + string? capturedBoundary = null; + var mover = new Mock(); + mover.Setup(service => service.PerformActionOn( + FileAction.Copy, + companionSource, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(true); + var audiobook = new Audiobook + { + Id = 42, + BasePath = destinationDirectory + }; + var fileService = new Mock(MockBehavior.Strict); + fileService + .Setup(service => service.CheckAudiobookFileOwnershipAsync( + audiobook, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new AudiobookFileOwnershipCheckResult( + AudiobookFileOwnershipCheckOutcome.Available)); + var semanticsResolver = new FileSystemSemanticsResolver(); + var directoryOwnershipStore = new Mock(); + directoryOwnershipStore + .Setup(store => store.EnsureCreatedHierarchyAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + "manual-import-companion", + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (directory, boundary, _, _, _, _, _) => + { + capturedDirectory = directory; + capturedBoundary = boundary; + }) + .ReturnsAsync([]); + // Since #864 the companion pass picks between two store calls on the publication plan, + // and with no capability resolver injected a non-durable source takes the additive one. + // Capture from both, so this test pins the boundary argument whichever path runs. + directoryOwnershipStore + .Setup(store => store.EnsureAdditiveHierarchyAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (directory, boundary, _, _) => + { + capturedDirectory = directory; + capturedBoundary = boundary; + }) + .Returns(Task.CompletedTask); + var importer = new ManualImportCompanionImporter( + Mock.Of(), + mover.Object, + SupportedSourceCapability(), + new LocalFileSystem(), + directoryOwnershipStore.Object, + NullLogger.Instance, + fileService.Object); + var tracker = new ManualImportDestinationTracker( + new LocalFileSystem(), + Mock.Of()); + var sourceResolution = await semanticsResolver.ResolveAsync(sourceDirectory); + var destinationResolution = await semanticsResolver.ResolveAsync(destinationDirectory); + var items = new[] + { + new ManualImportItemDto + { + FullPath = audioSource, + MatchedAudiobookId = audiobook.Id + } + }; + var results = new[] + { + new ManualImportResultDto + { + Success = true, + SourcePath = audioSource, + DestinationPath = audioDestination, + Audiobook = audiobook + } + }; + + var imported = await importer.ImportAsync( + FileAction.Copy, + items, + results, + sourceDirectory, + selectedAudioProfiles: [], + tracker, + sourceResolution.Semantics, + new Dictionary + { + [audiobook.Id] = destinationResolution + }, + importBlacklist: [], + rootFolders: [ + new RootFolder { Id = 1, Name = "library", Path = libraryRoot } + ]); + + // The boundary handed to the ownership store is what this test exists to pin, and it is + // captured before publication is attempted. Since #864 the publication step that follows + // goes through IFileMover.PrepareActionForRegistrationDetailedAsync and a registration + // lease, which this test deliberately does not stand up, so the companion does not + // complete here and `imported` stays 0. Asserting the count would be asserting the mock + // graph rather than the boundary. ManualImportCompanionOwnershipTests covers the + // end-to-end path. + Assert.NotNull(capturedBoundary); + Assert.Equal(destinationDirectory, capturedDirectory); + Assert.Equal(libraryRoot, capturedBoundary); + Assert.NotEqual(destinationDirectory, capturedBoundary); fileService.VerifyAll(); } finally diff --git a/tests/Features/Api/Features/Downloads/ManualImportCompanionOwnershipTests.cs b/tests/Features/Api/Features/Downloads/ManualImportCompanionOwnershipTests.cs index 5d0c17f38..a1f0ea0e9 100644 --- a/tests/Features/Api/Features/Downloads/ManualImportCompanionOwnershipTests.cs +++ b/tests/Features/Api/Features/Downloads/ManualImportCompanionOwnershipTests.cs @@ -142,18 +142,29 @@ public async Task ImportAsync_DestinationOwnedByAnotherAudiobook_DoesNotWriteCom { [targetAudiobook.Id] = destinationResolution }, - importBlacklist: []); + importBlacklist: [], + rootFolders: [ + new RootFolder + { + Id = 1, + Name = "library", + Path = Path.Join(testRoot, "library") + } + ]); Assert.Equal(0, imported); Assert.True(File.Exists(companionSource)); Assert.False(File.Exists(companionDestination)); mover.Verify( - service => service.PerformActionOn( - It.IsAny(), + service => service.PrepareActionForRegistrationDetailedAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny()), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); } } diff --git a/tests/Features/Api/Features/Prowlarr/ProwlarrImportUrlBaseTests.cs b/tests/Features/Api/Features/Prowlarr/ProwlarrImportUrlBaseTests.cs new file mode 100644 index 000000000..aa13049f2 --- /dev/null +++ b/tests/Features/Api/Features/Prowlarr/ProwlarrImportUrlBaseTests.cs @@ -0,0 +1,110 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Net; +using System.Text; +using Listenarr.Api.Dtos; +using Listenarr.Tests.Common; +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Tests.Features.Api.Features.Prowlarr +{ + [Trait("Name", "ProwlarrImportUrlBaseTests")] + [Trait("Category", "Api")] + public class ProwlarrImportUrlBaseTests : BaseTests + { + private const string IndexerPayload = """ + [ + { + "id": 4, + "name": "Example Indexer", + "protocol": "usenet", + "categories": [3030], + "enable": true + } + ] + """; + + /// + /// Stands in for a Prowlarr instance configured with a URL base: anything requested outside that + /// base is answered with a redirect onto it, exactly as the reported deployment behaved. + /// + private sealed class UrlBaseRedirectHandler : HttpMessageHandler + { + private readonly string _urlBase; + + public UrlBaseRedirectHandler(string urlBase) => _urlBase = urlBase; + + public List Requests { get; } = new(); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var uri = request.RequestUri!; + Requests.Add(uri); + + if (!uri.AbsolutePath.StartsWith(_urlBase + "/", StringComparison.Ordinal)) + { + var redirect = new HttpResponseMessage(HttpStatusCode.TemporaryRedirect); + redirect.Headers.Location = new Uri(_urlBase + uri.PathAndQuery, UriKind.Relative); + return Task.FromResult(redirect); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(IndexerPayload, Encoding.UTF8, "application/json") + }); + } + } + + [Fact] + public async Task ImportFromProwlarr_WhenDiscoveryIsRedirectedOntoAUrlBase_StoresProxyUrlsUnderThatBase() + { + var handler = new UrlBaseRedirectHandler("/prowlarr"); + var controller = MockUtils.CreateIndexersController(_provider, handler); + + var result = await controller.ImportFromProwlarr(new ProwlarrImportRequestDto + { + Url = "http://prowlarr.example:9696", + ApiKey = "test-key" + }); + + Assert.IsType(result); + Assert.Contains(handler.Requests, uri => uri.AbsolutePath == "/prowlarr/api/v1/indexer"); + + var imported = Assert.Single(await _indexerRepository.GetAllAsync()); + Assert.Equal("http://prowlarr.example:9696/prowlarr/4/api", imported.Url); + } + + [Fact] + public async Task ImportFromProwlarr_WhenDiscoveryIsNotRedirected_KeepsTheSuppliedBase() + { + var handler = new UrlBaseRedirectHandler(string.Empty); + var controller = MockUtils.CreateIndexersController(_provider, handler); + + var result = await controller.ImportFromProwlarr(new ProwlarrImportRequestDto + { + Url = "http://prowlarr.example:9696", + ApiKey = "test-key" + }); + + Assert.IsType(result); + + var imported = Assert.Single(await _indexerRepository.GetAllAsync()); + Assert.Equal("http://prowlarr.example:9696/4/api", imported.Url); + } + } +} diff --git a/tests/Features/Api/Services/UnmatchedScanBackgroundServiceTests.cs b/tests/Features/Api/Services/UnmatchedScanBackgroundServiceTests.cs index 652824ca5..886131ff3 100644 --- a/tests/Features/Api/Services/UnmatchedScanBackgroundServiceTests.cs +++ b/tests/Features/Api/Services/UnmatchedScanBackgroundServiceTests.cs @@ -119,5 +119,63 @@ public void BuildGroupedFilesForFolder_UsesAuthorToKeepSameTitleSeparated() Assert.Contains(groups, group => group.Single() == fileA); Assert.Contains(groups, group => group.Single() == fileB); } + + [Fact] + public void BuildGroupedFilesForFolder_GroupsChapterFilesIndexedAsNumberOfTotal() + { + // A chapter-per-file rip that numbers its parts "N of M". The index carries its + // own total, so it describes a set rather than distinct works, and every file + // belongs to the one book the folder names. + var folder = @"D:\test\Jack of Shadows"; + var files = Enumerable.Range(1, 4) + .Select(n => Path.Join(folder, $"Jack of Shadows {n:000} of 004.mp3")) + .ToArray(); + + var groups = UnmatchedScanBackgroundService.BuildGroupedFilesForFolder( + files, + folder, + FileSystemPathSemantics.CurrentHostDefault); + + var group = Assert.Single(groups); + Assert.Equal(4, group.Count); + } + + [Fact] + public void BuildGroupedFilesForFolder_GroupsBareNumberOfTotalFilenames() + { + // The same convention with no title in the filename at all. Stripping the index + // empties the stem, which is what lets the folder-name fallback gather them. + var folder = @"D:\test\Jack of Shadows"; + var files = Enumerable.Range(1, 3) + .Select(n => Path.Join(folder, $"{n:000} of 003.mp3")) + .ToArray(); + + var groups = UnmatchedScanBackgroundService.BuildGroupedFilesForFolder( + files, + folder, + FileSystemPathSemantics.CurrentHostDefault); + + var group = Assert.Single(groups); + Assert.Equal(3, group.Count); + } + + [Fact] + public void BuildGroupedFilesForFolder_KeepsTitlesWhoseOwnWordsReadLikeAnIndex() + { + // "of" between two words is not an index, and a trailing number is not a total. + // Two separate works in one author folder must stay separate. + var folder = @"D:\test\Roger Zelazny"; + var jack = Path.Join(folder, "Jack of Shadows.mp3"); + var nine = Path.Join(folder, "Nine Princes in Amber 2.mp3"); + + var groups = UnmatchedScanBackgroundService.BuildGroupedFilesForFolder( + new[] { jack, nine }, + folder, + FileSystemPathSemantics.CurrentHostDefault); + + Assert.Equal(2, groups.Count); + Assert.Contains(groups, group => group.Single() == jack); + Assert.Contains(groups, group => group.Single() == nine); + } } } diff --git a/tests/Features/Application/Audiobooks/Files/AudiobookFileServiceCoordinationTests.cs b/tests/Features/Application/Audiobooks/Files/AudiobookFileServiceCoordinationTests.cs index bc00bc48a..a6be5d2d4 100644 --- a/tests/Features/Application/Audiobooks/Files/AudiobookFileServiceCoordinationTests.cs +++ b/tests/Features/Application/Audiobooks/Files/AudiobookFileServiceCoordinationTests.cs @@ -36,6 +36,7 @@ public async Task ClaimAudiobookFileAsync_UnresolvedMoveExecution_BlocksBeforeCa fileRepository.Object, Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), @@ -135,6 +136,7 @@ public async Task ClaimAudiobookFileAsync_AcquiresGlobalBoundaryBeforeAudiobookL fileRepository.Object, Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), fileSystem.Object, diff --git a/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringConcurrencyTests.cs b/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringConcurrencyTests.cs new file mode 100644 index 000000000..087e54e46 --- /dev/null +++ b/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringConcurrencyTests.cs @@ -0,0 +1,111 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Tests.Common; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Listenarr.Tests.Features.Application.Audiobooks.Quality +{ + [Trait("Name", "QualityProfileScoringConcurrencyTests")] + [Trait("Category", "Unit")] + public class QualityProfileScoringConcurrencyTests : BaseTests + { + // IIndexerRepository is registered scoped and the ListenArrDbContext behind it is scoped + // too, so every repository in a scope shares one context. EF rejects a second operation + // started on a context while another is in flight. Asserting on the real exception would + // mean racing it, so this counts overlap directly: the stub records the highest number of + // calls it ever had in flight at once. Anything above one is the condition EF refuses. + private sealed class OverlapRecordingIndexerRepository : IIndexerRepository + { + private int _inFlight; + public int MaxConcurrent { get; private set; } + public int CallCount { get; private set; } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) + { + var now = Interlocked.Increment(ref _inFlight); + lock (this) + { + CallCount++; + if (now > MaxConcurrent) MaxConcurrent = now; + } + + // A real query is not instantaneous. Without this the tasks can complete one at a + // time by luck and the overlap the test exists to catch would go unobserved. + await Task.Delay(20); + + Interlocked.Decrement(ref _inFlight); + return new Indexer { Id = id, Name = $"indexer-{id}", Type = "Usenet", Retention = 1500 }; + } + + public Task> GetAllAsync(CancellationToken ct = default) => + Task.FromResult(new List()); + public Task> GetEnabledAsync(bool isAutomaticSearch, CancellationToken ct = default) => + Task.FromResult(new List()); + public Task GetByNameAsync(string name, CancellationToken ct = default) => + Task.FromResult(null); + public Task AddAsync(Indexer indexer, CancellationToken ct = default) => + Task.FromResult(indexer); + public Task UpdateAsync(Indexer indexer, CancellationToken ct = default) => Task.CompletedTask; + public Task DeleteAsync(int id, CancellationToken ct = default) => Task.CompletedTask; + } + + [Fact] + public async Task ScoreSearchResults_DoesNotQueryTheIndexerRepositoryConcurrently() + { + var indexerRepository = new OverlapRecordingIndexerRepository(); + var service = new QualityProfileService( + Mock.Of(), + NullLogger.Instance, + indexerRepository); + + // Twelve results across three indexers: enough to overlap, and enough to show that the + // batch does not need one query per result. + var searchResults = Enumerable.Range(0, 12) + .Select(i => new SearchResult + { + Id = $"result-{i}", + Title = $"A Book {i}", + IndexerId = (i % 3) + 1, + Format = "mp3", + Language = "English", + PublishedDate = DateTime.UtcNow.AddDays(-1).ToString("o") + }) + .ToList(); + + var profile = new QualityProfile + { + MinimumSize = 0, + MaximumSize = 0, + PreferredFormats = ["mp3"], + PreferredWords = [], + MustNotContain = [], + MustContain = [], + PreferredLanguages = ["English"], + MinimumSeeders = 0, + MaximumAge = 3650 + }; + + var scores = await service.ScoreSearchResults(searchResults, profile); + + Assert.Equal(searchResults.Count, scores.Count); + Assert.Equal(1, indexerRepository.MaxConcurrent); + // Three distinct indexers, so three lookups rather than one per result. + Assert.Equal(3, indexerRepository.CallCount); + } + } +} diff --git a/tests/Features/Application/Downloads/Common/DownloadClientGatewayPathMappingConcurrencyTests.cs b/tests/Features/Application/Downloads/Common/DownloadClientGatewayPathMappingConcurrencyTests.cs new file mode 100644 index 000000000..675f5218a --- /dev/null +++ b/tests/Features/Application/Downloads/Common/DownloadClientGatewayPathMappingConcurrencyTests.cs @@ -0,0 +1,165 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Tests.Common; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Listenarr.Tests.Features.Application.Downloads.Common; + +[Trait("Name", "DownloadClientGatewayPathMappingConcurrencyTests")] +[Trait("Category", "Unit")] +public sealed class DownloadClientGatewayPathMappingConcurrencyTests : BaseTests +{ + // IRemotePathMappingService is scoped and the ListenArrDbContext behind its repository is + // scoped too. GetQueueAsync fans out over every queue item, and each item used to look up the + // client's mappings for itself, so a queue of N items issued N concurrent queries against a + // context that permits one at a time. Asserting on the EF exception would mean racing it, so + // this counts the overlap directly. + private sealed class OverlapRecordingMappingService : IRemotePathMappingService + { + private int _inFlight; + public int MaxConcurrentLookups { get; private set; } + public int LookupCount { get; private set; } + + public async Task> GetPathMappingByClientAsync( + DownloadClientConfiguration client) + { + var now = Interlocked.Increment(ref _inFlight); + lock (this) + { + LookupCount++; + if (now > MaxConcurrentLookups) MaxConcurrentLookups = now; + } + + // A real query is not instantaneous; without this the overlap can go unobserved. + await Task.Delay(20); + + Interlocked.Decrement(ref _inFlight); + return []; + } + + public string TranslatePath( + IReadOnlyList mappings, + DownloadClientConfiguration client, + string remotePath) => remotePath; + + public async Task TranslatePathAsync( + DownloadClientConfiguration client, + string remotePath) + { + var mappings = await GetPathMappingByClientAsync(client); + return TranslatePath(mappings, client, remotePath); + } + + public Task> GetAllAsync() => + Task.FromResult(new List()); + public Task GetByIdAsync(int id) => + Task.FromResult(null); + public Task CreateAsync(RemotePathMapping mapping) => + Task.FromResult(mapping); + public Task UpdateAsync(RemotePathMapping mapping) => + Task.FromResult(mapping); + public Task DeleteAsync(int id) => Task.FromResult(true); + } + + [Fact] + public async Task GetQueueAsync_ResolvesClientMappingsOncePerBatch() + { + var mappingService = new OverlapRecordingMappingService(); + var client = new DownloadClientConfiguration + { + Id = "client-1", + Name = "qbittorrent", + Type = "qBittorrent" + }; + + var items = Enumerable.Range(0, 10) + .Select(i => new QueueItem + { + Id = $"item-{i}", + RemotePath = $"/remote/downloads/book-{i}", + ContentPath = $"/remote/downloads/book-{i}/audio.m4b" + }) + .ToList(); + + var adapter = new Mock(); + adapter.Setup(a => a.GetQueueAsync(client, It.IsAny())) + .ReturnsAsync(items); + var factory = new Mock(); + factory.Setup(f => f.GetByType(It.IsAny())).Returns(adapter.Object); + + var gateway = new DownloadClientGateway( + mappingService, + factory.Object, + new LocalFileSystem(), + new FileSystemSemanticsResolver(), + NullLogger.Instance); + + await gateway.GetQueueAsync(client); + + Assert.Equal(1, mappingService.MaxConcurrentLookups); + // Ten items, each carrying two translatable paths, resolved from one lookup. + Assert.Equal(1, mappingService.LookupCount); + } + + [Fact] + public async Task GetQueueAsync_ResolvesOncePerBatch_WhenItemsCarrySourceFiles() + { + var mappingService = new OverlapRecordingMappingService(); + var client = new DownloadClientConfiguration + { + Id = "client-1", + Name = "qbittorrent", + Type = "qBittorrent" + }; + + // qBittorrent's queue mapper populates SourceFiles from the torrent's file list, so a + // real queue item arrives with one entry per file rather than with the list empty. + var items = Enumerable.Range(0, 10) + .Select(i => new QueueItem + { + Id = $"item-{i}", + RemotePath = $"/remote/downloads/book-{i}", + ContentPath = $"/remote/downloads/book-{i}/audio.m4b", + SourceFiles = + [ + $"/remote/downloads/book-{i}/01.m4b", + $"/remote/downloads/book-{i}/02.m4b", + $"/remote/downloads/book-{i}/03.m4b" + ] + }) + .ToList(); + + var adapter = new Mock(); + adapter.Setup(a => a.GetQueueAsync(client, It.IsAny())) + .ReturnsAsync(items); + var factory = new Mock(); + factory.Setup(f => f.GetByType(It.IsAny())).Returns(adapter.Object); + + var gateway = new DownloadClientGateway( + mappingService, + factory.Object, + new LocalFileSystem(), + new FileSystemSemanticsResolver(), + NullLogger.Instance); + + await gateway.GetQueueAsync(client); + + Assert.Equal(1, mappingService.MaxConcurrentLookups); + Assert.Equal(1, mappingService.LookupCount); + } +} diff --git a/tests/Features/Application/Downloads/Common/DownloadClientGatewayTests.cs b/tests/Features/Application/Downloads/Common/DownloadClientGatewayTests.cs index 732f98112..10977aba6 100644 --- a/tests/Features/Application/Downloads/Common/DownloadClientGatewayTests.cs +++ b/tests/Features/Application/Downloads/Common/DownloadClientGatewayTests.cs @@ -390,6 +390,11 @@ public async Task GetQueueItemAsync_DedupesCaseOnlySourceFilesUsingResolvedSeman It.IsAny(), It.IsAny())) .ReturnsAsync((DownloadClientConfiguration _, string path) => path); + mapping.Setup(service => service.TranslatePath( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns((IReadOnlyList _, DownloadClientConfiguration _, string path) => path); var resolver = new Mock(); resolver.Setup(service => service.ResolveAsync( It.IsAny(), diff --git a/tests/Features/Application/Downloads/Queue/DownloadOrphanCleanupServiceTests.cs b/tests/Features/Application/Downloads/Queue/DownloadOrphanCleanupServiceTests.cs index e3ba31431..cd294d0ed 100644 --- a/tests/Features/Application/Downloads/Queue/DownloadOrphanCleanupServiceTests.cs +++ b/tests/Features/Application/Downloads/Queue/DownloadOrphanCleanupServiceTests.cs @@ -86,7 +86,7 @@ await service.RemoveOrphansAsync( [Fact] [Trait("Method", "RemoveOrphansAsync")] - public async Task RemoveOrphansAsync_DoesNotRemoveIdlessDownloadWhenLiveSnapshotIsEmpty() + public async Task RemoveOrphansAsync_RemovesIdlessDownloadWhenLiveSnapshotIsEmpty() { var client = CreateClient(); var download = await AddDownloadAsync( @@ -102,6 +102,96 @@ await service.RemoveOrphansAsync( [], [download]); + Assert.Null(await _downloadRepository.GetByIdAsync(download.Id)); + _metricsMock.Verify(m => m.Increment("download.orphan.unlinked_removed", 1), Times.Once); + _metricsMock.Verify(m => m.Increment("download.orphan.removed", It.IsAny()), Times.Never); + } + + [Fact] + [Trait("Method", "RemoveOrphansAsync")] + public async Task RemoveOrphansAsync_RemovesOnlyTrackedDownloadWhenLiveSnapshotIsEmpty() + { + var client = CreateClient(); + var download = await AddDownloadAsync( + id: "sole-active-orphan", + clientId: client.Id, + status: DownloadStatus.Downloading, + startedAt: DateTime.UtcNow.AddMinutes(-10), + metadata: new Dictionary + { + ["ClientDownloadId"] = "deleted-in-client" + }); + var service = _provider.GetRequiredService(); + + await service.RemoveOrphansAsync( + client, + CreateLiveSnapshot(client, []), + [], + [download]); + + Assert.Null(await _downloadRepository.GetByIdAsync(download.Id)); + _metricsMock.Verify(m => m.Increment("download.orphan.removed", 1), Times.Once); + _metricsMock.Verify(m => m.Increment("download.orphan.unlinked_removed", It.IsAny()), Times.Never); + } + + [Fact] + [Trait("Method", "RemoveOrphansAsync")] + public async Task RemoveOrphansAsync_DoesNotRemoveRecentDownloadWhenLiveSnapshotIsEmpty() + { + var client = CreateClient(); + var download = await AddDownloadAsync( + id: "recent-empty-snapshot-download", + clientId: client.Id, + status: DownloadStatus.Downloading, + startedAt: DateTime.UtcNow.AddMinutes(-1), + metadata: new Dictionary + { + ["ClientDownloadId"] = "deleted-in-client" + }); + var service = _provider.GetRequiredService(); + + await service.RemoveOrphansAsync( + client, + CreateLiveSnapshot(client, []), + [], + [download]); + + Assert.NotNull(await _downloadRepository.GetByIdAsync(download.Id)); + _metricsMock.Verify(m => m.Increment(It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [Trait("Method", "RemoveOrphansAsync")] + [InlineData(true, false)] + [InlineData(false, true)] + public async Task RemoveOrphansAsync_DoesNotRemoveWhenEmptySnapshotIsNotTrusted(bool usedCachedSnapshot, bool isUnavailable) + { + var client = CreateClient(); + var download = await AddDownloadAsync( + id: "untrusted-empty-snapshot-download", + clientId: client.Id, + status: DownloadStatus.Downloading, + startedAt: DateTime.UtcNow.AddMinutes(-10), + metadata: new Dictionary + { + ["ClientDownloadId"] = "deleted-in-client" + }); + var service = _provider.GetRequiredService(); + + await service.RemoveOrphansAsync( + client, + new ClientQueueFetchResult( + client, + [], + usedCachedSnapshot, + isUnavailable, + snapshotAge: null, + failureReason: isUnavailable ? "unavailable" : null, + snapshotState: isUnavailable ? "unavailable" : "cached", + snapshotRefreshedAtUtc: DateTimeOffset.UtcNow), + [], + [download]); + Assert.NotNull(await _downloadRepository.GetByIdAsync(download.Id)); _metricsMock.Verify(m => m.Increment(It.IsAny(), It.IsAny()), Times.Never); } diff --git a/tests/Features/Application/Metadata/AudiobookMetadataRefreshServiceTests.cs b/tests/Features/Application/Metadata/AudiobookMetadataRefreshServiceTests.cs new file mode 100644 index 000000000..c091b7bce --- /dev/null +++ b/tests/Features/Application/Metadata/AudiobookMetadataRefreshServiceTests.cs @@ -0,0 +1,78 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Application.Metadata +{ + [Trait("Name", "AudiobookMetadataRefreshServiceTests")] + [Trait("Category", "Application")] + public class AudiobookMetadataRefreshServiceTests : BaseTests + { + [Fact] + public void FillMissingFields_FillsEmptyFields_WithoutOverwritingExisting() + { + var audiobook = new Audiobook + { + Title = "My Existing Title", // already set — must be preserved + Narrators = new List() // empty — should be filled + }; + var metadata = new AudibleBookMetadata + { + Title = "Provider Title", + Narrators = new List { "Erin Bennett" }, + Publisher = "Little, Brown & Company", + PublishYear = "2018", + Description = "A novel.", + Runtime = 728 + }; + + var changed = AudiobookMetadataRefreshService.FillMissingFields(audiobook, metadata); + + Assert.True(changed); + Assert.Equal("My Existing Title", audiobook.Title); // not overwritten + Assert.Equal(new[] { "Erin Bennett" }, audiobook.Narrators); // filled + Assert.Equal("Little, Brown & Company", audiobook.Publisher); + Assert.Equal("2018", audiobook.PublishYear); + Assert.Equal("A novel.", audiobook.Description); + Assert.Equal(728, audiobook.Runtime); + } + + [Fact] + public void FillMissingFields_ReturnsFalse_WhenNothingToFill() + { + var audiobook = new Audiobook + { + Title = "Title", + Publisher = "Publisher", + Narrators = new List { "Someone" } + }; + var metadata = new AudibleBookMetadata + { + Title = "Other Title", + Publisher = "Other Publisher", + Narrators = new List { "Someone Else" } + }; + + var changed = AudiobookMetadataRefreshService.FillMissingFields(audiobook, metadata); + + Assert.False(changed); + Assert.Equal("Title", audiobook.Title); + Assert.Equal("Publisher", audiobook.Publisher); + } + } +} diff --git a/tests/Features/Application/Search/IndexerSearchWorkflowTests.cs b/tests/Features/Application/Search/IndexerSearchWorkflowTests.cs new file mode 100644 index 000000000..5160540c4 --- /dev/null +++ b/tests/Features/Application/Search/IndexerSearchWorkflowTests.cs @@ -0,0 +1,44 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Application.Search +{ + [Trait("Area", "Search")] + [Trait("Name", "IndexerSearchWorkflowTests")] + [Trait("Category", "IndexerSearchWorkflow")] + public class IndexerSearchWorkflowTests : BaseTests + { + [Fact] + [Trait("Method", "SearchIndexersAsync")] + [Trait("Scenario", "NoIndexersConfiguredReturnsEmptyNotMockResults")] + public async Task SearchIndexers_NoIndexersConfigured_ReturnsEmptyWithoutSyntheticResults() + { + // Given + var workflow = _provider.GetRequiredService(); + Assert.Empty(await _indexerRepository.GetEnabledAsync(isAutomaticSearch: false)); + + // When + var results = await workflow.SearchIndexersAsync("Dune"); + + // Then + Assert.Empty(results); + } + } +} diff --git a/tests/Features/Application/Search/Indexers/Common/IndexerSearchWorkflowTests.cs b/tests/Features/Application/Search/Indexers/Common/IndexerSearchWorkflowTests.cs new file mode 100644 index 000000000..2cab14deb --- /dev/null +++ b/tests/Features/Application/Search/Indexers/Common/IndexerSearchWorkflowTests.cs @@ -0,0 +1,114 @@ +using Listenarr.Tests.Builders; +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Application.Search.Indexers.Common +{ + [Trait("Name", "IndexerSearchWorkflowTests")] + [Trait("Category", "IndexerSearchWorkflow")] + public class IndexerSearchWorkflowTests : BaseTests + { + private static IndexerSearchWorkflow CreateWorkflow( + IEnumerable enabledIndexers, + IEnumerable providers) + { + var indexerRepository = new Mock(); + indexerRepository + .Setup(r => r.GetEnabledAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(enabledIndexers.ToList()); + + var additionalSettingsParser = new IndexerAdditionalSettingsParser( + Mock.Of>()); + + return new IndexerSearchWorkflow( + new HttpClient(), + Mock.Of(), + indexerRepository.Object, + providers, + additionalSettingsParser, + Mock.Of>()); + } + + [Fact] + public async Task SearchIndexersAsync_OneIndexerTimesOut_ReturnsHealthyIndexerResults() + { + // Given: two enabled indexers, one whose provider times out (TaskCanceledException) + // and one that returns results + var timingOutIndexer = new IndexerBuilder() + .WithName("SlowIndexer") + .WithImplementation("Slow") + .Build(); + var healthyIndexer = new IndexerBuilder() + .WithName("FastIndexer") + .WithImplementation("Fast") + .Build(); + + var healthyResult = new IndexerSearchResult + { + Id = "healthy-1", + Title = "The Healthy Result", + Source = "FastIndexer", + Seeders = 42 + }; + + var providers = new IIndexerSearchProvider[] + { + new FakeSearchProvider("Slow", () => throw new TaskCanceledException("The request was canceled due to the configured HttpClient.Timeout")), + new FakeSearchProvider("Fast", () => new List { healthyResult }) + }; + + var workflow = CreateWorkflow(new[] { timingOutIndexer, healthyIndexer }, providers); + + // When: an automatic search runs across both indexers + var results = await workflow.SearchIndexersAsync("some query", isAutomaticSearch: true); + + // Then: the healthy indexer's results survive and no exception escapes + Assert.Single(results); + Assert.Equal("healthy-1", results[0].Id); + Assert.Equal("FastIndexer", results[0].Source); + } + + [Fact] + public async Task SearchIndexersAsync_AllIndexersTimeOut_ReturnsEmptyWithoutThrowing() + { + // Given: every enabled indexer's provider times out + var indexerA = new IndexerBuilder().WithName("A").WithImplementation("SlowA").Build(); + var indexerB = new IndexerBuilder().WithName("B").WithImplementation("SlowB").Build(); + + var providers = new IIndexerSearchProvider[] + { + new FakeSearchProvider("SlowA", () => throw new TaskCanceledException()), + new FakeSearchProvider("SlowB", () => throw new TaskCanceledException()) + }; + + var workflow = CreateWorkflow(new[] { indexerA, indexerB }, providers); + + // When: a search runs across both timing-out indexers + var results = await workflow.SearchIndexersAsync("some query", isAutomaticSearch: true); + + // Then: the timeouts are contained and the search returns an empty list, not an exception + Assert.Empty(results); + } + + private sealed class FakeSearchProvider : IIndexerSearchProvider + { + private readonly Func> _behavior; + + public FakeSearchProvider(string indexerType, Func> behavior) + { + IndexerType = indexerType; + _behavior = behavior; + } + + public string IndexerType { get; } + + public Task> SearchAsync( + Indexer indexer, + string query, + string? category = null, + SearchRequest? request = null) + { + return Task.FromResult(_behavior()); + } + } + } +} diff --git a/tests/Features/Application/Search/Metadata/MetadataConvertersSeriesAsinTests.cs b/tests/Features/Application/Search/Metadata/MetadataConvertersSeriesAsinTests.cs new file mode 100644 index 000000000..199e707a9 --- /dev/null +++ b/tests/Features/Application/Search/Metadata/MetadataConvertersSeriesAsinTests.cs @@ -0,0 +1,95 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Tests.Common; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Listenarr.Tests.Features.Application.Search.Metadata +{ + /// + /// Audnexus returns an ASIN for each series a book belongs to, and + /// AudiobookSeriesMembership has a SeriesAsin column to hold it. The converter maps the + /// series name and position but drops the ASIN, so SeriesAsin is never populated. + /// + /// The series ASIN is the stable identifier: a book ASIN is per-marketplace and + /// per-narrator, and a series name is free text that varies between editions. + /// + /// Data below is from the live Audnexus record for H. Rider Haggard's "She and Allan" + /// (B00CQ5WAXW), which belongs to two series at once. + /// + [Trait("Name", "MetadataConvertersSeriesAsinTests")] + [Trait("Category", "Application")] + public class MetadataConvertersSeriesAsinTests : BaseTests + { + private static MetadataConverters Converter() => + new(imageCacheService: null, + NullLogger.Instance, + requestContextAccessor: null); + + private static AudnexusBookResponse SheAndAllan() => new() + { + Asin = "B00CQ5WAXW", + Title = "She and Allan", + SeriesPrimary = new AudnexusSeries + { + Asin = "B01E633FQM", + Name = "Ayesha", + Position = "0", + }, + SeriesSecondary = new AudnexusSeries + { + Asin = "B01F5TL5K4", + Name = "Allan Quatermain", + Position = "7", + }, + }; + + [Fact] + public void PrimarySeriesAsin_IsMapped() + { + var metadata = Converter().ConvertAudnexusToMetadata(SheAndAllan(), "B00CQ5WAXW"); + + var primary = metadata.SeriesMemberships?.Single(m => m.IsPrimary); + + Assert.NotNull(primary); + Assert.Equal("Ayesha", primary!.SeriesName); + Assert.Equal("0", primary.SeriesNumber); + Assert.Equal("B01E633FQM", primary.SeriesAsin); + } + + [Fact] + public void SecondarySeriesAsin_IsMapped() + { + var metadata = Converter().ConvertAudnexusToMetadata(SheAndAllan(), "B00CQ5WAXW"); + + var secondary = metadata.SeriesMemberships?.Single(m => !m.IsPrimary); + + Assert.NotNull(secondary); + Assert.Equal("Allan Quatermain", secondary!.SeriesName); + Assert.Equal("7", secondary.SeriesNumber); + Assert.Equal("B01F5TL5K4", secondary.SeriesAsin); + } + + [Fact] + public void ABookInTwoSeries_KeepsBothMemberships() + { + var metadata = Converter().ConvertAudnexusToMetadata(SheAndAllan(), "B00CQ5WAXW"); + + Assert.Equal(2, metadata.SeriesMemberships?.Count); + } + } +} diff --git a/tests/Features/Domain/Audiobooks/SeriesPositionReproTests.cs b/tests/Features/Domain/Audiobooks/SeriesPositionReproTests.cs new file mode 100644 index 000000000..c42c46440 --- /dev/null +++ b/tests/Features/Domain/Audiobooks/SeriesPositionReproTests.cs @@ -0,0 +1,158 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Globalization; +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Domain.Audiobooks +{ + /// + /// Audible/Audnexus report a series position as a STRING, and it is not always a number. + /// Real, live examples from the catalogue: + /// + /// "The Father Brown Collection: Books 1-4" -> position "1-4" (one ASIN, four books) + /// "The Thirty-Nine Steps" -> position "1-2" (bundles its sequel) + /// "She and Allan" -> position "0" (prequel slot) + /// a novella between two books -> position "1.5" + /// + /// Two defects followed from squeezing that string through a decimal: + /// + /// 1. The parse used the server's culture. Where '.' is the group separator (de-DE), + /// "1.5" parses as 15; under fr-FR it does not parse at all. + /// 2. A position that does not parse became null, which is indistinguishable from a + /// book with NO series position -- so naming fell through to the track number and + /// wrote it into the filename as if it were the series number. + /// + [Trait("Name", "SeriesPositionReproTests")] + [Trait("Category", "Domain")] + public sealed class SeriesPositionReproTests : BaseTests + { + private static Audiobook Book(string? seriesNumber) => new() + { + Title = "Test", + Series = "Test Series", + SeriesNumber = seriesNumber, + }; + + private static FileNamingService Naming() + { + var config = new Mock(); + var logger = new Mock>(); + return new FileNamingService(config.Object, logger.Object); + } + + // --------------------------------------------------------------- + // 1. Culture: the source always uses '.', so parsing must be invariant. + // --------------------------------------------------------------- + + [Theory] + [InlineData("en-US")] + [InlineData("de-DE")] // '.' is the GROUP separator here -- "1.5" once parsed as 15 + [InlineData("fr-FR")] // "1.5" once failed to parse at all + public void DecimalPosition_SurvivesAnyServerCulture(string culture) + { + var original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo(culture); + var metadata = Book("1.5").CreateBasicAudioMetadata(); + + Assert.Equal(1.5m, metadata.SeriesPosition); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + [Fact] + public void DecimalPosition_IsWrittenInvariantly_NotWithALocalDecimalComma() + { + var original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + var metadata = Book("1.5").CreateBasicAudioMetadata(); + + var name = Naming().ApplyNamingPattern("{SeriesNumber}", metadata, treatAsFilename: true); + + // Not "1,5" -- a comma in a filename is a locale leaking onto disk. + Assert.Equal("1.5", name); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + // --------------------------------------------------------------- + // 2. A real but non-numeric position must not be lost. + // --------------------------------------------------------------- + + [Theory] + [InlineData("1-4")] // The Father Brown Collection: Books 1-4 + [InlineData("1-2")] // The Thirty-Nine Steps (bundles Greenmantle) + public void RangePosition_IsPreserved_EvenThoughItIsNotADecimal(string position) + { + var metadata = Book(position).CreateBasicAudioMetadata(); + + // decimal? genuinely cannot hold "1-4", and should not try. + Assert.Null(metadata.SeriesPosition); + + // But the value is real and must survive. + Assert.Equal(position, metadata.SeriesPositionRaw); + } + + [Theory] + [InlineData("1-4")] + [InlineData("1-2")] + public void RangePosition_ReachesTheFilename_AndIsNotReplacedByTheTrackNumber(string position) + { + var metadata = Book(position).CreateBasicAudioMetadata(); + metadata.TrackNumber = 7; // the value that used to be written instead + + var name = Naming().ApplyNamingPattern("{SeriesNumber}", metadata, treatAsFilename: true); + + Assert.Equal(position, name); + Assert.NotEqual("7", name); + } + + [Fact] + public void AbsentPosition_StillFallsBackToTheTrackNumber() + { + // The fallback itself is deliberate and must be preserved: a book with no series + // position at all should still get the track number. The bug was that a REAL + // position was being treated as an absent one. + var metadata = Book(null).CreateBasicAudioMetadata(); + metadata.TrackNumber = 7; + + var name = Naming().ApplyNamingPattern("{SeriesNumber}", metadata, treatAsFilename: true); + + Assert.Equal("7", name); + } + + [Fact] + public void ZeroPosition_Survives() + { + // She and Allan sits at position "0" of the Ayesha series. + var metadata = Book("0").CreateBasicAudioMetadata(); + + Assert.Equal(0m, metadata.SeriesPosition); + Assert.Equal("0", metadata.SeriesPositionRaw); + } + } +} diff --git a/tests/Features/Infrastructure/Configuration/OperationalOptionsValidatorTests.cs b/tests/Features/Infrastructure/Configuration/OperationalOptionsValidatorTests.cs index 9e01ac808..5d04bf047 100644 --- a/tests/Features/Infrastructure/Configuration/OperationalOptionsValidatorTests.cs +++ b/tests/Features/Infrastructure/Configuration/OperationalOptionsValidatorTests.cs @@ -35,6 +35,24 @@ public void FileMoverOptions_InvalidBackoffAndTimeout_FailValidation() Assert.Contains(result.Failures, failure => failure.Contains("MaxBackoffMs", StringComparison.Ordinal)); } + [Fact] + public void FileMoverOptions_UnknownWeakPublicationMode_FailsValidation() + { + var result = new FileMoverOptionsValidator().Validate( + null, + new FileMoverOptions + { + WeakPublicationMode = (WeakPublicationMode)999 + }); + + Assert.False(result.Succeeded); + Assert.Contains( + result.Failures, + failure => failure.Contains( + "WeakPublicationMode", + StringComparison.Ordinal)); + } + [Fact] public void ExternalRequestOptions_InvalidTimeoutAndRetries_FailValidation() { diff --git a/tests/Features/Infrastructure/DownloadClients/Common/DownloadClientCircuitBreakerIsolationTests.cs b/tests/Features/Infrastructure/DownloadClients/Common/DownloadClientCircuitBreakerIsolationTests.cs new file mode 100644 index 000000000..8e135e3ca --- /dev/null +++ b/tests/Features/Infrastructure/DownloadClients/Common/DownloadClientCircuitBreakerIsolationTests.cs @@ -0,0 +1,61 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Net; +using Listenarr.Infrastructure.DependencyInjection.DownloadClients; +using Listenarr.Tests.Common; +using Polly.CircuitBreaker; + +namespace Listenarr.Tests.Features.Infrastructure.DownloadClients.Common; + +[Trait("Name", "DownloadClientCircuitBreakerIsolationTests")] +[Trait("Category", "Infrastructure")] +public sealed class DownloadClientCircuitBreakerIsolationTests : BaseTests +{ + // A Polly circuit breaker is stateful: its open/closed state and failure count live in the + // policy instance. One instance shared across the download clients is one global breaker, so a + // run of failures against qBittorrent stops Transmission, SABnzbd and NZBGet too. + // + // This drives the policies directly rather than through HttpClient. Going through the named + // clients would also pass through the retry policy, whose backoff is 2, 4 and 8 seconds, so + // opening a breaker that way costs about 45 seconds. The property that matters is whether two + // clients share breaker state, and that is observable here in milliseconds. + [Fact] + public async Task CircuitBreakerPolicies_DoNotShareStateBetweenClients() + { + var first = DownloadClientRegistrationExtensions.CreateCircuitBreakerPolicy(); + var second = DownloadClientRegistrationExtensions.CreateCircuitBreakerPolicy(); + + Assert.NotSame(first, second); + + // Three consecutive transient failures is the configured threshold. + for (var attempt = 0; attempt < 3; attempt++) + { + await first.ExecuteAsync(() => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable))); + } + + await Assert.ThrowsAnyAsync(() => + first.ExecuteAsync(() => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)))); + + // The second client is untouched by the first client's failures. + var stillWorking = await second.ExecuteAsync(() => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + Assert.Equal(HttpStatusCode.OK, stillWorking.StatusCode); + } +} diff --git a/tests/Features/Infrastructure/DownloadClients/Common/UsenetAdapterFilteringTests.cs b/tests/Features/Infrastructure/DownloadClients/Common/UsenetAdapterFilteringTests.cs index e9f55f6b3..0a3759b77 100644 --- a/tests/Features/Infrastructure/DownloadClients/Common/UsenetAdapterFilteringTests.cs +++ b/tests/Features/Infrastructure/DownloadClients/Common/UsenetAdapterFilteringTests.cs @@ -153,7 +153,11 @@ public async Task Sabnzbd_GetQueueAndItems_FilterByConfiguredCategory() } }; - var queue = await adapter.GetQueueAsync(client, ["SABnzbd_nzo_1", "SABnzbd_nzo_2"], CancellationToken.None); + // Category filtering scopes untracked discovery (the no-tracked-ids display + // path). A tracked nzo_id bypasses it so a grabbed job SABnzbd reassigned to + // Default is never hidden; here no ids are tracked, so the foreign-category + // "Movie One" slot is excluded and only the audiobooks slot survives. + var queue = await adapter.GetQueueAsync(client, CancellationToken.None); Assert.Single(queue); Assert.Equal("Book One", queue[0].Title); diff --git a/tests/Features/Infrastructure/DownloadClients/Qbittorrent/QbittorrentAdapterTests.cs b/tests/Features/Infrastructure/DownloadClients/Qbittorrent/QbittorrentAdapterTests.cs index 038abe508..d2909b5cc 100644 --- a/tests/Features/Infrastructure/DownloadClients/Qbittorrent/QbittorrentAdapterTests.cs +++ b/tests/Features/Infrastructure/DownloadClients/Qbittorrent/QbittorrentAdapterTests.cs @@ -98,6 +98,35 @@ public async Task TestConnection_NormalizesHostWithSchemeAndPath() Assert.Equal("/api/v2/app/version", uri.AbsolutePath); } + [Fact] + public async Task TestConnection_WithUrlBase_PrefixesApiPath() + { + var mock = _provider.GetRequiredService(); + + var client = await _downloadClientConfigurationRepository.SaveAsync(new DownloadClientConfigurationBuilder() + .WithHost("192.168.50.111") + .WithPort(8080) + .WithoutSsl() + .WithType("qbittorrent") + .WithUsername("admin") + .WithPassword("admin") + .WithUrlBase("/qbittorrent") + .Build()); + + var adapter = _provider.GetRequiredService(); + var (success, message) = await adapter.TestConnectionAsync(client); + + Assert.True(success); + Assert.Contains("Successfully connected to qBittorrent", message, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(mock.GetLastRequest()); + + var uri = mock.GetLastRequest().RequestUri; + // Unlike Transmission's urlBase (which replaces the whole RPC path to match Transmission's + // own --rpc-url-base setting), qBittorrent has no equivalent server-side base path setting, + // so urlBase is a plain prefix: the fixed "/api/v2/..." routes must still follow it. + Assert.Equal("/qbittorrent/api/v2/app/version", uri.AbsolutePath); + } + [Fact] public async Task AddAsync_WhenMagnetAndTorrentUrlAreProvided_UsesVerifiedMagnetHashWithoutDownloading() { @@ -495,6 +524,57 @@ public async Task GetQueueAsync_WithoutIds_ReturnsEmpty_OnQueueRequestFailure() Assert.Empty(items); } + + // A queue response whose middle torrent carries `downloaded` in the given JSON token form. + // The torrents either side of it are well formed, so anything missing from the result is + // attributable to that one field. + private static string QueueWithMalformedMiddleTorrent(string malformedDownloaded) => $$""" + [ + { + "hash": "aaaa1111", "name": "First", "progress": 0.5, "size": 1000, + "downloaded": 500, "state": "downloading", "save_path": "/downloads/a" + }, + { + "hash": "bbbb2222", "name": "Second", "progress": 0.5, "size": 1000, + "downloaded": {{malformedDownloaded}}, "state": "downloading", "save_path": "/downloads/b" + }, + { + "hash": "cccc3333", "name": "Third", "progress": 0.5, "size": 1000, + "downloaded": 700, "state": "downloading", "save_path": "/downloads/c" + } + ] + """; + + // qBittorrent documents `downloaded` as an integer, so the typed accessor reading it is + // right about the normal case. It was not resilient about the abnormal one: a value in + // another token form threw out of the mapper, out of the loop walking the response, and + // took every torrent after it along with it, while the poll still reported itself as a + // healthy live snapshot. + // + // "600.5" is a JSON number that is not an integer (FormatException from GetInt64) and + // "\"600\"" is a quoted one (InvalidOperationException). The quoted form is the shape + // already reported against the NZBGet adapter in #618 and #619. + [Theory] + [InlineData("600.5")] + [InlineData("\"600\"")] + [InlineData("6e2")] + public async Task GetQueueAsync_WhenOneTorrentIsUnreadable_DropsOnlyThatTorrent(string malformedDownloaded) + { + var apiMock = _provider.GetRequiredService(); + apiMock.InfoResponseOverride = QueueWithMalformedMiddleTorrent(malformedDownloaded); + var gateway = (DownloadClientGateway)_provider.GetRequiredService(); + var adapter = (QbittorrentAdapter)gateway.ResolveAdapter(_client); + + var items = await adapter.GetQueueAsync(_client); + + // The torrent AFTER the unreadable one is the whole point. Asserting only that the + // list is non-empty would pass on the truncating behaviour, because the first torrent + // is mapped before anything throws. + Assert.Contains(items, item => item.Id == "aaaa1111"); + Assert.Contains(items, item => item.Id == "cccc3333"); + Assert.DoesNotContain(items, item => item.Id == "bbbb2222"); + Assert.Equal(2, items.Count); + } [Fact] public async Task MarkItemAsImportedAsync_SetsConfiguredPostImportCategory() { diff --git a/tests/Features/Infrastructure/DownloadClients/Sabnzbd/SabnzbdAdapterTests.cs b/tests/Features/Infrastructure/DownloadClients/Sabnzbd/SabnzbdAdapterTests.cs index c63c417e3..756f702e4 100644 --- a/tests/Features/Infrastructure/DownloadClients/Sabnzbd/SabnzbdAdapterTests.cs +++ b/tests/Features/Infrastructure/DownloadClients/Sabnzbd/SabnzbdAdapterTests.cs @@ -43,6 +43,50 @@ public override async Task InitializeAsync() .Build()); } + /// + /// Regression test for https://github.com/Listenarrs/Listenarr/issues/808 + /// Release titles containing '"' (common in usenet subject lines) crashed + /// AddAsync with an ArgumentException from ContentDispositionHeaderValue + /// before the request ever reached SABnzbd. + /// + [Fact] + public async Task AddAsync_TitleContainsQuotesAndCommas_SendsNzbWithoutHeaderEncodingError() + { + // Given: a real-world release title (from DrunkenSlug/NZBgeek) with embedded + // quotes and a comma-decimal size, resolved the way the production pipeline does + var title = "DBS #0762 \"J.K. Rowling - Harry Potter 1-7 Audio Book (english)\" - " + + "\"Audio book - Harry Potter And The Deathly Hallows - J.K. Rowling.part01.rar\" (02_22) - 672,05 MB"; + var candidate = new TrustedDownloadCandidate( + "release-1", + title, + "J.K. Rowling", + "Harry Potter", + "DrunkenSlug (Prowlarr)", + "MP3", + "en", + 700_000_000, + null, + new DownloadSourceDescriptor( + IndexerId: null, + IndexerImplementation: "Newznab", + Protocol: DownloadProtocol.Usenet, + Locators: [new DownloadSourceLocator(DownloadSourceLocatorKind.NzbUrl, "https://indexer.example/nzb/1")])); + var downloader = new Mock(); + downloader + .Setup(d => d.DownloadAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync([1, 2, 3]); + var resolver = new GenericUsenetSourceResolver(downloader.Object); + + // When: the title is resolved into a prepared submission and sent to SABnzbd + var submission = await resolver.ResolveAsync(candidate, provisionalDownloadId: null, CancellationToken.None); + var adapter = MockUtils.CreateSabnzbdAdapter(_provider); + var result = await adapter.AddAsync(_client, submission); + + // Then: no ArgumentException, and SABnzbd actually received the addfile request + Assert.Equal("SABnzbd_nzo_addfile_test", result.ExternalId); + Assert.Single(sabnzbdApiMock.AddFileRequests); + } + [Fact] public async Task TestConnectionAsync_NormalizesHostWithSchemeAndPath() { @@ -422,5 +466,133 @@ public void HistoryStatus_IsMappedWithoutBehaviorDrift(string status, string exp Assert.Equal(expected, item!.Status); Assert.Equal("/downloads/book", item.ContentPath); } + + [Fact] + public void QueueSlot_InDifferentCategory_ButTracked_IsReturned() + { + using var document = System.Text.Json.JsonDocument.Parse( + """ + { + "nzo_id": "sab-tracked-1", + "filename": "Book Folder", + "status": "Downloading", + "percentage": "40", + "mb": "100", + "mbleft": "60", + "cat": "*" + } + """); + + var item = SabnzbdResponseMapper.MapQueueSlotToQueueItem( + _client, + document.RootElement, + "audiobooks", + speed: 0, + monitoredIds: new HashSet { "sab-tracked-1" }); + + Assert.NotNull(item); + Assert.Equal("sab-tracked-1", item!.Id); + } + + [Fact] + public void QueueSlot_InDifferentCategory_NotTracked_IsFiltered() + { + using var document = System.Text.Json.JsonDocument.Parse( + """ + { + "nzo_id": "sab-untracked-1", + "filename": "Book Folder", + "status": "Downloading", + "percentage": "40", + "mb": "100", + "mbleft": "60", + "cat": "*" + } + """); + + var item = SabnzbdResponseMapper.MapQueueSlotToQueueItem( + _client, + document.RootElement, + "audiobooks", + speed: 0, + monitoredIds: new HashSet()); + + Assert.Null(item); + } + + [Fact] + public void HistorySlot_InDifferentCategory_ButTracked_IsReturned() + { + using var document = System.Text.Json.JsonDocument.Parse( + """{"nzo_id":"sab-tracked-2","name":"Book","status":"Completed","category":"*","storage":"/downloads/book"}"""); + + var item = SabnzbdResponseMapper.MapHistorySlotToQueueItem( + _client, + document.RootElement, + "audiobooks", + new HashSet(), + monitoredIds: new HashSet { "sab-tracked-2" }); + + Assert.NotNull(item); + Assert.Equal("sab-tracked-2", item!.Id); + } + + [Fact] + public void HistorySlot_InDifferentCategory_NotTracked_IsFiltered() + { + using var document = System.Text.Json.JsonDocument.Parse( + """{"nzo_id":"sab-untracked-2","name":"Book","status":"Completed","category":"*","storage":"/downloads/book"}"""); + + var item = SabnzbdResponseMapper.MapHistorySlotToQueueItem( + _client, + document.RootElement, + "audiobooks", + new HashSet(), + monitoredIds: new HashSet()); + + Assert.Null(item); + } + + [Fact] + public async Task TestConnectionAsync_CategoryMissingFromSabnzbd_ReturnsAdvisoryPass() + { + sabnzbdApiMock.Categories = ["*", "Default"]; + var client = new DownloadClientConfigurationBuilder() + .WithHost("http://192.168.50.111/sab") + .WithPort(8080) + .WithoutSsl() + .WithApiKey("secret") + .WithType("sabnzbd") + .WithSettings("category", "audiobooks") + .Build(); + + var gateway = _provider.GetRequiredService(); + var (success, message) = await gateway.TestConnectionAsync(client); + + Assert.True(success); + Assert.Contains("does not exist", message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("audiobooks", message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task TestConnectionAsync_CategoryPresentInSabnzbd_ReturnsCleanPass() + { + sabnzbdApiMock.Categories = ["*", "Default", "audiobooks"]; + var client = new DownloadClientConfigurationBuilder() + .WithHost("http://192.168.50.111/sab") + .WithPort(8080) + .WithoutSsl() + .WithApiKey("secret") + .WithType("sabnzbd") + .WithSettings("category", "audiobooks") + .Build(); + + var gateway = _provider.GetRequiredService(); + var (success, message) = await gateway.TestConnectionAsync(client); + + Assert.True(success); + Assert.Contains("connected", message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("does not exist", message, StringComparison.OrdinalIgnoreCase); + } } } diff --git a/tests/Features/Infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapperTests.cs b/tests/Features/Infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapperTests.cs new file mode 100644 index 000000000..3c82faa72 --- /dev/null +++ b/tests/Features/Infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapperTests.cs @@ -0,0 +1,121 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Infrastructure.DownloadClients.Sabnzbd +{ + [Trait("Name", "SabnzbdResponseMapperTests")] + [Trait("Category", "SabnzbdResponseMapper")] + public sealed class SabnzbdResponseMapperTests : BaseTests + { + /// + /// Regression test for https://github.com/Listenarrs/Listenarr/issues/839 + /// SABnzbd reports an item as "Completed" in the active queue before it is + /// archived to history with a real storage path. Mapping that slot to a + /// completed QueueItem let downloads reach DownloadStatus.Completed with an + /// empty DownloadPath, permanently blocking import. + /// + [Fact] + public void MapQueueSlotToQueueItem_CompletedStatusWithoutStorage_ReturnsNull() + { + // Given: an active-queue slot reporting Completed with no storage field yet + var client = new DownloadClientConfiguration { DownloadPath = "/downloads" }; + using var document = System.Text.Json.JsonDocument.Parse( + """ + { + "nzo_id": "sab-race-1", + "filename": "Book", + "status": "Completed", + "percentage": "100", + "mb": "100", + "mbleft": "0" + } + """); + + // When + var item = SabnzbdResponseMapper.MapQueueSlotToQueueItem( + client, + document.RootElement, + configuredCategory: string.Empty, + speed: 0); + + // Then: excluded rather than reported as a pathless completion + Assert.Null(item); + } + + [Fact] + public void MapQueueSlotToQueueItem_CompletedStatusWithStorage_ReturnsCompletedItem() + { + // Given: an active-queue slot reporting Completed with a real storage path + var client = new DownloadClientConfiguration { DownloadPath = "/downloads" }; + using var document = System.Text.Json.JsonDocument.Parse( + """ + { + "nzo_id": "sab-race-2", + "filename": "Book", + "status": "Completed", + "percentage": "100", + "mb": "100", + "mbleft": "0", + "storage": "/downloads/complete/Book" + } + """); + + // When + var item = SabnzbdResponseMapper.MapQueueSlotToQueueItem( + client, + document.RootElement, + configuredCategory: string.Empty, + speed: 0); + + // Then: a real storage path still resolves to a completed item, unaffected + Assert.NotNull(item); + Assert.Equal("completed", item!.Status); + Assert.Equal("/downloads/complete/Book", item.ContentPath); + } + + [Fact] + public void MapQueueSlotToQueueItem_DownloadingStatusWithoutStorage_StillReturnsItem() + { + // Given: a genuinely still-downloading slot, which never carries storage + var client = new DownloadClientConfiguration { DownloadPath = "/downloads" }; + using var document = System.Text.Json.JsonDocument.Parse( + """ + { + "nzo_id": "sab-race-3", + "filename": "Book", + "status": "Downloading", + "percentage": "50", + "mb": "100", + "mbleft": "50" + } + """); + + // When + var item = SabnzbdResponseMapper.MapQueueSlotToQueueItem( + client, + document.RootElement, + configuredCategory: string.Empty, + speed: 0); + + // Then: the new guard only targets "completed" - in-progress items are unaffected + Assert.NotNull(item); + Assert.Equal("downloading", item!.Status); + } + } +} diff --git a/tests/Features/Infrastructure/Downloads/Cleanup/MovedDownloadCleanupProcessorTests.cs b/tests/Features/Infrastructure/Downloads/Cleanup/MovedDownloadCleanupProcessorTests.cs index 2a205f439..53589022a 100644 --- a/tests/Features/Infrastructure/Downloads/Cleanup/MovedDownloadCleanupProcessorTests.cs +++ b/tests/Features/Infrastructure/Downloads/Cleanup/MovedDownloadCleanupProcessorTests.cs @@ -256,6 +256,27 @@ await _provider.GetRequiredService() Assert.False(_gateway.LastRemoveDeleteFiles); } + [Fact] + public async Task RunCycleAsync_DowngradesDeleteFilesWhenCompatibilityImportRetainedSource() + { + var client = await CreateRemovableClientAsync("remove_and_delete"); + _gateway.RemoveResult = true; + var download = await AddMovedDownloadAsync(client, canBeRemoved: true); + await AddCompletedImportJobAsync( + download, + sourceRetained: true); + + await _provider.GetRequiredService() + .RunCycleAsync(CancellationToken.None); + + Assert.Null(await _downloadRepository.GetByIdAsync(download.Id)); + Assert.Equal(1, _gateway.GetCallCount(nameof(_gateway.RemoveAsync))); + Assert.False(_gateway.LastRemoveDeleteFiles); + var history = await GetCleanupHistoryAsync(download.Id); + Assert.Contains(history.Records, entry => + DetailValue(entry, "SourceRetained") == bool.TrueString); + } + [Fact] public async Task RunCycleAsync_BlocksRecentMovedWithoutImportProof() { @@ -317,7 +338,8 @@ private async Task AddMovedDownloadAsync( private async Task AddCompletedImportJobAsync( Download download, string? correlationId = null, - DateTime? completedAt = null) + DateTime? completedAt = null, + bool sourceRetained = false) { var job = new DownloadProcessingJobBuilder() .WithDownload(download) @@ -327,6 +349,7 @@ private async Task AddCompletedImportJobAsync( { job.JobData["CorrelationId"] = correlationId; } + job.JobData["SourceRetained"] = sourceRetained; await _downloadProcessingJobRepository.AddAsync(job); } diff --git a/tests/Features/Infrastructure/Ffmpeg/Metadata/FfprobeTagMetadataMapperTests.cs b/tests/Features/Infrastructure/Ffmpeg/Metadata/FfprobeTagMetadataMapperTests.cs new file mode 100644 index 000000000..425ca0a96 --- /dev/null +++ b/tests/Features/Infrastructure/Ffmpeg/Metadata/FfprobeTagMetadataMapperTests.cs @@ -0,0 +1,95 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Text.Json; +using Listenarr.Infrastructure.Ffmpeg.Metadata; + +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Infrastructure.Ffmpeg.Metadata +{ + [Trait("Name", "FfprobeTagMetadataMapperTests")] + [Trait("Category", "Infrastructure")] + public class FfprobeTagMetadataMapperTests : BaseTests + { + private static JsonElement TagsFrom(string json) + { + // JsonDocument is disposable, but the returned element is only read synchronously + // within each test, so cloning keeps it valid without keeping the document alive. + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + [Fact] + public void Apply_ReadsAsinAndIsbnFromTags() + { + var metadata = new AudioMetadata(); + var tags = TagsFrom("{\"title\":\"A Book\",\"ASIN\":\"B0078PA1OA\",\"ISBN\":\"9781250120207\"}"); + + FfprobeTagMetadataMapper.Apply(metadata, tags); + + Assert.Equal("B0078PA1OA", metadata.Asin); + Assert.Equal("9781250120207", metadata.Isbn); + Assert.Equal("A Book", metadata.Title); + } + + [Fact] + public void Apply_MatchesAsinTag_CaseInsensitively() + { + var metadata = new AudioMetadata(); + var tags = TagsFrom("{\"asin\":\"B0078PA1OA\"}"); + + FfprobeTagMetadataMapper.Apply(metadata, tags); + + Assert.Equal("B0078PA1OA", metadata.Asin); + } + + [Fact] + public void Apply_ReadsAudibleAsinTagVariant() + { + var metadata = new AudioMetadata(); + var tags = TagsFrom("{\"AUDIBLE_ASIN\":\"B0078PA1OA\"}"); + + FfprobeTagMetadataMapper.Apply(metadata, tags); + + Assert.Equal("B0078PA1OA", metadata.Asin); + } + + [Fact] + public void Apply_LeavesAsinNull_WhenNoAsinTagPresent() + { + var metadata = new AudioMetadata(); + var tags = TagsFrom("{\"title\":\"A Book\"}"); + + FfprobeTagMetadataMapper.Apply(metadata, tags); + + Assert.Null(metadata.Asin); + Assert.Null(metadata.Isbn); + } + + [Fact] + public void Apply_DoesNotOverwriteExistingAsin() + { + var metadata = new AudioMetadata { Asin = "EXISTING123" }; + var tags = TagsFrom("{\"ASIN\":\"B0078PA1OA\"}"); + + FfprobeTagMetadataMapper.Apply(metadata, tags); + + Assert.Equal("EXISTING123", metadata.Asin); + } + } +} diff --git a/tests/Features/Infrastructure/FileSystem/CompatibilityFilePublicationRecoveryServiceTests.cs b/tests/Features/Infrastructure/FileSystem/CompatibilityFilePublicationRecoveryServiceTests.cs new file mode 100644 index 000000000..a29167347 --- /dev/null +++ b/tests/Features/Infrastructure/FileSystem/CompatibilityFilePublicationRecoveryServiceTests.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Infrastructure.FileSystem; + +[Trait("Name", "CompatibilityFilePublicationRecoveryServiceTests")] +[Trait("Category", "Infrastructure")] +public sealed class CompatibilityFilePublicationRecoveryServiceTests : BaseTests +{ + [Fact] + public async Task ReconcileAsync_PlannedJournalWithTarget_PreservesBothAndMarksAttention() + { + var root = FileService.GetTempDirectory("compatibility-recovery-target"); + var source = Path.Join(root, "source.m4b"); + var destination = Path.Join(root, "destination.m4b"); + await File.WriteAllTextAsync(source, "audio"); + await File.WriteAllTextAsync(destination, "partial"); + var operationId = Guid.NewGuid(); + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using (var db = await factory.CreateDbContextAsync()) + { + db.CompatibilityFilePublicationJournals.Add( + new CompatibilityFilePublicationJournal + { + OperationId = operationId, + RequestedAction = FileAction.Move, + EffectiveAction = FileAction.Copy, + SourcePath = source, + DestinationPath = destination, + SourceLength = 5, + SourceSha256 = new string('A', 64), + State = CompatibilityFilePublicationState.Planned + }); + await db.SaveChangesAsync(); + } + var service = new CompatibilityFilePublicationRecoveryService( + factory, + TimeProvider.System, + NullLogger.Instance); + + await service.ReconcileAsync(); + + Assert.Equal("audio", await File.ReadAllTextAsync(source)); + Assert.Equal("partial", await File.ReadAllTextAsync(destination)); + await using var verification = await factory.CreateDbContextAsync(); + var journal = await verification.CompatibilityFilePublicationJournals + .SingleAsync(candidate => candidate.OperationId == operationId); + Assert.Equal( + CompatibilityFilePublicationState.NeedsAttention, + journal.State); + } +} diff --git a/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessRegistrationTests.cs b/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessRegistrationTests.cs index 8bbd42557..834e453d5 100644 --- a/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessRegistrationTests.cs +++ b/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessRegistrationTests.cs @@ -1,5 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; +using System.Security.Cryptography; +using System.Text; using Listenarr.Tests.Common; @@ -21,6 +23,24 @@ public async Task CheckPublicationSource_ExistingStableFile_ReturnsSupported() Assert.False(string.IsNullOrWhiteSpace(result.PhysicalObjectIdentity)); } + [Fact] + public async Task CheckPublicationSource_IdentityUnsupported_ReturnsContentOnlyProof() + { + var scenario = await CreateScenarioAsync( + "registration-source-content-only-capability"); + var capability = Assert.IsAssignableFrom( + CreateMover(forceContentOnlySourceProof: true)); + + var result = await capability.CheckAsync(scenario.Source); + + Assert.True(result.IsSupported, result.Reason); + Assert.True(result.SourceProof.HasValue); + var proof = result.SourceProof.Value; + Assert.False(proof.HasDurablePhysicalObjectIdentity); + Assert.Equal(FilePublicationSourceAuthority.ContentOnly, proof.Authority); + Assert.Equal(5, proof.Length); + } + [Fact] public async Task PrepareRegistration_ReadOnlyDestination_BlocksBeforeJournalCreation() { @@ -436,7 +456,7 @@ public async Task PrepareMove_EmptyOperationId_FailsClosedWithoutPublication() } [LinuxFact] - public async Task PrepareMove_ForcedCrossVolumeRejectsBeforePublicationOrJournalCreation() + public async Task PrepareMove_ForcedCrossVolumeCopiesRegistersThenRetiresExactSource() { var scenario = await CreateScenarioAsync("registration-cross-volume-blocked"); var mover = CreateMover(forceCrossVolume: true); @@ -447,15 +467,327 @@ public async Task PrepareMove_ForcedCrossVolumeRejectsBeforePublicationOrJournal scenario.Destination, scenario.OperationId); - Assert.Null(lease); + Assert.NotNull(lease); Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Source)); - Assert.False(File.Exists(scenario.Destination)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + Assert.True(lease.PrepareCleanupRecovery(73)); + Assert.Equal( + RegistrationPublicationCompletion.Completed, + lease.CompletePublication()); + Assert.True(await mover.CompletePreparedMoveAsync( + scenario.Source, + scenario.Destination, + lease, + scenario.OperationId)); + Assert.False(File.Exists(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Completed, + audiobookId: 73); + AssertNoLibraryArtifacts(scenario.Root); + } + + [LinuxFact] + public async Task PrepareCompanionMove_UsesCompanionRecoveryOwnerAcrossVolumes() + { + var scenario = await CreateScenarioAsync("registration-companion-cross-volume"); + var mover = CreateMover(forceCrossVolume: true); + var capability = Assert.IsAssignableFrom< + IFilePublicationSourceCapability>(mover); + var sourceCapability = await capability.CheckAsync(scenario.Source); + Assert.True(sourceCapability.IsSupported, sourceCapability.Reason); + + var preparation = await mover.PrepareActionForRegistrationDetailedAsync( + FilePublicationPlan.Durable(FileAction.Move), + scenario.Source, + scenario.Destination, + scenario.OperationId, + expectedRegisteredPhysicalObjectIdentity: null, + sourceCapability.SourceProof!.Value, + isCompanionFile: true, + companionAudiobookId: 73); + + using var lease = Assert.IsAssignableFrom< + IAudiobookFileRegistrationLease>(preparation.RegistrationLease); + Assert.True(lease.PrepareCleanupRecovery(73)); + Assert.Equal( + RegistrationPublicationCompletion.Completed, + lease.CompletePublication()); + + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using (var committedDb = await factory.CreateDbContextAsync()) + { + var committed = await committedDb.FileMutationJournals + .SingleAsync(candidate => + candidate.OperationId == scenario.OperationId); + Assert.Equal(73, committed.AudiobookId); + Assert.Equal( + FileMutationOwner.RegistrationCompanionFile, + committed.AudiobookFileId); + Assert.Equal( + FileMutationJournalState.RegistrationCommitted, + committed.State); + } + + Assert.True(await mover.CompletePreparedMoveAsync( + scenario.Source, + scenario.Destination, + lease, + scenario.OperationId)); + Assert.False(File.Exists(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Completed, + audiobookId: 73); + AssertNoLibraryArtifacts(scenario.Root); + } + + [CrossVolumeFact] + public async Task PrepareCompanionMove_RealCrossVolumeCopiesThenRetiresSource() + { + var sourceRoot = FileService.GetTempDirectory( + "registration-real-cross-volume-source"); + var source = Path.Join(sourceRoot, "cover.jpg"); + await File.WriteAllTextAsync(source, "cover"); + var providedDestinationRoot = Path.GetFullPath( + Environment.GetEnvironmentVariable( + CrossVolumeFactAttribute.DestinationPathEnvironmentVariable) + ?? throw new InvalidOperationException( + "A real cross-volume destination was not provided.")); + var destinationRoot = Path.Join( + providedDestinationRoot, + $"listenarr-{Guid.NewGuid():N}"); + Directory.CreateDirectory(destinationRoot); + var destination = Path.Join(destinationRoot, "cover.jpg"); + var operationId = Guid.NewGuid(); + + try + { + var mover = CreateMover(); + var capability = Assert.IsAssignableFrom< + IFilePublicationSourceCapability>(mover); + var sourceCapability = await capability.CheckAsync(source); + Assert.True(sourceCapability.IsSupported, sourceCapability.Reason); + + var preparation = await mover + .PrepareActionForRegistrationDetailedAsync( + FilePublicationPlan.Durable(FileAction.Move), + source, + destination, + operationId, + expectedRegisteredPhysicalObjectIdentity: null, + sourceCapability.SourceProof!.Value, + isCompanionFile: true, + companionAudiobookId: 75); + using var lease = Assert.IsAssignableFrom< + IAudiobookFileRegistrationLease>( + preparation.RegistrationLease); + Assert.NotEqual( + lease.SourcePhysicalObjectIdentity, + lease.PhysicalObjectIdentity); + Assert.True(lease.PrepareCleanupRecovery(75)); + Assert.Equal( + RegistrationPublicationCompletion.Completed, + lease.CompletePublication()); + Assert.True(await mover.CompletePreparedMoveAsync( + source, + destination, + lease, + operationId)); + + Assert.False(File.Exists(source)); + Assert.Equal("cover", await File.ReadAllTextAsync(destination)); + await AssertJournalStateAsync( + operationId, + FileMutationJournalState.Completed, + audiobookId: 75); + } + finally + { + if (Directory.Exists(destinationRoot)) + { + Directory.Delete(destinationRoot, recursive: true); + } + } + } + + [Fact] + public async Task PrepareCompatibilityMove_CopiesAndRetainsSourceWithoutDurableMoveJournal() + { + var scenario = await CreateScenarioAsync("registration-compatible-move"); + var mover = CreateMover(); + var hash = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes("audio"))); + var proof = new FilePublicationSourceProof( + $"content-only:{hash}", + 5, + hash, + FilePublicationSourceAuthority.ContentOnly); + + var preparation = await mover.PrepareActionForRegistrationDetailedAsync( + FilePublicationPlan.Additive(FileAction.Move), + scenario.Source, + scenario.Destination, + scenario.OperationId, + expectedRegisteredPhysicalObjectIdentity: null, + proof, + isCompanionFile: true, + companionAudiobookId: 74); + + Assert.True(preparation.IsSuccess, preparation.Message); + Assert.Equal(FileAction.Copy, preparation.EffectiveAction); + Assert.Equal( + FilePublicationSourceDisposition.Retained, + preparation.SourceDisposition); + using var lease = Assert.IsAssignableFrom< + IAudiobookFileRegistrationLease>(preparation.RegistrationLease); + Assert.False(lease.HasDurablePhysicalObjectIdentity); + Assert.True(lease.PrepareCleanupRecovery(74)); + Assert.Equal( + RegistrationPublicationCompletion.Completed, + lease.CompletePublication()); + + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); var factory = _provider.GetRequiredService< IDbContextFactory>(); await using var db = await factory.CreateDbContextAsync(); - Assert.False(await db.FileMutationJournals - .AsNoTracking() - .AnyAsync(candidate => candidate.OperationId == scenario.OperationId)); + Assert.Empty(await db.FileMutationJournals.ToListAsync()); + var journal = await db.CompatibilityFilePublicationJournals + .SingleAsync(candidate => + candidate.OperationId == scenario.OperationId); + Assert.Equal( + CompatibilityFilePublicationState.Completed, + journal.State); + Assert.Equal(FileAction.Move, journal.RequestedAction); + Assert.Equal(FileAction.Copy, journal.EffectiveAction); + Assert.True(journal.IsCompanionFile); + AssertNoLibraryArtifacts(scenario.Root); + } + + [NetworkStorageTheory] + [InlineData(false)] + [InlineData(true)] + public async Task PrepareCompatibilityMove_OnNetworkStorage_CopiesAndRetainsSource( + bool isCompanionFile) + { + var providedRoot = Path.GetFullPath( + Environment.GetEnvironmentVariable( + NetworkStorageTheoryAttribute.PathEnvironmentVariable) + ?? throw new InvalidOperationException( + "A network filesystem path was not provided.")); + var scenarioRoot = Path.Join( + providedRoot, + $"listenarr-{Guid.NewGuid():N}"); + var sourceDirectory = Path.Join(scenarioRoot, "source"); + var destinationDirectory = Path.Join(scenarioRoot, "destination"); + Directory.CreateDirectory(sourceDirectory); + Directory.CreateDirectory(destinationDirectory); + var source = Path.Join(sourceDirectory, "cover.jpg"); + var destination = Path.Join(destinationDirectory, "cover.jpg"); + await File.WriteAllTextAsync(source, "cover"); + var operationId = Guid.NewGuid(); + + try + { + var nativeCapability = Assert.IsAssignableFrom< + IFilePublicationSourceCapability>(CreateMover()); + var nativeResult = await nativeCapability.CheckAsync(source); + Assert.True(nativeResult.IsSupported, nativeResult.Reason); + + var mover = CreateMover(forceContentOnlySourceProof: true); + var weakCapability = Assert.IsAssignableFrom< + IFilePublicationSourceCapability>(mover); + var sourceCapability = await weakCapability.CheckAsync(source); + Assert.True(sourceCapability.IsSupported, sourceCapability.Reason); + var proof = Assert.NotNull(sourceCapability.SourceProof); + Assert.False(proof.HasDurablePhysicalObjectIdentity); + Assert.Equal(FilePublicationSourceAuthority.ContentOnly, proof.Authority); + var preparation = await mover + .PrepareActionForRegistrationDetailedAsync( + FilePublicationPlan.Additive(FileAction.Move), + source, + destination, + operationId, + expectedRegisteredPhysicalObjectIdentity: null, + proof, + isCompanionFile, + companionAudiobookId: isCompanionFile ? 76 : null); + + Assert.True(preparation.IsSuccess, preparation.Message); + Assert.Equal(FileAction.Copy, preparation.EffectiveAction); + Assert.Equal( + FilePublicationSourceDisposition.Retained, + preparation.SourceDisposition); + using var lease = Assert.IsAssignableFrom< + IAudiobookFileRegistrationLease>( + preparation.RegistrationLease); + Assert.False(lease.HasDurablePhysicalObjectIdentity); + Assert.True(lease.PrepareCleanupRecovery(76)); + Assert.Equal( + RegistrationPublicationCompletion.Completed, + lease.CompletePublication()); + + Assert.Equal("cover", await File.ReadAllTextAsync(source)); + Assert.Equal("cover", await File.ReadAllTextAsync(destination)); + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using var db = await factory.CreateDbContextAsync(); + var journal = await db.CompatibilityFilePublicationJournals + .SingleAsync(candidate => + candidate.OperationId == operationId); + Assert.Equal( + CompatibilityFilePublicationState.Completed, + journal.State); + Assert.Equal(isCompanionFile, journal.IsCompanionFile); + } + finally + { + if (Directory.Exists(scenarioRoot)) + { + Directory.Delete(scenarioRoot, recursive: true); + } + } + } + + [Fact] + public async Task PrepareCompatibilityCopy_PreexistingTargetIsPreservedAndNeedsAttention() + { + var scenario = await CreateScenarioAsync("registration-compatible-existing"); + await File.WriteAllTextAsync(scenario.Destination, "foreign"); + var mover = CreateMover(); + var hash = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes("audio"))); + var proof = new FilePublicationSourceProof( + $"content-only:{hash}", + 5, + hash, + FilePublicationSourceAuthority.ContentOnly); + + var preparation = await mover.PrepareActionForRegistrationDetailedAsync( + FilePublicationPlan.Additive(FileAction.Copy), + scenario.Source, + scenario.Destination, + scenario.OperationId, + expectedRegisteredPhysicalObjectIdentity: null, + proof); + + Assert.False(preparation.IsSuccess); + Assert.Null(preparation.RegistrationLease); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Source)); + Assert.Equal("foreign", await File.ReadAllTextAsync(scenario.Destination)); + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using var db = await factory.CreateDbContextAsync(); + var journal = await db.CompatibilityFilePublicationJournals + .SingleAsync(candidate => + candidate.OperationId == scenario.OperationId); + Assert.Equal( + CompatibilityFilePublicationState.NeedsAttention, + journal.State); AssertNoLibraryArtifacts(scenario.Root); } @@ -1392,7 +1724,8 @@ private FileMover CreateMover( Func? beforePinnedHardlinkCreation = null, Func? readOnlyFileSystemProbe = null, IRootFolderRepository? rootFolderRepository = null, - IRootFolderStorageHealthResolver? rootFolderStorageHealthResolver = null) + IRootFolderStorageHealthResolver? rootFolderStorageHealthResolver = null, + bool forceContentOnlySourceProof = false) { var factory = _provider.GetRequiredService< IDbContextFactory>(); @@ -1407,6 +1740,7 @@ private FileMover CreateMover( FileMoveLockDirectoryForTest = FileService.GetTempDirectory( "file-mover-markerless-registration-locks"), ForceCrossVolumeForTest = forceCrossVolume, + ForceContentOnlySourceProofForTest = forceContentOnlySourceProof, BeforePinnedHardlinkCreationForTestAsync = beforePinnedHardlinkCreation, BeforeMarkerlessRegistrationSourceDeleteForTestAsync = diff --git a/tests/Features/Infrastructure/FileSystem/FilePublicationCapabilityResolverTests.cs b/tests/Features/Infrastructure/FileSystem/FilePublicationCapabilityResolverTests.cs new file mode 100644 index 000000000..624f6ce09 --- /dev/null +++ b/tests/Features/Infrastructure/FileSystem/FilePublicationCapabilityResolverTests.cs @@ -0,0 +1,114 @@ +using Microsoft.Extensions.Options; +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Infrastructure.FileSystem; + +[Trait("Name", "FilePublicationCapabilityResolverTests")] +[Trait("Category", "Infrastructure")] +public sealed class FilePublicationCapabilityResolverTests : BaseTests +{ + [Fact] + public async Task ResolveAsync_WeakWritableDestination_DowngradesMoveToCopyAndRetain() + { + var root = BuildRoot(); + var repository = new Mock(MockBehavior.Strict); + repository.Setup(candidate => candidate.GetAllAsync()) + .ReturnsAsync([root]); + var health = new Mock(MockBehavior.Strict); + health.Setup(candidate => candidate.ResolveAsync( + root, + It.IsAny())) + .ReturnsAsync(WeakWritableObservation()); + var resolver = new FilePublicationCapabilityResolver( + repository.Object, + health.Object); + + var plan = await resolver.ResolveAsync( + FileAction.Move, + Path.Join(FileService.GetTempDirectory("publication-source"), "source.m4b"), + Path.Join(root.Path, "book", "target.m4b"), + DurableProof()); + + Assert.True(plan.IsAllowed); + Assert.Equal( + FilePublicationExecutionMode.AdditiveCopyRetainSource, + plan.Mode); + Assert.Equal(FileAction.Copy, plan.EffectiveAction); + Assert.Equal( + FilePublicationSourceDisposition.Retained, + plan.SourceDisposition); + repository.VerifyAll(); + health.VerifyAll(); + } + + [Fact] + public async Task ResolveAsync_WeakModeDisabled_BlocksWithoutGrantingMutation() + { + var root = BuildRoot(); + var repository = new Mock(MockBehavior.Strict); + repository.Setup(candidate => candidate.GetAllAsync()) + .ReturnsAsync([root]); + var health = new Mock(MockBehavior.Strict); + health.Setup(candidate => candidate.ResolveAsync( + root, + It.IsAny())) + .ReturnsAsync(WeakWritableObservation()); + var resolver = new FilePublicationCapabilityResolver( + repository.Object, + health.Object, + Options.Create(new FileMoverOptions + { + WeakPublicationMode = WeakPublicationMode.Disabled + })); + + var plan = await resolver.ResolveAsync( + FileAction.Move, + Path.Join(FileService.GetTempDirectory("publication-disabled-source"), "source.m4b"), + Path.Join(root.Path, "book", "target.m4b"), + DurableProof()); + + Assert.False(plan.IsAllowed); + Assert.Equal(FilePublicationExecutionMode.Blocked, plan.Mode); + Assert.Equal( + "compatibility_publication_disabled", + plan.ReasonCode); + repository.VerifyAll(); + health.VerifyAll(); + } + + private RootFolder BuildRoot() + { + var path = FileService.GetTempDirectory("publication-capability-root"); + return new RootFolder + { + Id = 91, + Name = "Weak storage", + Path = path, + PathIdentityState = PathIdentityState.Valid, + ResolvedCaseSensitivity = + FileSystemPathSemantics.CurrentHostDefault.CaseSensitivity, + CaseSensitivityMode = + FileSystemPathSemantics.CurrentHostDefault.CaseSensitivity + == FileSystemCaseSensitivity.Sensitive + ? FileSystemCaseSensitivityMode.Sensitive + : FileSystemCaseSensitivityMode.Insensitive + }; + } + + private static RootFolderStorageObservation WeakWritableObservation() => + new( + RootFolderStorageState.Limited, + RootFolderStorageReason.IdentityUnsupported, + "Durable identity is unavailable.", + CanConfirmCurrentFolder: false, + CanChangePath: true, + CanMutateFilesystem: false, + ConfirmationToken: null, + CanPublishNewFiles: true); + + private static FilePublicationSourceProof DurableProof() => + new( + "durable:test", + 5, + new string('A', 64)); +} diff --git a/tests/Features/Infrastructure/FileSystem/PinnedAudiobookFileRegistrationLeaseTests.cs b/tests/Features/Infrastructure/FileSystem/PinnedAudiobookFileRegistrationLeaseTests.cs index 43617c61f..712dd0477 100644 --- a/tests/Features/Infrastructure/FileSystem/PinnedAudiobookFileRegistrationLeaseTests.cs +++ b/tests/Features/Infrastructure/FileSystem/PinnedAudiobookFileRegistrationLeaseTests.cs @@ -93,6 +93,32 @@ public async Task OpenMetadataWriteStream_PublicPathReplaced_DoesNotOpenReplacem await File.ReadAllTextAsync(displacedPath)); } + [Fact] + public async Task OpenMetadataWriteStream_IsReadableSoATagLibraryCanParseWhatItRewrites() + { + var parent = FileService.GetTempDirectory( + "registration-lease-metadata-write-access"); + var publicPath = await FileService.GetFileAsync( + parent, + "book.m4b", + "original generation"); + using var lease = PinnedAudiobookFileRegistrationLease.Open(publicPath); + + using var stream = lease.OpenMetadataWriteStream(); + + // TagLib reads the existing box structure back through the write stream before it + // saves, so a write-only handle throws NotSupportedException mid-parse and the tag is + // never written. The import still reports success, which is why this needs asserting + // rather than observing. + Assert.True(stream.CanWrite); + Assert.True(stream.CanRead); + Assert.True(stream.CanSeek); + + var buffer = new byte[stream.Length]; + Assert.Equal(buffer.Length, stream.Read(buffer, 0, buffer.Length)); + Assert.Equal("original generation", System.Text.Encoding.UTF8.GetString(buffer)); + } + [WindowsFact] public async Task StableRegistrationLease_BlocksPublicPathReplacementUntilDisposed() { diff --git a/tests/Features/Infrastructure/FileSystem/RootFolderStorageHealthResolverTests.cs b/tests/Features/Infrastructure/FileSystem/RootFolderStorageHealthResolverTests.cs index 3726e525f..63c90bfcc 100644 --- a/tests/Features/Infrastructure/FileSystem/RootFolderStorageHealthResolverTests.cs +++ b/tests/Features/Infrastructure/FileSystem/RootFolderStorageHealthResolverTests.cs @@ -206,7 +206,9 @@ public async Task ResolveAsync_AuthorizedGenerationMissing_ReturnsMissingWithout .ReturnsAsync(DirectoryObjectIdentityResolution.Unavailable( "Directory not found.", DirectoryObjectIdentityFailureKind.Missing)); - var resolver = new RootFolderStorageHealthResolver(identityResolver.Object); + var resolver = new RootFolderStorageHealthResolver( + identityResolver.Object, + readOnlyFileSystemProbe: _ => false); var result = await resolver.ResolveAsync(root); @@ -239,7 +241,9 @@ public async Task ResolveAsync_AuthorizedGenerationReplaced_ReturnsChangedBoundT ManagedDirectoryIdentity.CurrentVersion, "replacement", null)); - var resolver = new RootFolderStorageHealthResolver(identityResolver.Object); + var resolver = new RootFolderStorageHealthResolver( + identityResolver.Object, + readOnlyFileSystemProbe: _ => false); var result = await resolver.ResolveAsync(root); @@ -274,7 +278,9 @@ public async Task ResolveAsync_LegacyWeakIdentity_AllowsScanAndExplicitIdentityU ManagedDirectoryIdentity.CurrentVersion, "strong-current", null)); - var resolver = new RootFolderStorageHealthResolver(identityResolver.Object); + var resolver = new RootFolderStorageHealthResolver( + identityResolver.Object, + readOnlyFileSystemProbe: _ => false); var result = await resolver.ResolveAsync(root); @@ -346,7 +352,9 @@ public async Task ResolveAsync_NoAuthorizedGeneration_ReturnsUnconfirmed() ManagedDirectoryIdentity.CurrentVersion, "observed", null)); - var resolver = new RootFolderStorageHealthResolver(identityResolver.Object); + var resolver = new RootFolderStorageHealthResolver( + identityResolver.Object, + readOnlyFileSystemProbe: _ => false); var result = await resolver.ResolveAsync(root); @@ -481,7 +489,9 @@ public async Task ResolveAsync_IdentityUnsupported_PreservesTechnicalFailureDeta .ReturnsAsync(DirectoryObjectIdentityResolution.Unavailable( detail, DirectoryObjectIdentityFailureKind.IdentityUnsupported)); - var resolver = new RootFolderStorageHealthResolver(identityResolver.Object); + var resolver = new RootFolderStorageHealthResolver( + identityResolver.Object, + readOnlyFileSystemProbe: _ => false); var result = await resolver.ResolveAsync(root); @@ -494,6 +504,7 @@ public async Task ResolveAsync_IdentityUnsupported_PreservesTechnicalFailureDeta StringComparison.OrdinalIgnoreCase); Assert.True(result.CanReadFilesystem); Assert.True(result.CanScanFilesystem); + Assert.True(result.CanPublishNewFiles); Assert.False(result.CanMutateFilesystem); identityResolver.VerifyAll(); } @@ -521,7 +532,9 @@ public async Task ResolveAsync_UnsupportedPersistedIdentityWithCurrentStrongIden ManagedDirectoryIdentity.CurrentVersion, "current-strong-identity", null)); - var resolver = new RootFolderStorageHealthResolver(identityResolver.Object); + var resolver = new RootFolderStorageHealthResolver( + identityResolver.Object, + readOnlyFileSystemProbe: _ => false); var result = await resolver.ResolveAsync(root); diff --git a/tests/Features/Infrastructure/FileSystem/UnixOpenFlagsTests.cs b/tests/Features/Infrastructure/FileSystem/UnixOpenFlagsTests.cs index 28334d037..dec74cfae 100644 --- a/tests/Features/Infrastructure/FileSystem/UnixOpenFlagsTests.cs +++ b/tests/Features/Infrastructure/FileSystem/UnixOpenFlagsTests.cs @@ -48,6 +48,21 @@ public void GetLinuxDirectorySafetyFlags_RejectsArchitecturesListenarrDoesNotRel UnixOpenFlags.GetLinuxDirectorySafetyFlags(architecture)); } + [Fact] + public void OpenReadWriteNoFollow_AsksForReadAccessWhereOpenWriteDoesNot() + { + // Given / When + var writeOnly = UnixOpenFlags.OpenWriteNoFollow(); + var readWrite = UnixOpenFlags.OpenReadWriteNoFollow(); + + // Then: the access mode is the low two bits, and only that differs. Asserting the + // difference rather than a literal keeps this honest on both released architectures, + // since the noFollow bit is not the same value on arm64 and x64. + Assert.Equal(1, writeOnly & 0x3); + Assert.Equal(2, readWrite & 0x3); + Assert.Equal(writeOnly & ~0x3, readWrite & ~0x3); + } + [Fact] public void EnsureMacOSArchitectureSupported_AcceptsReleasedX64Target() { diff --git a/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceMetadataBoundaryTests.cs b/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceMetadataBoundaryTests.cs index 747565f30..25635cc47 100644 --- a/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceMetadataBoundaryTests.cs +++ b/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceMetadataBoundaryTests.cs @@ -24,7 +24,7 @@ public async Task ScanAsync_CaseDistinctMetadataFolders_RemainConflicting() var lowerFile = Path.Join(lowerDirectory, "part-b.m4b"); var metadata = new Mock(MockBehavior.Strict); metadata.Setup(service => service.ExtractFileMetadataAsync( - It.IsAny())) + It.IsAny())) .ReturnsAsync(MatchingMetadata()); Init(services => services.WithSingleton(metadata.Object)); Directory.CreateDirectory(upperDirectory); @@ -65,7 +65,7 @@ await _applicationSettingsRepository.SaveAsync( Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "MetadataAttributionConflict"); metadata.Verify( - service => service.ExtractFileMetadataAsync(It.IsAny()), + service => service.ExtractFileMetadataAsync(It.IsAny()), Times.Exactly(2)); } diff --git a/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceTests.cs b/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceTests.cs index cf25bc59a..44e8c3d1c 100644 --- a/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceTests.cs @@ -141,11 +141,14 @@ public async Task ScanAsync_MetadataReplacementAndRestore_ReadsPinnedFileGenerat "unrelated.m4b", "Original Book"); var displaced = Path.Join(root, "original-generation.bin"); + var observedSources = new List(); var metadata = new Mock(MockBehavior.Strict); metadata.Setup(service => service.ExtractFileMetadataAsync( - It.IsAny())) - .Returns(async extractionPath => + It.IsAny())) + .Returns(async fileSource => { + observedSources.Add(fileSource); + var extractionPath = fileSource.ReadPath; var displacedOriginal = false; try { @@ -190,8 +193,17 @@ public async Task ScanAsync_MetadataReplacementAndRestore_ReadsPinnedFileGenerat await _audiobookFileRepository.GetByAudiobookIdAsync(audiobook.Id)); Assert.Equal("Original Book", await File.ReadAllTextAsync(candidate)); metadata.Verify( - service => service.ExtractFileMetadataAsync(It.IsAny()), + service => service.ExtractFileMetadataAsync(It.IsAny()), Times.Once); + + // The two halves of the source do different jobs and both matter here. ReadPath is the + // pinned generation, which is what the assertions above prove was read even while the + // visible file was swapped. PublicPath is the file as a person sees it, and it has to + // keep its real name: on Linux ReadPath is a /proc descriptor link with no extension, + // so anything deriving identity from it loses the extension entirely. + var observed = Assert.Single(observedSources); + Assert.Equal(candidate, observed.PublicPath); + Assert.Equal(".m4b", Path.GetExtension(observed.PublicPath)); } [Fact] diff --git a/tests/Features/Infrastructure/Library/Scanning/ScanFileDiscoveryTests.cs b/tests/Features/Infrastructure/Library/Scanning/ScanFileDiscoveryTests.cs index 83b43ae27..313d94d24 100644 --- a/tests/Features/Infrastructure/Library/Scanning/ScanFileDiscoveryTests.cs +++ b/tests/Features/Infrastructure/Library/Scanning/ScanFileDiscoveryTests.cs @@ -314,6 +314,61 @@ public void Discover_LinkedDirectoryInsideIdentifierBoundary_IsNotTraversed() && issue.Path == link); } + [Fact] + public void FindMatchingAudioFiles_FolderDropsLeadingThe_StillMatches() + { + var requested = CreateAudioFile( + "Karla McLaren", + "Language of Emotions", + "Language of Emotions.m4b"); + var audiobook = new AudiobookBuilder() + .WithTitle("The Language of Emotions") + .WithAuthor("Karla McLaren") + .Build(); + + var result = Discover(audiobook); + + var found = Assert.Single(result); + Assert.Equal(requested, found); + } + + [Fact] + public void FindMatchingAudioFiles_FolderAddsLeadingArticle_StillMatches() + { + var requested = CreateAudioFile( + "Gabor Mate", + "The Myth of Normal", + "The Myth of Normal.m4b"); + var audiobook = new AudiobookBuilder() + .WithTitle("Myth of Normal") + .WithAuthor("Gabor Mate") + .Build(); + + var result = Discover(audiobook); + + var found = Assert.Single(result); + Assert.Equal(requested, found); + } + + [Fact] + public void FindMatchingAudioFiles_ArticleToleranceDoesNotCrossLinkSiblingBooks() + { + // Same author, two books that both start with "The". Article-insensitivity + // must still compare the full remaining title, so only the requested book + // is attributed -- it must not collapse "The Reckoning" onto "The Awakening". + var requested = CreateAudioFile("Shared Author", "The Reckoning", "The Reckoning.m4b"); + _ = CreateAudioFile("Shared Author", "The Awakening", "The Awakening.m4b"); + var audiobook = new AudiobookBuilder() + .WithTitle("The Reckoning") + .WithAuthor("Shared Author") + .Build(); + + var result = Discover(audiobook); + + var found = Assert.Single(result); + Assert.Equal(requested, found); + } + private List Discover(Audiobook audiobook) => DiscoverResult(audiobook).AttributedFiles.ToList(); diff --git a/tests/Features/Infrastructure/Persistence/FileRegistrationRecoveryProtocolTests.cs b/tests/Features/Infrastructure/Persistence/FileRegistrationRecoveryProtocolTests.cs new file mode 100644 index 000000000..2d8a794cf --- /dev/null +++ b/tests/Features/Infrastructure/Persistence/FileRegistrationRecoveryProtocolTests.cs @@ -0,0 +1,76 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Tests.Common; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Listenarr.Tests.Features.Infrastructure.Persistence; + +[Trait("Name", "FileRegistrationRecoveryProtocolTests")] +[Trait("Category", "Infrastructure")] +public sealed class FileRegistrationRecoveryProtocolTests : BaseTests +{ + // A journal left on an older protocol version disables filesystem mutations for the whole + // application, and there is no in-app route to clear it, so this exception message is the + // entire brief an operator gets. Naming only the first of several turns one repair into one + // restart per journal, with no way to know how many remain. + [Fact] + public async Task ReconcileAsync_LegacyJournals_NamesEveryOneAndHowMany() + { + Init(); + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + + var operationIds = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() }; + await using (var seed = await factory.CreateDbContextAsync()) + { + var created = DateTime.UtcNow.AddMinutes(-30); + foreach (var (operationId, index) in operationIds.Select((id, i) => (id, i))) + { + seed.FileMutationJournals.Add(new FileMutationJournal + { + OperationId = operationId, + Action = FileAction.HardlinkCopy, + State = FileMutationJournalState.Planned, + ProtocolVersion = FileMutationProtocol.Current - 1, + SourcePath = $"/incoming/book-{index}.m4b", + DestinationPath = $"/library/book-{index}.m4b", + CreatedAt = created.AddSeconds(index), + UpdatedAt = created.AddSeconds(index) + }); + } + await seed.SaveChangesAsync(); + } + + var service = new FileRegistrationRecoveryService( + factory, + Mock.Of(), + TimeProvider.System, + NullLogger.Instance); + + var thrown = await Assert.ThrowsAsync( + () => service.ReconcileAsync()); + + // The count first, so an operator knows the size of the job before reading identifiers. + Assert.Contains("3 file-mutation journal(s)", thrown.Message, StringComparison.Ordinal); + foreach (var operationId in operationIds) + { + Assert.Contains(operationId.ToString(), thrown.Message, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/tests/Features/Infrastructure/Persistence/FileRenameRecoveryReconcilerTests.cs b/tests/Features/Infrastructure/Persistence/FileRenameRecoveryReconcilerTests.cs index c2dc6a2bc..c0c205f1e 100644 --- a/tests/Features/Infrastructure/Persistence/FileRenameRecoveryReconcilerTests.cs +++ b/tests/Features/Infrastructure/Persistence/FileRenameRecoveryReconcilerTests.cs @@ -10,6 +10,66 @@ namespace Listenarr.Tests.Features.Infrastructure.Persistence; [Trait("Category", "Infrastructure")] public sealed class FileRenameRecoveryReconcilerTests : BaseTests { + [Fact] + public async Task ReconcileAsync_CommittedCompanionPublication_RetiresExactSource() + { + var root = FileService.GetTempDirectory("companion-registration-recovery"); + await AddAuthorizedRootAsync(root); + var sourceDirectory = Path.Join(root, "source"); + var destinationDirectory = Path.Join(root, "library"); + Directory.CreateDirectory(sourceDirectory); + Directory.CreateDirectory(destinationDirectory); + var source = Path.Join(sourceDirectory, "cover.jpg"); + var destination = Path.Join(destinationDirectory, "cover.jpg"); + await File.WriteAllTextAsync(source, "cover"); + var audiobook = await _audiobookRepository.AddAsync( + new AudiobookBuilder() + .WithTitle("Companion Recovery") + .WithBasePath(destinationDirectory) + .Build()); + var operationId = Guid.NewGuid(); + var mover = _provider.GetRequiredService(); + var capability = await mover.CheckAsync(source); + Assert.True(capability.IsSupported, capability.Reason); + + var preparation = await mover.PrepareActionForRegistrationDetailedAsync( + FilePublicationPlan.Durable(FileAction.Move), + source, + destination, + operationId, + expectedRegisteredPhysicalObjectIdentity: null, + capability.SourceProof!.Value, + isCompanionFile: true, + companionAudiobookId: audiobook.Id); + using (var lease = Assert.IsAssignableFrom< + IAudiobookFileRegistrationLease>(preparation.RegistrationLease)) + { + Assert.True(lease.PrepareCleanupRecovery(audiobook.Id)); + Assert.Equal( + RegistrationPublicationCompletion.Completed, + lease.CompletePublication()); + } + + Assert.True(File.Exists(source)); + Assert.Equal("cover", await File.ReadAllTextAsync(destination)); + + await _provider.GetRequiredService() + .ReconcileAsync(); + + Assert.False(File.Exists(source)); + Assert.Equal("cover", await File.ReadAllTextAsync(destination)); + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using var db = await factory.CreateDbContextAsync(); + var journal = await db.FileMutationJournals.SingleAsync(candidate => + candidate.OperationId == operationId); + Assert.Equal(FileMutationJournalState.Completed, journal.State); + Assert.Equal(audiobook.Id, journal.AudiobookId); + Assert.Equal( + FileMutationOwner.RegistrationCompanionFile, + journal.AudiobookFileId); + } + [Fact] public async Task ReconcileAsync_CompletedFilesystemRenameBeforeMetadataCommit_RepairsTrackedPath() { diff --git a/tests/Features/Infrastructure/Persistence/LibraryFilesystemStartupReconciliationServiceTests.cs b/tests/Features/Infrastructure/Persistence/LibraryFilesystemStartupReconciliationServiceTests.cs index b5685a51e..98c8d672d 100644 --- a/tests/Features/Infrastructure/Persistence/LibraryFilesystemStartupReconciliationServiceTests.cs +++ b/tests/Features/Infrastructure/Persistence/LibraryFilesystemStartupReconciliationServiceTests.cs @@ -65,6 +65,8 @@ public async Task StartAsync_ReturnsWhileReconciliationIsBlocked_ThenCompletesIn order.Add("rename"); return Task.CompletedTask; }); + var compatibility = new StubCompatibilityRecoveryService( + () => order.Add("compatibility")); var files = new Mock(MockBehavior.Strict); files.Setup(service => service.ReconcileAsync(It.IsAny())) .Returns((CancellationToken _) => @@ -80,7 +82,8 @@ public async Task StartAsync_ReturnsWhileReconciliationIsBlocked_ThenCompletesIn files.Object, deletion.Object, registration.Object, - rename.Object); + rename.Object, + compatibility); var readiness = new LibraryFilesystemReadiness(); var service = new LibraryFilesystemStartupReconciliationService( provider.GetRequiredService(), @@ -107,6 +110,7 @@ public async Task StartAsync_ReturnsWhileReconciliationIsBlocked_ThenCompletesIn "ownership", "deletion", "registration-recover", + "compatibility", "rename", "files" ], @@ -214,7 +218,8 @@ private static ServiceProvider BuildProvider( IAudiobookFileIdentityReconciler files, IAudiobookDeletionIntentReconciler? deletion = null, IFileRegistrationRecoveryService? registration = null, - IFileRenameRecoveryReconciler? rename = null) => + IFileRenameRecoveryReconciler? rename = null, + ICompatibilityFilePublicationRecoveryService? compatibility = null) => new ServiceCollection() .AddScoped(_ => root) .AddScoped(_ => relocation) @@ -226,6 +231,8 @@ private static ServiceProvider BuildProvider( && service.ReconcileAsync(It.IsAny()) == Task.CompletedTask)) .AddScoped(_ => rename ?? Mock.Of(service => service.ReconcileAsync(It.IsAny()) == Task.CompletedTask)) + .AddScoped(_ => compatibility + ?? new StubCompatibilityRecoveryService()) .AddScoped(_ => files) .BuildServiceProvider(new ServiceProviderOptions { @@ -233,4 +240,14 @@ private static ServiceProvider BuildProvider( ValidateOnBuild = true }); + private sealed class StubCompatibilityRecoveryService(Action? onRun = null) + : ICompatibilityFilePublicationRecoveryService + { + public Task ReconcileAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + onRun?.Invoke(); + return Task.CompletedTask; + } + } } diff --git a/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs b/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs index 0d11d21a5..6e56af2ec 100644 --- a/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs +++ b/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs @@ -38,6 +38,8 @@ public class SqliteMigrationSchemaTests : BaseTests "20260810160640_AddMoveJobRelocationForeignKey"; private const string FileMutationParentGenerationProofsMigrationId = "20260818132300_AddFileMutationParentGenerationProofs"; + private const string CompatibilityFilePublicationMigrationId = + "20260821141235_AddCompatibilityFilePublication"; private static (SqliteConnection Connection, ListenArrDbContext Context) CreateMigratedSqliteContext() @@ -161,7 +163,8 @@ public async Task MigrationHistory_ContainsOnlyRetainedRepairsAndConsolidatedPrM ProcessExecutionLogRepairId, ConsolidatedMigrationId, MoveJobRelocationForeignKeyMigrationId, - FileMutationParentGenerationProofsMigrationId + FileMutationParentGenerationProofsMigrationId, + CompatibilityFilePublicationMigrationId ], postCanary); Assert.Contains("20251124102000_AddMoveJobSourcePath", applied); diff --git a/tests/Mocks/Api/SabnzbdApiMock.cs b/tests/Mocks/Api/SabnzbdApiMock.cs index dbd0e7b8b..cba0b9ccb 100644 --- a/tests/Mocks/Api/SabnzbdApiMock.cs +++ b/tests/Mocks/Api/SabnzbdApiMock.cs @@ -11,13 +11,16 @@ public class SabnzbdApiMock : BaseApiMock public static readonly string REMOTE_PATH = FileUtils.GetAbsolutePath("downloads", "completed"); public string contentPath = FileUtils.GetAbsolutePath("completed", "Book.m4b"); + public List Categories { get; set; } = ["*", "Default"]; public System.Net.HttpStatusCode HistoryStatusCode { get; set; } = System.Net.HttpStatusCode.OK; public string? HistoryResponseOverride { get; set; } public List RemovalRequests { get; } = []; + public List AddFileRequests { get; } = []; public SabnzbdApiMock() { AddRoute("api", ProcessRequest, HttpMethod.Get); + AddRoute("api", ProcessRequest, HttpMethod.Post); } public async Task GetHistory(HttpRequestMessage request, CancellationToken ct) @@ -92,6 +95,12 @@ public async Task GetQueue(HttpRequestMessage request, Canc return MockUtils.GetCannedResponse(response); } + public async Task GetCats(HttpRequestMessage request, CancellationToken ct) + { + var quoted = Categories.Select(category => $"\"{category}\""); + return MockUtils.GetCannedResponse($$"""{"categories": [{{string.Join(", ", quoted)}}]}"""); + } + public async Task GetVersion(HttpRequestMessage request, CancellationToken ct) { return MockUtils.GetCannedResponse(""" @@ -101,6 +110,17 @@ public async Task GetVersion(HttpRequestMessage request, Ca """); } + public async Task AddFile(HttpRequestMessage request, CancellationToken ct) + { + AddFileRequests.Add(request.RequestUri!); + return MockUtils.GetCannedResponse(""" + { + "status": true, + "nzo_ids": ["SABnzbd_nzo_addfile_test"] + } + """); + } + public async Task ProcessRequest(HttpRequestMessage request, CancellationToken ct) { var query = HttpUtility.ParseQueryString(request.RequestUri.Query); @@ -110,6 +130,14 @@ public async Task ProcessRequest(HttpRequestMessage request { return await GetVersion(request, ct); } + else if (string.Equals("get_cats", mode)) + { + return await GetCats(request, ct); + } + else if (string.Equals("addfile", mode)) + { + return await AddFile(request, ct); + } else if (string.Equals("history", mode)) { if (string.Equals("delete", query["name"], StringComparison.Ordinal))