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
36 changes: 36 additions & 0 deletions fe/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import type {
DownloadClientConfiguration,
ApplicationSettings,
ProwlarrImportConnectionSettings,
AudiobookshelfConnectionSettings,
AudiobookshelfActionResult,
Audiobook,
History,
Indexer,
Expand Down Expand Up @@ -878,6 +880,40 @@ class ApiService {
return this.request<ProwlarrImportConnectionSettings>('/configuration/prowlarr-import')
}

// Audiobookshelf integration
async getAudiobookshelfSettings(): Promise<AudiobookshelfConnectionSettings> {
return this.request<AudiobookshelfConnectionSettings>('/configuration/audiobookshelf')
}

async saveAudiobookshelfSettings(
settings: AudiobookshelfConnectionSettings,
): Promise<AudiobookshelfConnectionSettings> {
return this.request<AudiobookshelfConnectionSettings>('/configuration/audiobookshelf', {
method: 'POST',
body: JSON.stringify(settings),
})
}

async testAudiobookshelfConnection(payload: {
url?: string
apiKey?: string
}): Promise<AudiobookshelfActionResult> {
return this.request<AudiobookshelfActionResult>('/audiobookshelf/test', {
method: 'POST',
body: JSON.stringify(payload),
})
}

async getAudiobookshelfLibraries(): Promise<AudiobookshelfActionResult> {
return this.request<AudiobookshelfActionResult>('/audiobookshelf/libraries')
}

async triggerAudiobookshelfScan(): Promise<AudiobookshelfActionResult> {
return this.request<AudiobookshelfActionResult>('/audiobookshelf/scan', {
method: 'POST',
})
}

// Root Folders
async getRootFolders(): Promise<RootFolder[]> {
return this.request<RootFolder[]>('/rootfolders')
Expand Down
20 changes: 20 additions & 0 deletions fe/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,26 @@ export interface ProwlarrImportConnectionSettings {
hasSavedApiKey: boolean
}

export interface AudiobookshelfConnectionSettings {
url: string
apiKey?: string | null
libraryId?: string | null
notifyOnImport: boolean
hasSavedApiKey: boolean
}

export interface AudiobookshelfLibrary {
id: string
name: string
mediaType: string
}

export interface AudiobookshelfActionResult {
success: boolean
message: string
libraries?: AudiobookshelfLibrary[] | null
}

export interface StartupConfig {
logLevel?: string
enableSsl?: boolean
Expand Down
24 changes: 23 additions & 1 deletion fe/src/views/SettingsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@
<PhGlobe />
Discord Bot
</button>
<button
@click="router.push({ hash: '#audiobookshelf' })"
:class="{ active: activeTab === 'audiobookshelf' }"
class="tab-button"
>
<PhBooks />
Audiobookshelf
</button>
<button
@click="router.push({ hash: '#general' })"
:class="{ active: activeTab === 'general' }"
Expand Down Expand Up @@ -257,6 +265,8 @@
ref="notificationsRef"
:settings="settings"
/>

<AudiobookshelfTab v-if="activeTab === 'audiobookshelf'" />
</div>

<!-- Metadata Source Configuration Modal -->
Expand Down Expand Up @@ -400,6 +410,7 @@ import RootFoldersTab from '@/views/settings/RootFoldersTab.vue'
import DownloadClientsTab from '@/views/settings/DownloadClientsTab.vue'
import QualityProfilesTab from '@/views/settings/QualityProfilesTab.vue'
import DiscordBotTab from '@/views/settings/DiscordBotTab.vue'
import AudiobookshelfTab from '@/views/settings/AudiobookshelfTab.vue'
import NotificationsTab from '@/views/settings/NotificationsTab.vue'
import IndexersTab from '@/views/settings/IndexersTab.vue'
import { Modal, ModalHeader, ModalFooter } from '@/components/feedback'
Expand All @@ -414,6 +425,7 @@ import {
PhStar,
PhBell,
PhGlobe,
PhBooks,
PhSliders,
PhPlus,
PhSpinner,
Expand Down Expand Up @@ -450,7 +462,14 @@ logger.debug(
(globalThis as unknown as { __vitest?: unknown }).__vitest,
)
const activeTab = ref<
'rootfolders' | 'indexers' | 'clients' | 'quality-profiles' | 'notifications' | 'bot' | 'general'
| 'rootfolders'
| 'indexers'
| 'clients'
| 'quality-profiles'
| 'notifications'
| 'bot'
| 'audiobookshelf'
| 'general'
>('rootfolders')

const mobileTabOptions = computed(() => [
Expand All @@ -460,6 +479,7 @@ const mobileTabOptions = computed(() => [
{ value: 'quality-profiles', label: 'Quality Profiles', icon: PhStar },
{ value: 'notifications', label: 'Notifications', icon: PhBell },
{ value: 'bot', label: 'Discord Bot', icon: PhGlobe },
{ value: 'audiobookshelf', label: 'Audiobookshelf', icon: PhBooks },
{ value: 'general', label: 'General Settings', icon: PhSliders },
// Integrations removed
])
Expand Down Expand Up @@ -1124,6 +1144,7 @@ const syncTabFromHash = () => {
| 'quality-profiles'
| 'notifications'
| 'bot'
| 'audiobookshelf'
| 'general'
if (
hash &&
Expand All @@ -1134,6 +1155,7 @@ const syncTabFromHash = () => {
'quality-profiles',
'notifications',
'bot',
'audiobookshelf',
'general',
].includes(hash)
) {
Expand Down
56 changes: 56 additions & 0 deletions fe/src/views/library/AudiobookDetailView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,7 @@ import {
PhArrowLeft,
PhArrowClockwise,
PhBookmark,
PhBooks,
PhSpinner,
PhMagnifyingGlass,
PhFolderOpen,
Expand Down Expand Up @@ -732,6 +733,8 @@ const deleteFilesOnDisk = ref(false)
const deleteFolderOnDisk = ref(false)
const showFullDescription = ref(false)
const scanning = ref(false)
const audiobookshelfScanning = ref(false)
const audiobookshelfConfigured = ref(false)
const rescanningMetadata = ref(false)
const scanQueued = ref(false)
const scanJobId = ref<string | null>(null)
Expand Down Expand Up @@ -833,6 +836,23 @@ const topActions = computed<DetailTopAction[]>(() => [
showOrganizeModal.value = true
},
},
...(audiobookshelfConfigured.value
? [
{
key: 'audiobookshelf-scan' as const,
label: audiobookshelfScanning.value ? 'Updating Audiobookshelf...' : 'Update Audiobookshelf',
title: 'Request an Audiobookshelf scan so this book shows up there',
ariaLabel: 'Update Audiobookshelf',
icon: audiobookshelfScanning.value ? PhSpinner : PhBooks,
iconClass: audiobookshelfScanning.value ? 'ph-spin' : undefined,
disabled: audiobookshelfScanning.value,
desktopGroup: 'secondary' as const,
onClick: () => {
void updateAudiobookshelf()
},
},
]
: []),
{
key: 'delete',
label: 'Delete',
Expand Down Expand Up @@ -878,6 +898,7 @@ type DetailTopAction = {
| 'edit'
| 'rescan-metadata'
| 'organize'
| 'audiobookshelf-scan'
| 'delete'
label: string
title: string
Expand Down Expand Up @@ -1186,6 +1207,19 @@ onMounted(async () => {
syncActiveTabFromRoute()
document.addEventListener('click', handleClickOutside)

// Show the Audiobookshelf action only when the integration is configured
// (typeof guard keeps partial apiService mocks in tests working)
if (typeof apiService.getAudiobookshelfSettings === 'function') {
void apiService
.getAudiobookshelfSettings()
.then((settings) => {
audiobookshelfConfigured.value = Boolean(settings.url && settings.hasSavedApiKey)
})
.catch(() => {
audiobookshelfConfigured.value = false
})
}

await loadAudiobook()

// subscribe to scan job updates
Expand Down Expand Up @@ -1453,6 +1487,28 @@ function handleDownloaded(result: SearchResult) {
closeManualSearch()
}

async function updateAudiobookshelf() {
const toast = useToast()
audiobookshelfScanning.value = true
try {
const result = await apiService.triggerAudiobookshelfScan()
if (result.success) {
toast.success('Audiobookshelf', result.message)
} else {
toast.error('Audiobookshelf scan failed', result.message)
}
} catch (err) {
errorTracking.captureException(err as Error, {
component: 'AudiobookDetailView',
operation: 'updateAudiobookshelf',
metadata: { audiobookId: audiobook.value?.id },
})
toast.error('Audiobookshelf scan failed', 'Could not reach the Listenarr API')
} finally {
audiobookshelfScanning.value = false
}
}

async function scanFiles() {
if (!audiobook.value) return
scanning.value = true
Expand Down
47 changes: 47 additions & 0 deletions fe/src/views/library/CollectionView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,17 @@
<component v-else :is="isCurrentSeriesMonitored ? PhEye : PhPlus" />
{{ isCurrentSeriesMonitored ? 'Monitoring Series' : 'Monitor Series' }}
</button>
<button
v-if="audiobookshelfConfigured"
class="toolbar-btn"
:disabled="audiobookshelfScanning"
@click="updateAudiobookshelf"
title="Request an Audiobookshelf scan so new books in this series show up there"
>
<PhArrowClockwise v-if="audiobookshelfScanning" class="spin-icon" />
<PhBooks v-else />
{{ audiobookshelfScanning ? 'Updating Audiobookshelf...' : 'Update Audiobookshelf' }}
</button>
</div>
</div>
<div class="toolbar-filters">
Expand Down Expand Up @@ -879,6 +890,8 @@ const seriesLookup = ref<SeriesLookupResponse | null>(null)
const seriesLookupLoading = ref(false)
const seriesLookupRequestId = ref(0)
const seriesMetadataRefreshBusy = ref(false)
const audiobookshelfScanning = ref(false)
const audiobookshelfConfigured = ref(false)
const seriesMonitoringBusy = ref(false)
const seriesMonitoringStatus = ref<MonitoredSeries | null>(null)
const seriesMonitoringStatusRequestId = ref(0)
Expand Down Expand Up @@ -1970,6 +1983,28 @@ async function refreshSeriesMetadata() {
}
}

async function updateAudiobookshelf() {
if (audiobookshelfScanning.value) return
audiobookshelfScanning.value = true
try {
const result = await apiService.triggerAudiobookshelfScan()
if (result.success) {
toast.success('Audiobookshelf', result.message)
} else {
toast.error('Audiobookshelf scan failed', result.message)
}
} catch (err) {
errorTracking.captureException(err as Error, {
component: 'CollectionView',
operation: 'updateAudiobookshelf',
metadata: { series: name.value },
})
toast.error('Audiobookshelf scan failed', 'Could not reach the Listenarr API')
} finally {
audiobookshelfScanning.value = false
}
}

const goBack = () => {
router.back()
}
Expand Down Expand Up @@ -2287,6 +2322,18 @@ function handleCheckboxKeydown(audiobook: CollectionDisplayItem, event: Keyboard
}

onMounted(async () => {
// Show the Audiobookshelf action only when the integration is configured
// (typeof guard keeps partial apiService mocks in tests working)
if (typeof apiService.getAudiobookshelfSettings === 'function') {
void apiService
.getAudiobookshelfSettings()
.then((settings) => {
audiobookshelfConfigured.value = Boolean(settings.url && settings.hasSavedApiKey)
})
.catch(() => {
audiobookshelfConfigured.value = false
})
}
await loadCollectionData(false)
})

Expand Down
Loading