Skip to content
Merged
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.57] - 2026-03-08

### Added
- **Library payload and status regression coverage:** Added focused backend and frontend regression tests covering slim `/library` payload behavior, wanted-flag correctness, shared audiobook status calculation, and download-client host normalization across NZBGet, qBittorrent, SABnzbd, and Transmission.

### Changed
- **Slimmed `/library` list contract:** Converted `GET /library` from a hybrid list/detail response into a lighter list payload, while keeping `GET /library/{id}` as the rich single-audiobook detail endpoint.
- **Server-side library status evaluation:** Moved list status calculation into shared backend/frontend helpers so library and collection views no longer depend on full file metadata from the list response to derive status.
- **Event-driven library badge updates:** Removed periodic full-library polling for the Wanted badge in favor of store-driven updates backed by existing SignalR events, keeping a reconnect refresh path instead of a timer.
- **Deduplicated library fetches and client-side lookups:** Updated the frontend library store and app shell to collapse concurrent `/library` requests into a single in-flight fetch and reuse cached library state for header search and related UI lookups.
- **Download client URI handling normalization:** Standardized host/scheme/port/path handling across all download clients through a shared URI builder so adapters and monitor paths all interpret download-client connection settings consistently.

### Fixed
- **Slow `/library` responses on large or remote libraries:** Removed per-audiobook filesystem existence checks from the library list path and replaced them with DB-backed wanted-state evaluation, eliminating expensive synchronous disk/network probes during list loads.
- **Duplicate `/library` requests during app startup:** Fixed overlapping library fetches from the app shell and library views so initial navigation no longer issues redundant full-library requests.
- **Library polling churn:** Stopped the app from re-fetching the full library every 60 seconds just to refresh the Wanted badge.
- **NZBGet host parsing failures (`http:80` / name resolution errors):** Fixed malformed URL construction when users paste a scheme or path into download-client host fields, and applied the same normalization to qBittorrent, SABnzbd, and Transmission to prevent the same bug class across clients.
- **Extra database work in audiobook detail loading:** Collapsed the audiobook detail route from a two-query existence-check/fetch pattern into a single no-tracking detail query.

## [0.2.56] - 2026-03-05

### Added
Expand Down
4 changes: 2 additions & 2 deletions fe/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion fe/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "listenarr-fe",
"version": "0.2.55",
"version": "0.2.56",
"private": true,
"type": "module",
"engines": {
Expand Down
103 changes: 22 additions & 81 deletions fe/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@ import ConfirmDialog from '@/components/feedback/ConfirmDialog.vue'
import { useConfirmService } from '@/composables/confirmService'
import { useNotification } from '@/composables/useNotification'
import { useDownloadsStore } from '@/stores/downloads'
import { useLibraryStore } from '@/stores/library'
import { useAuthStore } from '@/stores/auth'
import { apiService } from '@/services/api'
import { getStartupConfigCached } from '@/services/startupConfigCache'
Expand All @@ -522,6 +523,7 @@ const STARTUP_CONFIG_UPDATED_EVENT = 'listenarr-startup-config-updated'
const { notification, close: closeNotification } = useNotification()
const { getProtectedImageSrc } = useProtectedImages()
const downloadsStore = useDownloadsStore()
const libraryStore = useLibraryStore()
const auth = useAuthStore()
const authEnabled = ref(false)
const startupConfigLoaded = ref(false)
Expand Down Expand Up @@ -697,7 +699,7 @@ const closeMobileMenu = () => {
// Reactive state for badges and counters
const notificationCount = computed(() => recentNotifications.filter((n) => !n.dismissed).length)
const queueItems = ref<QueueItem[]>([])
const wantedCount = ref(0)
const wantedCount = computed(() => libraryStore.audiobooks.filter((book) => book.wanted === true).length)
const systemIssues = ref(0)

// Activity count: Optimized with memoized intermediate computations
Expand Down Expand Up @@ -828,59 +830,15 @@ function notificationIconComponent(icon?: string) {
}
}

let wantedBadgeRefreshInterval: number | undefined
let unsubscribeQueue: (() => void) | null = null
let unsubscribeFilesRemoved: (() => void) | null = null
let wantedBadgeVisibilityHandler: (() => void) | null = null

function startWantedBadgePolling() {
if (wantedBadgeRefreshInterval) return
// Refresh immediately then start interval (only when page is visible)
if (!document.hidden) {
refreshWantedBadge()
wantedBadgeRefreshInterval = window.setInterval(refreshWantedBadge, 60000)
}

if (!wantedBadgeVisibilityHandler) {
wantedBadgeVisibilityHandler = () => {
if (document.hidden) {
if (wantedBadgeRefreshInterval) {
clearInterval(wantedBadgeRefreshInterval)
wantedBadgeRefreshInterval = undefined
}
} else {
if (!wantedBadgeRefreshInterval) {
refreshWantedBadge()
wantedBadgeRefreshInterval = window.setInterval(refreshWantedBadge, 60000)
}
}
}
// Use VueUse for automatic cleanup
useEventListener(document, 'visibilitychange', wantedBadgeVisibilityHandler)
}
}

function stopWantedBadgePolling() {
if (wantedBadgeRefreshInterval) {
clearInterval(wantedBadgeRefreshInterval)
wantedBadgeRefreshInterval = undefined
}
// Event listener is automatically cleaned up by VueUse
wantedBadgeVisibilityHandler = null
}
let unsubscribeSignalRConnected: (() => void) | null = null

// Fetch wanted badge count (library changes less frequently - minimal polling)
const refreshWantedBadge = async () => {
const syncLibrarySnapshot = async () => {
try {
// Wanted badge: rely exclusively on the server-provided `wanted` flag.
// Treat only audiobooks where server returns wanted === true as wanted.
const library = await apiService.getLibrary()
wantedCount.value = library.filter((book) => {
const serverWanted = (book as unknown as Record<string, unknown>)['wanted']
return serverWanted === true
}).length
await libraryStore.fetchLibrary()
} catch (err) {
logger.error('Failed to refresh wanted badge:', err)
logger.error('Failed to sync library snapshot:', err)
}
}

Expand Down Expand Up @@ -936,8 +894,10 @@ const onSearchInput = async () => {
searchDebounceTimer = window.setTimeout(async () => {
searching.value = true
try {
// First try to match local library entries
const lib = await apiService.getLibrary()
if (libraryStore.audiobooks.length === 0) {
await libraryStore.fetchLibrary()
}
const lib = libraryStore.audiobooks
const lower = q.toLowerCase()
const localMatches = lib.filter(
(b) =>
Expand Down Expand Up @@ -1099,8 +1059,14 @@ onMounted(async () => {

// If authenticated, load protected resources and enable real-time updates
if (auth.user.authenticated) {
// Load initial downloads
await downloadsStore.loadDownloads()
// Hydrate the app once, then keep it current from SignalR updates.
await Promise.all([downloadsStore.loadDownloads(), syncLibrarySnapshot()])

unsubscribeSignalRConnected = signalRService.onConnected(() => {
if (auth.user.authenticated) {
void syncLibrarySnapshot()
}
})

// Subscribe to queue updates via SignalR (real-time, no polling!)
unsubscribeQueue = signalRService.onQueueUpdate((queue) => {
Expand All @@ -1118,8 +1084,6 @@ onMounted(async () => {
const display =
removed.length > 0 ? removed.join(', ') : 'Files were removed from a library item.'
toast.info('Files removed', display, 6000)
// Refresh wanted badge in case monitored items lost files
refreshWantedBadge()
// Push into recent notifications
pushNotification({
id: `files-removed-${Date.now()}`,
Expand Down Expand Up @@ -1158,25 +1122,6 @@ onMounted(async () => {
}
})

// Subscribe to audiobook updates (for wanted badge refresh only, no notifications)
signalRService.onAudiobookUpdate((ab) => {
try {
if (!ab) return

// If server provided a wanted flag, refresh the wanted badge using the authoritative value
try {
const serverWanted = (ab as unknown as Record<string, unknown>)['wanted']
if (typeof serverWanted === 'boolean') {
// Recompute wantedCount by fetching library DTOs and trusting server 'wanted'
// This is a targeted refresh to avoid stale counts; call refreshWantedBadge()
refreshWantedBadge()
}
} catch {}
} catch (err) {
logger.error('AudiobookUpdate error', err)
}
})

// Subscribe to download updates for notification purposes.
// Only create notifications for meaningful lifecycle events: start (Queued)
// and completion (Completed). Do not create notifications for continuous
Expand Down Expand Up @@ -1214,9 +1159,6 @@ onMounted(async () => {
recentDownloadTitles.value.delete(title)
}, 30000)
}
} else if (status === 'moved') {
// Download was successfully imported - refresh wanted badge to reflect the change
refreshWantedBadge()
} else {
// Ignore progress/other transient updates
}
Expand Down Expand Up @@ -1250,9 +1192,6 @@ onMounted(async () => {
logger.debug('Fallback queue fetch failed (non-fatal)', err)
}

// Only poll "Wanted" badge (library changes infrequently)
startWantedBadgePolling()

logger.info('✅ Real-time updates enabled - Activity badge updates automatically via SignalR!')
await refreshAuthPresentationFromStartupConfig(true)

Expand Down Expand Up @@ -1297,7 +1236,9 @@ onUnmounted(() => {
if (unsubscribeFilesRemoved) {
unsubscribeFilesRemoved()
}
stopWantedBadgePolling()
if (unsubscribeSignalRConnected) {
unsubscribeSignalRConnected()
}
// Event listeners are automatically cleaned up by VueUse
})

Expand Down
Loading
Loading