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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions fe/src/__tests__/AudiobooksView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).ResizeObserver = class {
observe() {}
disconnect() {}
}
}
if (typeof (globalThis as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') {
;(globalThis as unknown as Record<string, unknown>).WebSocket = function () {
/* noop */
}
}

const pinia = createPinia()
setActivePinia(pinia)
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div />' } },
{ 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> | 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'])
})
})
3 changes: 3 additions & 0 deletions fe/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions fe/src/views/library/AudiobooksView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' },
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,11 @@ public class LibraryAudiobookListItem
public string[]? AuthorAsins { get; set; }
public bool Wanted { get; set; }
public string Status { get; set; } = string.Empty;

/// <summary>
/// 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.
/// </summary>
public DateTime? DateDownloaded { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public async Task<IReadOnlyList<LibraryAudiobookListItem>> 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)
Expand Down Expand Up @@ -146,7 +147,10 @@ public async Task<IReadOnlyList<LibraryAudiobookListItem>> GetAllAsync()
hasAnyFile,
a.Quality,
qualityProfile,
files)
files),
DateDownloaded = earliestFileDateById.TryGetValue(a.Id, out var earliestFileDate)
? earliestFileDate
: (DateTime?)null
};
}).ToList();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@ public interface IAudiobookFileRepository
Task<List<AudiobookFile>> GetAllAsync(CancellationToken ct = default);
Task<List<AudiobookFormatSummary>> GetFormatSummariesAsync(CancellationToken ct = default);
Task<Dictionary<int, int>> GetCountsByAudiobookIdAsync(CancellationToken ct = default);
Task<Dictionary<int, DateTime>> GetEarliestCreatedAtByAudiobookIdAsync(CancellationToken ct = default);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -142,5 +142,14 @@ public async Task<Dictionary<int, int>> GetCountsByAudiobookIdAsync(Cancellation
.Select(g => new { AudiobookId = g.Key, Count = g.Count() })
.ToDictionaryAsync(r => r.AudiobookId, r => r.Count, ct);
}

public async Task<Dictionary<int, DateTime>> 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);
}
}
}