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
99 changes: 29 additions & 70 deletions fe/src/__tests__/audiobookStatus.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,91 +17,50 @@
*/
import { describe, expect, it } from 'vitest'
import { computeAudiobookStatus } from '@/utils/audiobookStatus'
import type { Audiobook, QualityProfile } from '@/types'
import type { Audiobook } from '@/types'

describe('computeAudiobookStatus', () => {
it('uses the server-provided slim status when files are not present', () => {
const audiobook = {
id: 1,
title: 'Slim Book',
status: 'quality-match',
wanted: false,
} as Audiobook

expect(computeAudiobookStatus(audiobook, new Set(), [])).toBe('quality-match')
it('trusts the server-computed quality-match status', () => {
const audiobook = { id: 1, title: 'Matched', status: 'quality-match' } as Audiobook
expect(computeAudiobookStatus(audiobook, new Set())).toBe('quality-match')
})

it('lets active downloads override the cached list status', () => {
const audiobook = {
id: 2,
title: 'Downloading Book',
status: 'quality-match',
wanted: false,
} as Audiobook

expect(computeAudiobookStatus(audiobook, new Set([2]), [])).toBe('downloading')
it('trusts the server-computed quality-mismatch status', () => {
const audiobook = { id: 2, title: 'Below Cutoff', status: 'quality-mismatch' } as Audiobook
expect(computeAudiobookStatus(audiobook, new Set())).toBe('quality-mismatch')
})

it('recomputes from files when a richer audiobook payload is available', () => {
const audiobook = {
id: 3,
title: 'Detailed Book',
qualityProfileId: 10,
files: [{ id: 100, format: 'm4b', bitrate: 320000 }],
} as Audiobook

const profiles: QualityProfile[] = [
{
id: 10,
name: 'High Quality',
cutoffQuality: '320kbps',
preferredFormats: ['m4b'],
qualities: [{ quality: '320kbps', allowed: true, priority: 0 }],
},
]

expect(computeAudiobookStatus(audiobook, new Set(), profiles)).toBe('quality-match')
it('trusts the server-computed no-file status', () => {
const audiobook = { id: 3, title: 'Missing', status: 'no-file' } as Audiobook
expect(computeAudiobookStatus(audiobook, new Set())).toBe('no-file')
})

it('handles bitrate values stored in bits per second', () => {
const audiobook = {
id: 4,
title: 'Bitrate Book',
qualityProfileId: 10,
files: [{ id: 101, format: 'm4b', bitrate: 256000 }],
} as Audiobook

const profiles: QualityProfile[] = [
{
id: 10,
name: 'High Quality',
cutoffQuality: '256kbps',
preferredFormats: ['m4b'],
qualities: [{ quality: '256kbps', allowed: true, priority: 0 }],
},
]

expect(computeAudiobookStatus(audiobook, new Set(), profiles)).toBe('quality-match')
it('lets an active download override the cached server status', () => {
const audiobook = { id: 4, title: 'Downloading', status: 'quality-match' } as Audiobook
expect(computeAudiobookStatus(audiobook, new Set([4]))).toBe('downloading')
})

it('treats WavPack files as lossless', () => {
it('does not recompute from files/profiles — the server is the source of truth', () => {
// Files look like a healthy 320kbps M4B, but the server says it is below cutoff
// (e.g. its profile cutoff is higher). The frontend must defer to the server.
const audiobook = {
id: 5,
title: 'Lossless Book',
title: 'Defer To Server',
status: 'quality-mismatch',
qualityProfileId: 10,
files: [{ id: 102, format: 'wv', container: 'wv' }],
files: [{ id: 100, format: 'm4b', codec: 'aac', bitrate: 320000 }],
} as Audiobook

const profiles: QualityProfile[] = [
{
id: 10,
name: 'Lossless',
cutoffQuality: 'lossless',
preferredFormats: ['wv'],
qualities: [{ quality: 'lossless', allowed: true, priority: 0 }],
},
]
expect(computeAudiobookStatus(audiobook, new Set())).toBe('quality-mismatch')
})

it('falls back to no-file when the server sent no recognizable status', () => {
const audiobook = { id: 6, title: 'No Status' } as Audiobook
expect(computeAudiobookStatus(audiobook, new Set())).toBe('no-file')
})

expect(computeAudiobookStatus(audiobook, new Set(), profiles)).toBe('quality-match')
it('falls back to no-file when the status is an unrelated download-state string', () => {
const audiobook = { id: 7, title: 'Stray Status', status: 'completed' } as unknown as Audiobook
expect(computeAudiobookStatus(audiobook, new Set())).toBe('no-file')
})
})
125 changes: 14 additions & 111 deletions fe/src/utils/audiobookStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* 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 type { Audiobook, AudiobookStatus, QualityProfile } from '@/types'
import type { Audiobook, AudiobookStatus } from '@/types'

const AUDIOBOOK_STATUSES: AudiobookStatus[] = [
'downloading',
Expand All @@ -24,8 +24,6 @@ const AUDIOBOOK_STATUSES: AudiobookStatus[] = [
'quality-match',
]

const normalize = (value?: string): string => (value || '').toString().trim().toLowerCase()

function isAudiobookStatus(value: unknown): value is AudiobookStatus {
return typeof value === 'string' && AUDIOBOOK_STATUSES.includes(value as AudiobookStatus)
}
Expand All @@ -45,124 +43,29 @@ export function formatAudiobookStatus(status: AudiobookStatus): string {
}
}

/**
* Resolve the display status for an audiobook.
*
* The backend is the single source of truth for quality matching: the `/library`
* list endpoint runs {@link AudiobookStatusEvaluator.ComputeStatus} server-side and
* returns the result as `audiobook.status`. The frontend trusts that value and only
* applies a client-side override for transient state the server's snapshot can't see —
* an in-flight download for this audiobook.
*
* This deliberately no longer mirrors the backend QualityMatcher client-side; that
* duplicated business logic and could disagree with the automatic-search cutoff.
*/
export function computeAudiobookStatus(
audiobook: Audiobook,
audiobook: Pick<Audiobook, 'id' | 'status'>,
activeDownloadAudiobookIds: ReadonlySet<number>,
qualityProfiles: QualityProfile[],
): AudiobookStatus {
if (activeDownloadAudiobookIds.has(audiobook.id)) {
return 'downloading'
}

const hasFiles = Array.isArray(audiobook.files) && audiobook.files.length > 0
if (hasFiles) {
const profile = qualityProfiles.find((item) => item.id === audiobook.qualityProfileId)

if (!profile) {
const hasFileSummary =
!!(audiobook.filePath && audiobook.fileSize && audiobook.fileSize > 0) || hasFiles
return hasFileSummary ? 'quality-match' : 'no-file'
}

const preferredFormats = (profile.preferredFormats || []).map((item) => normalize(item))
const candidateFiles = audiobook.files!.filter((file) => {
if (!file) return false
const fileFormat = normalize(file.format) || normalize(file.container) || ''
if (preferredFormats.length === 0) return true
return (
preferredFormats.includes(fileFormat) ||
preferredFormats.some((preferredFormat) => fileFormat.includes(preferredFormat))
)
})

if (candidateFiles.length === 0) {
return 'quality-mismatch'
}

if (!profile.cutoffQuality || !profile.qualities || profile.qualities.length === 0) {
return 'quality-match'
}

const qualityPriority = new Map<string, number>()
for (const quality of profile.qualities) {
if (!quality || !quality.quality) continue
qualityPriority.set(normalize(quality.quality), quality.priority)
}

const cutoff = normalize(profile.cutoffQuality)
const cutoffPriority = qualityPriority.has(cutoff)
? qualityPriority.get(cutoff)!
: Number.POSITIVE_INFINITY

for (const file of candidateFiles) {
const derivedQuality = deriveQualityLabel(audiobook, file)
if (!derivedQuality) continue

const priority = qualityPriority.has(derivedQuality)
? qualityPriority.get(derivedQuality)!
: Number.POSITIVE_INFINITY

if (priority <= cutoffPriority) {
return 'quality-match'
}
}

return 'quality-mismatch'
}

if (isAudiobookStatus(audiobook.status)) {
return audiobook.status
}

return 'no-file'
}

function deriveQualityLabel(
audiobook: Audiobook,
file:
| {
bitrate?: number
container?: string
codec?: string
format?: string
}
| undefined,
): string {
if (audiobook.quality) return normalize(audiobook.quality)

if (file && file.bitrate) {
const bitrate = Number(file.bitrate)
if (!Number.isNaN(bitrate)) {
const bitrateKbps = bitrate >= 1000 ? bitrate / 1000 : bitrate
if (bitrateKbps >= 320) return '320kbps'
if (bitrateKbps >= 256) return '256kbps'
if (bitrateKbps >= 192) return '192kbps'
return `${Math.round(bitrateKbps)}kbps`
}
}

const container = normalize(file?.container)
const codec = normalize(file?.codec)
if (
container.includes('flac') ||
codec.includes('flac') ||
container.includes('alac') ||
codec.includes('alac') ||
container.includes('aiff') ||
codec.includes('aiff') ||
container.includes('ape') ||
codec.includes('ape') ||
container.includes('dsd') ||
codec.includes('dsd') ||
container.includes('wv') ||
codec.includes('wv') ||
container.includes('wav') ||
codec.includes('wav')
) {
return 'lossless'
}

if (file?.format) return normalize(file.format)

return ''
}
2 changes: 1 addition & 1 deletion fe/src/views/library/AudiobooksView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1790,7 +1790,7 @@ const activeDownloadAudiobookIds = computed(() => {
})

function computeAudiobookStatusRaw(audiobook: Audiobook): AudiobookStatus {
return computeAudiobookStatus(audiobook, activeDownloadAudiobookIds.value, qualityProfiles.value)
return computeAudiobookStatus(audiobook, activeDownloadAudiobookIds.value)
}

const audiobookStatusById = computed(() => {
Expand Down
2 changes: 1 addition & 1 deletion fe/src/views/library/CollectionView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2190,7 +2190,7 @@ function getAudiobookStatus(audiobook: CollectionDisplayItem): CollectionStatus
return 'not-added'
}

return computeAudiobookStatus(audiobook, activeDownloadAudiobookIds.value, qualityProfiles.value)
return computeAudiobookStatus(audiobook, activeDownloadAudiobookIds.value)
}

function getMonitoringLabel(audiobook: CollectionDisplayItem): string {
Expand Down
Loading