diff --git a/fe/src/__tests__/AudiobooksView.spec.ts b/fe/src/__tests__/AudiobooksView.spec.ts index 718836de9..58c37a8d9 100644 --- a/fe/src/__tests__/AudiobooksView.spec.ts +++ b/fe/src/__tests__/AudiobooksView.spec.ts @@ -796,3 +796,82 @@ describe('AudiobooksView Grouping', () => { expect(wrapper.find('.series-bottom-placard').exists()).toBe(true) }) }) + +describe('AudiobooksView Date Added sorting', () => { + beforeEach(() => { + const pinia = createPinia() + setActivePinia(pinia) + }) + + it('sorts by date-added (file import date) with fileless books last', async () => { + if ( + typeof (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver === 'undefined' + ) { + ;(globalThis as unknown as Record).ResizeObserver = class { + observe() {} + disconnect() {} + } + } + if (typeof (globalThis as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') { + ;(globalThis as unknown as Record).WebSocket = function () { + /* noop */ + } + } + + const pinia = createPinia() + setActivePinia(pinia) + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'home', component: { template: '
' } }, + { path: '/audiobooks', name: 'audiobooks', component: AudiobooksView }, + ], + }) + await router.push('/audiobooks') + await router.isReady().catch(() => {}) + + const store = useLibraryStore() + store.audiobooks = [ + { id: 1, title: 'Oldest', authors: ['A'], dateDownloaded: '2023-01-01T00:00:00Z', files: [] }, + { id: 2, title: 'Newest', authors: ['A'], dateDownloaded: '2025-06-01T00:00:00Z', files: [] }, + { id: 3, title: 'Middle', authors: ['A'], dateDownloaded: '2024-03-15T00:00:00Z', files: [] }, + { id: 4, title: 'No File', authors: ['A'], files: [] }, // no dateDownloaded + ] as unknown as import('@/types').Audiobook[] + store.fetchLibrary = vi.fn(async () => undefined) + + const wrapper = mount(AudiobooksView, { + global: { + plugins: [pinia, router], + stubs: ['BulkEditModal', 'EditAudiobookModal', 'CustomFilterModal', 'FiltersDropdown', 'CustomSelect'], + }, + }) + await new Promise((r) => setTimeout(r, 0)) + + const vm = wrapper.vm as unknown as { + sortKey: string + sortOrder: string + groupBy: string + setGroupBy?: (value: string) => Promise | void + audiobooks: Array<{ title?: string }> + sortOptions: Array<{ value: string; label: string }> + } + + // Ensure we're in the flat books view (grouping may persist from other tests) + await vm.setGroupBy?.('books') + await wrapper.vm.$nextTick() + + // The 'Date Added' option is offered when grouping by books + expect(vm.sortOptions.map((o) => o.value)).toContain('date-added') + + // Descending: newest first, book with no file last + vm.sortKey = 'date-added' + vm.sortOrder = 'desc' + await wrapper.vm.$nextTick() + expect(vm.audiobooks.map((a) => a.title)).toEqual(['Newest', 'Middle', 'Oldest', 'No File']) + + // Ascending: oldest first, book with no file still last + vm.sortOrder = 'asc' + await wrapper.vm.$nextTick() + expect(vm.audiobooks.map((a) => a.title)).toEqual(['Oldest', 'Middle', 'Newest', 'No File']) + }) +}) diff --git a/fe/src/types/index.ts b/fe/src/types/index.ts index 596251121..7ae8dc1c6 100644 --- a/fe/src/types/index.ts +++ b/fe/src/types/index.ts @@ -666,6 +666,9 @@ export interface Audiobook { wanted?: boolean // Server-computed list status used by slim /library responses. status?: AudiobookStatus + // When the earliest file for this audiobook was imported (UTC ISO string); undefined for + // audiobooks with no tracked files yet. Used for "Date Added" sorting. + dateDownloaded?: string } export interface History { diff --git a/fe/src/views/library/AudiobooksView.vue b/fe/src/views/library/AudiobooksView.vue index 610575b98..30f4cec19 100644 --- a/fe/src/views/library/AudiobooksView.vue +++ b/fe/src/views/library/AudiobooksView.vue @@ -1162,6 +1162,18 @@ const filteredAndSortedAudiobooks = computed(() => { av = (a.publishYear || '').toString().toLowerCase() bv = (b.publishYear || '').toString().toLowerCase() break + case 'date-added': { + // Sort by when the earliest file was imported. Audiobooks with no file yet + // (no dateDownloaded) always sort last, regardless of ascending/descending. + const at = a.dateDownloaded ? Date.parse(a.dateDownloaded) : NaN + const bt = b.dateDownloaded ? Date.parse(b.dateDownloaded) : NaN + const aMissing = Number.isNaN(at) + const bMissing = Number.isNaN(bt) + if (aMissing && bMissing) return 0 + if (aMissing) return 1 + if (bMissing) return -1 + return (at - bt) * (sortOrder.value === 'asc' ? 1 : -1) + } case 'monitored': av = !!a.monitored bv = !!b.monitored @@ -1552,6 +1564,7 @@ const sortOptions = computed(() => { { value: 'narrator-first', label: 'Narrator First Name' }, { value: 'publisher', label: 'Publisher' }, { value: 'year', label: 'Release Year' }, + { value: 'date-added', label: 'Date Added' }, { value: 'monitored', label: 'Monitored' }, { value: 'status', label: 'Status' }, ] diff --git a/listenarr.application/Audiobooks/Catalog/LibraryAudiobookListItem.cs b/listenarr.application/Audiobooks/Catalog/LibraryAudiobookListItem.cs index 4c19f20c5..7a104b10a 100644 --- a/listenarr.application/Audiobooks/Catalog/LibraryAudiobookListItem.cs +++ b/listenarr.application/Audiobooks/Catalog/LibraryAudiobookListItem.cs @@ -47,5 +47,11 @@ public class LibraryAudiobookListItem public string[]? AuthorAsins { get; set; } public bool Wanted { get; set; } public string Status { get; set; } = string.Empty; + + /// + /// When the audiobook's earliest file was imported (UTC). Null for audiobooks that + /// have no tracked files yet (e.g. wanted books). Used for "Date Added" sorting. + /// + public DateTime? DateDownloaded { get; set; } } } diff --git a/listenarr.application/Audiobooks/Catalog/LibraryListService.cs b/listenarr.application/Audiobooks/Catalog/LibraryListService.cs index 9191b81c4..33d09d639 100644 --- a/listenarr.application/Audiobooks/Catalog/LibraryListService.cs +++ b/listenarr.application/Audiobooks/Catalog/LibraryListService.cs @@ -69,6 +69,7 @@ public async Task> GetAllAsync() // was started on this context instance" under real database latency. var fileSummaryRows = await _audiobookFileRepository.GetFormatSummariesAsync(); var fileCountById = await _audiobookFileRepository.GetCountsByAudiobookIdAsync(); + var earliestFileDateById = await _audiobookFileRepository.GetEarliestCreatedAtByAudiobookIdAsync(); var membershipsByAudiobookId = await _audiobookRepository.GetAllSeriesMembershipsGroupedByAudiobookIdAsync(); var filesByAudiobookId = fileSummaryRows .GroupBy(f => f.AudiobookId) @@ -146,7 +147,10 @@ public async Task> GetAllAsync() hasAnyFile, a.Quality, qualityProfile, - files) + files), + DateDownloaded = earliestFileDateById.TryGetValue(a.Id, out var earliestFileDate) + ? earliestFileDate + : (DateTime?)null }; }).ToList(); } diff --git a/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookFileRepository.cs b/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookFileRepository.cs index a2dae76c2..78dd62fcc 100644 --- a/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookFileRepository.cs +++ b/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookFileRepository.cs @@ -33,5 +33,6 @@ public interface IAudiobookFileRepository Task> GetAllAsync(CancellationToken ct = default); Task> GetFormatSummariesAsync(CancellationToken ct = default); Task> GetCountsByAudiobookIdAsync(CancellationToken ct = default); + Task> GetEarliestCreatedAtByAudiobookIdAsync(CancellationToken ct = default); } } diff --git a/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.cs b/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.cs index b219f0580..5cfbcee61 100644 --- a/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.cs +++ b/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.cs @@ -142,5 +142,14 @@ public async Task> GetCountsByAudiobookIdAsync(Cancellation .Select(g => new { AudiobookId = g.Key, Count = g.Count() }) .ToDictionaryAsync(r => r.AudiobookId, r => r.Count, ct); } + + public async Task> GetEarliestCreatedAtByAudiobookIdAsync(CancellationToken ct = default) + { + return await _db.AudiobookFiles + .AsNoTracking() + .GroupBy(f => f.AudiobookId) + .Select(g => new { AudiobookId = g.Key, EarliestCreatedAt = g.Min(f => f.CreatedAt) }) + .ToDictionaryAsync(r => r.AudiobookId, r => r.EarliestCreatedAt, ct); + } } }