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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions fe/src/__tests__/AudiobooksView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ type AudiobooksVm = {
showItemDetails?: boolean
groupBy?: string
visibleRange?: { start: number; end: number }
sortKey?: string
sortOrder?: 'asc' | 'desc'
sortKeyProxy?: string
sortOptions?: Array<{ value: string; label: string }>
audiobooks?: Array<{ title?: string }>
}

const getVm = (wrapper: ReturnType<typeof mount>) => wrapper.vm as unknown as AudiobooksVm
Expand Down Expand Up @@ -118,6 +123,88 @@ describe('AudiobooksView', () => {
expect(wrapper.text()).toContain('Test Publisher')
expect(wrapper.text()).toContain('2020')
})

it('sorts by canonical date added with unknown dates 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'], added: '2023-01-01T00:00:00Z', files: [] },
{ id: 2, title: 'Newest', authors: ['A'], added: '2025-06-01T00:00:00Z', files: [] },
{ id: 3, title: 'Middle', authors: ['A'], added: '2024-03-15T00:00:00Z', files: [] },
{ id: 4, title: 'Unknown', authors: ['A'], added: null, files: [] },
] 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((resolve) => setTimeout(resolve, 0))

const vm = getVm(wrapper)
await vm.setGroupBy?.('books')
await wrapper.vm.$nextTick()
expect(vm.sortOptions?.map((option) => option.value)).toContain('added')

vm.sortKey = 'added'
vm.sortOrder = 'desc'
await wrapper.vm.$nextTick()
expect(vm.audiobooks?.map((book) => book.title)).toEqual([
'Newest',
'Middle',
'Oldest',
'Unknown',
])

vm.sortOrder = 'asc'
await wrapper.vm.$nextTick()
expect(vm.audiobooks?.map((book) => book.title)).toEqual([
'Oldest',
'Middle',
'Newest',
'Unknown',
])

vm.sortKey = 'title'
vm.sortOrder = 'asc'
vm.sortKeyProxy = 'added'
await wrapper.vm.$nextTick()
expect(vm.sortKey).toBe('added')
expect(vm.sortOrder).toBe('desc')
})
})

describe('AudiobooksView Grouping', () => {
Expand Down
55 changes: 55 additions & 0 deletions fe/src/__tests__/CustomFilterModal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Listenarr - Audiobook Management System
* Copyright (C) 2024-2026 Listenarr Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import CustomFilterModal from '@/components/domain/collection/CustomFilterModal.vue'

const modalStub = {
template: '<div><slot name="header" /><slot /><slot name="footer" /></div>',
}
const modalBodyStub = {
template: '<div><slot /></div>',
}

describe('CustomFilterModal', () => {
it('resets the operator and value when a rule changes to Date Added', async () => {
const wrapper = mount(CustomFilterModal, {
props: {
isOpen: true,
filter: {
id: 'recent',
label: 'Recent',
rules: [{ field: 'title', operator: 'contains', value: 'existing title' }],
},
},
global: {
stubs: {
Modal: modalStub,
ModalHeader: true,
ModalBody: modalBodyStub,
},
},
})

const field = wrapper.get('select.field-select')
await field.setValue('added')

expect((wrapper.get('select.op-select').element as HTMLSelectElement).value).toBe('eq')
expect((wrapper.get('input[type="date"]').element as HTMLInputElement).value).toBe('')
})
})
24 changes: 24 additions & 0 deletions fe/src/__tests__/customFilterEvaluator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,30 @@ describe('customFilterEvaluator - grouping and precedence', () => {
expect(evaluateRules(b4 as Audiobook, rules)).toBe(false)
})

it('compares date-added rules by the user-local calendar date and excludes unknown dates', () => {
const added = new Date(2026, 7, 19, 12, 30, 0)
const addedKey = `${added.getFullYear().toString().padStart(4, '0')}-${(added.getMonth() + 1)
.toString()
.padStart(2, '0')}-${added.getDate().toString().padStart(2, '0')}`
const previousDay = new Date(added.getFullYear(), added.getMonth(), added.getDate() - 1)
const previousDayKey = `${previousDay.getFullYear().toString().padStart(4, '0')}-${(
previousDay.getMonth() + 1
)
.toString()
.padStart(2, '0')}-${previousDay.getDate().toString().padStart(2, '0')}`
const book = { ...base, added: added.toISOString() } as Audiobook

expect(evaluateRules(book, [{ field: 'added', operator: 'eq', value: addedKey }])).toBe(true)
expect(evaluateRules(book, [{ field: 'added', operator: 'gt', value: previousDayKey }])).toBe(
true,
)
expect(
evaluateRules({ ...book, added: null }, [
{ field: 'added', operator: 'ne', value: addedKey },
]),
).toBe(false)
})

it('uses slim list file summary fields for path, filesize, and file count filters', () => {
const slimBook = {
...base,
Expand Down
31 changes: 29 additions & 2 deletions fe/src/components/domain/collection/CustomFilterModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@
>
(
</button>
<select v-model="r.field" class="form-select field-select">
<select
v-model="r.field"
class="form-select field-select"
@change="onFieldChange(r)"
>
<option value="monitored">Monitored</option>
<option value="title">Title</option>
<option value="author">Author</option>
Expand All @@ -64,6 +68,7 @@
<option value="publisher">Publisher</option>
<option value="qualityProfileId">Quality Profile</option>
<option value="publishYear">Published Year</option>
<option value="added">Date Added</option>
<option value="path">Path</option>
<option value="files">Files</option>
<option value="filesize">Filesize</option>
Expand All @@ -75,7 +80,9 @@
</template>
<template
v-else-if="
['publishYear', 'publishedYear', 'files', 'filesize'].includes(r.field)
['publishYear', 'publishedYear', 'files', 'filesize', 'added'].includes(
r.field,
)
"
>
<option value="eq">=</option>
Expand Down Expand Up @@ -120,6 +127,9 @@
placeholder="e.g. 2023"
/>
</template>
<template v-else-if="r.field === 'added'">
<input v-model="r.value" type="date" class="form-input value-input" />
</template>
<template v-else-if="r.field === 'files'">
<input
v-model.number="r.value"
Expand Down Expand Up @@ -303,6 +313,23 @@ watch(
{ immediate: true },
)

function onFieldChange(r: Rule) {
r.value = ''

if (r.field === 'monitored') {
r.operator = 'is'
r.value = 'true'
return
}

if (['publishYear', 'publishedYear', 'files', 'filesize', 'added'].includes(r.field)) {
r.operator = 'eq'
return
}

r.operator = 'contains'
}

function addRule() {
local.value.rules.push({
field: 'title',
Expand Down
1 change: 1 addition & 0 deletions fe/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,7 @@ export interface Audiobook {
authors?: string[]
publishedDate?: string
publishYear?: string
added?: string | null
series?: string
seriesNumber?: string
seriesMemberships?: AudiobookSeriesMembership[]
Expand Down
56 changes: 56 additions & 0 deletions fe/src/utils/customFilterEvaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,34 @@ function normalizeString(s: unknown) {
return (s ?? '').toString().toLowerCase()
}

function toLocalDateKey(value: string | null | undefined): string | null {
if (!value) return null

const date = new Date(value)
if (Number.isNaN(date.getTime())) return null

const year = date.getFullYear().toString().padStart(4, '0')
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
return `${year}-${month}-${day}`
}

function normalizeDateRuleValue(value: string): string | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null

const [year, month, day] = value.split('-').map(Number)
const date = new Date(Date.UTC(year, month - 1, day))
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
return null
}

return value
}

function resolveFileCount(a: Audiobook): number {
if (Array.isArray(a.files)) {
return a.files.length
Expand Down Expand Up @@ -88,6 +116,9 @@ function evalSingle(a: Audiobook, r: RuleLike): boolean {
case 'filesize':
left = String((a as unknown as Record<string, unknown>)['fileSize'] ?? '')
break
case 'added':
left = toLocalDateKey(a.added) ?? ''
break
default:
left = String((a as unknown as Record<string, unknown>)[field] ?? '')
break
Expand All @@ -96,6 +127,31 @@ function evalSingle(a: Audiobook, r: RuleLike): boolean {
const l = normalizeString(left)
const v = normalizeString(val)

if (field === 'added') {
const leftDate = left || null
const valueDate = normalizeDateRuleValue(val)
if (!leftDate || !valueDate) return false

switch (op) {
case 'eq':
case 'is':
return leftDate === valueDate
case 'ne':
case 'is_not':
return leftDate !== valueDate
case 'lt':
return leftDate < valueDate
case 'lte':
return leftDate <= valueDate
case 'gt':
return leftDate > valueDate
case 'gte':
return leftDate >= valueDate
default:
return true
}
}

const numericFields = new Set(['publishYear', 'publishedYear', 'files', 'filesize'])
if (numericFields.has(field)) {
const leftNum = Number(left)
Expand Down
14 changes: 13 additions & 1 deletion fe/src/views/library/AudiobooksView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1175,6 +1175,17 @@ const filteredAndSortedAudiobooks = computed(() => {
av = getAudiobookStatus(a)
bv = getAudiobookStatus(b)
break
case 'added': {
const aTime = a.added ? Date.parse(a.added) : Number.NaN
const bTime = b.added ? Date.parse(b.added) : Number.NaN
const aHasDate = Number.isFinite(aTime)
const bHasDate = Number.isFinite(bTime)

if (!aHasDate && !bHasDate) return 0
if (!aHasDate) return 1
if (!bHasDate) return -1
return (aTime - bTime) * (sortOrder.value === 'asc' ? 1 : -1)
}
}

if (typeof av === 'boolean' && typeof bv === 'boolean') {
Expand Down Expand Up @@ -1557,6 +1568,7 @@ const sortOptions = computed(() => {
{ value: 'narrator-first', label: 'Narrator First Name' },
{ value: 'publisher', label: 'Publisher' },
{ value: 'year', label: 'Release Year' },
{ value: 'added', label: 'Date Added' },
{ value: 'monitored', label: 'Monitored' },
{ value: 'status', label: 'Status' },
]
Expand Down Expand Up @@ -1586,7 +1598,7 @@ const sortKeyProxy = computed<string>({
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
} else {
sortKey.value = val
sortOrder.value = 'asc'
sortOrder.value = val === 'added' ? 'desc' : 'asc'
}
},
})
Expand Down
1 change: 1 addition & 0 deletions listenarr.api/Features/Library/LibraryAddWorkflow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ private async Task<IActionResult> AddCoreAsync(LibraryController.AddToLibraryReq
return new ConflictObjectResult(new { message = "Audiobook already exists in library", audiobook = ex.Audiobook });
}

audiobook.Added = DateTime.UtcNow;
await _repo.AddAsync(audiobook);
await ResolveAuthorAsinsAsync(audiobook);
await SendAddedNotificationAsync(audiobook);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ private async Task<LibraryAddOperationResult> CommitAsync(
}

audiobook.ImageUrl = preparedImage.FallbackImageUrl;
audiobook.Added = DateTime.UtcNow;
cancellationToken.ThrowIfCancellationRequested();
await _commitStore.CommitAsync(
audiobook,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public class LibraryAudiobookListItem
public string[]? Narrators { get; set; }
public string? PublishYear { get; set; }
public string? PublishedDate { get; set; }
public DateTime? Added { get; set; }
public string? Series { get; set; }
public string? SeriesNumber { get; set; }
public AudiobookSeriesMembershipDto[]? SeriesMemberships { get; set; }
Expand Down
Loading
Loading