Compare commits

...

59 Commits

Author SHA1 Message Date
advplyr
a467637cb5 Version bump v2.0.5 2022-04-29 12:59:35 -05:00
advplyr
1a23001955 Update version check to use releases from gh api instead of tags, add 5 minute buffer between checking for new releases 2022-04-29 12:20:51 -05:00
advplyr
8942dca31d Update docker-build workflow 2022-04-29 09:48:00 -05:00
advplyr
2a919012b6 Version bump 2.0.4 2022-04-28 18:43:00 -05:00
advplyr
40b342498f Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-04-28 18:40:34 -05:00
advplyr
e220b2818a Add docker-build workflow 2022-04-28 18:40:29 -05:00
advplyr
0df36d2609 Merge pull request #523 from mediacowboy/patch-1
Update readme.md
2022-04-28 17:43:50 -05:00
MediaCowboy
adfe50a841 Update readme.md
Updated the pull command to reflect the new docker repo.
2022-04-27 22:26:44 -05:00
advplyr
35925ddc1b Merge pull request #522 from selfhost-alt/skip-matching-identified-media
Add options to skip matching media items if they already have an ASIN/ISBN
2022-04-27 20:14:04 -05:00
advplyr
33dfb764fa Add:Support for openaudible folder structure (subject to change), add support for treating single audio files in the root directory as library items #401 2022-04-27 19:42:34 -05:00
advplyr
49bef2c641 Fix:Uploader removing single item from parsed upload items #530 2022-04-27 18:08:07 -05:00
advplyr
ac58536501 Fix:Drag n drop folder upload 2022-04-27 18:03:00 -05:00
advplyr
c344555be3 Fix:default user settings for orderBy and default to sort ascending for titles and authors #515 2022-04-27 17:20:44 -05:00
MediaCowboy
645bcc53c6 Update readme.md
Removed the --rm from the docker install command and added Docker Update section
2022-04-26 21:28:24 -05:00
Selfhost Alt
84dd06dfc4 Add options to skip matching media items if they already have an ASIN/ISBN 2022-04-26 17:36:29 -07:00
advplyr
0a73dd6437 Add:Ability to ignore directories by putting a file named .ignore inside dir #516 2022-04-26 19:11:32 -05:00
advplyr
2cc055a1ad Fix:checkbox default check color add to tailwind safelist #521 2022-04-26 18:14:11 -05:00
advplyr
d8ec3bd218 Merge pull request #512 from selfhost-alt/log-empty-folder-path-on-scan
Log full path when warning about empty root
2022-04-25 19:14:54 -05:00
advplyr
d189ec74c9 Update item api endpoint to include user media progress with item if using query string include=progress and optionally episode=episodeid - for mobile app downloads 2022-04-25 19:03:26 -05:00
advplyr
4291769b93 Fix:Filter checks on server to check for mediaType 2022-04-25 17:36:18 -05:00
Selfhost Alt
22900a3f67 Log full path when warning about empty root 2022-04-25 15:28:03 -07:00
advplyr
7fa08449de Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-04-25 16:39:02 -05:00
advplyr
4f7203fccb Update docker template 2022-04-25 16:38:57 -05:00
advplyr
0eea766931 Merge pull request #509 from jflattery/patch-1
Change default to ghcr
2022-04-25 14:58:18 -05:00
advplyr
5c054aef90 Merge pull request #508 from jflattery/patch-2
Change default to ghcr
2022-04-25 14:57:54 -05:00
Jim Flattery
a1674d5da1 Change default to ghcr 2022-04-25 15:45:08 -04:00
Jim Flattery
91597a5454 Change default to ghcr 2022-04-25 15:43:58 -04:00
advplyr
11354a3e3f Version bump 2.0.3 2022-04-24 19:18:43 -05:00
advplyr
dcd4f69383 Fix: set downloaded/uploaded cover owner and permissions and if creating intitial config/metadata directories at startup then set owner of those #394 2022-04-24 19:12:00 -05:00
advplyr
e253939c1e Fix: upload page files table selectable filename, size and type #406 2022-04-24 18:55:26 -05:00
advplyr
f25ce1c0e7 Fix: overlapping text on collections book table #410 2022-04-24 18:51:11 -05:00
advplyr
7717e57c16 Fix: add extra check for valid names and valid author name #502 2022-04-24 18:41:47 -05:00
advplyr
2e28c9b06d Add: button on issues page to remove all library items with issues #476 2022-04-24 18:25:33 -05:00
advplyr
4bc7cd2045 Fix: show books with invalid audio files and add error icon on book items #491 2022-04-24 18:05:15 -05:00
advplyr
5389115120 Add: Button on series page to mark all series as finished #452 2022-04-24 17:46:21 -05:00
advplyr
6e99cf6570 Fix: filter sort authors and series, authors page sort alphabetical #497 2022-04-24 17:15:41 -05:00
advplyr
21bdd9f9ec Fix set invalid flag to false when adding first episode to an empty podcast library item, dont show podcast errors on episode cards 2022-04-24 17:03:43 -05:00
advplyr
e3ae3f7e6a Update personalized api endpoint to new optimal function that only loops through library items once 2022-04-24 16:56:30 -05:00
advplyr
74bf917150 Update readme 2022-04-24 11:14:30 -05:00
advplyr
5666b263f5 Readme updates and banner update to represent podcasts 2022-04-24 11:11:49 -05:00
advplyr
fc8fec62a0 Version bump 2.0.2 2022-04-23 19:41:35 -05:00
advplyr
034d858f18 Change new podcast modal to remove episode download list #494, Fix error when importing many episodes (set max size to 5MB) #493, show podcast episodes downloading and in queue on podcast landing page 2022-04-23 19:41:06 -05:00
advplyr
ebc9e1a888 Fix batch mark as finished and clear selection #490 2022-04-23 17:17:05 -05:00
advplyr
c5a9c2bf5a Merge pull request #489 from selfhost-alt/configurable-backup-size
Make maximum backup size configurable
2022-04-23 17:06:59 -05:00
advplyr
3dbce8fd71 Fix:Persist playback rate #419 2022-04-23 16:51:13 -05:00
advplyr
b2d299dba6 Remove open playback sessions for user when starting a new playback session 2022-04-23 16:18:34 -05:00
Selfhost Alt
cb5d9a8287 Add explicit byte conversion variable to make code more self-documenting 2022-04-23 10:26:37 -07:00
Selfhost Alt
f9530897c0 Add tooltip to explain the max backup size 2022-04-23 10:23:01 -07:00
Selfhost Alt
7c7e8285a4 Make maximum backup size configurable 2022-04-23 10:19:31 -07:00
advplyr
7b3f9a1e0c Add bulkInsertEntities to db to handle migrating large collections 2022-04-23 06:25:16 -05:00
advplyr
399e0ea0bc Merge pull request #486 from selfhost-alt/quickmatch-updates-media-descriptions
Set description when quick matching media
2022-04-23 06:00:59 -05:00
advplyr
a47b0bce57 Merge pull request #485 from selfhost-alt/fix-scan-error
Update folder update logic to use new media path name
2022-04-23 05:59:10 -05:00
Selfhost Alt
4b60b4f73e Set description when quick matching media 2022-04-22 23:19:46 -07:00
Selfhost Alt
d88b20addd Update folder update logic to use new media path name 2022-04-22 22:29:38 -07:00
advplyr
5d12cc3f23 Podcast home page shelves for currently listening episodes, newest episodes. Podcast episode card 2022-04-22 19:31:11 -05:00
advplyr
84fb7ce8b3 Merge pull request #484 from benonymity/search_fix
Fix libraryItem ID reference in global search
2022-04-22 18:03:56 -05:00
benonymity
243cc672f7 Fix libraryItem in global search, same fix as app 2022-04-22 18:58:43 -04:00
advplyr
663546dd77 Fix edit modal registering/unregistering library item listeners #483 2022-04-22 17:42:49 -05:00
advplyr
1b79b3f42d Add secondary sort by series sort title when sorting by author #274 2022-04-22 17:11:03 -05:00
71 changed files with 1440 additions and 399 deletions

78
.github/workflows/docker-build.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
---
name: Build and Push Docker Image
on:
push:
branches: [master]
tags:
- 'v*.*.*'
# Only build when files in these directories have been changed
paths:
- client/**
- server/**
- index.js
- package.json
release:
types: [published, edited]
# Allows you to run workflow manually from Actions tab
workflow_dispatch:
jobs:
build:
if: "!contains(github.event.head_commit.message, 'skip ci')"
runs-on: ubuntu-20.04
steps:
- name: Check out
uses: actions/checkout@v2
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
images: advplyr/audiobookshelf-dev,ghcr.io/${{ github.repository_owner }}/audiobookshelf-dev
tags: |
type=edge,branch=master
type=semver,pattern={{version}}
- name: Setup QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Login to Dockerhub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Login to ghcr
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_PASSWORD }}
- name: Build image
uses: docker/build-push-action@v2
with:
tags: ${{ steps.meta.outputs.tags }}
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache

View File

@@ -166,6 +166,7 @@ export default {
isFinished: newIsFinished
}
})
console.log('Progress payloads', updateProgressPayloads)
this.$axios
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => {

View File

@@ -91,7 +91,7 @@ export default {
},
async fetchCategories() {
var categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`)
.$get(`/api/libraries/${this.currentLibraryId}/personalized`)
.then((data) => {
return data
})

View File

@@ -7,6 +7,11 @@
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editBook" />
</template>
</div>
<div v-if="shelf.type === 'episode'" class="flex items-center">
<template v-for="(entity, index) in shelf.entities">
<cards-lazy-book-card :key="entity.recentEpisode.id" :ref="`shelf-episode-${entity.recentEpisode.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editEpisode" />
</template>
</div>
<div v-if="shelf.type === 'series'" class="flex items-center">
<template v-for="entity in shelf.entities">
<cards-lazy-series-card :key="entity.name" :series-mount="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
@@ -70,11 +75,6 @@ export default {
selectedAuthor: null
}
},
watch: {
isSelectionMode(newVal) {
this.updateSelectionMode(newVal)
}
},
computed: {
bookCoverHeight() {
return this.bookCoverWidth * this.bookCoverAspectRatio
@@ -94,6 +94,9 @@ export default {
}
},
methods: {
clearSelectedEntities() {
this.updateSelectionMode(false)
},
editAuthor(author) {
this.selectedAuthor = author
this.showAuthorModal = true
@@ -103,9 +106,14 @@ export default {
this.$store.commit('setBookshelfBookIds', bookIds)
this.$store.commit('showEditModal', audiobook)
},
editEpisode({ libraryItem, episode }) {
this.$store.commit('setSelectedLibraryItem', libraryItem)
this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
},
updateSelectionMode(val) {
var selectedLibraryItems = this.$store.state.selectedLibraryItems
if (this.shelf.type === 'book') {
if (this.shelf.type === 'book' || this.shelf.type === 'podcast') {
this.shelf.entities.forEach((ent) => {
var component = this.$refs[`shelf-book-${ent.id}`]
if (!component || !component.length) return
@@ -113,10 +121,24 @@ export default {
component.setSelectionMode(val)
component.selected = selectedLibraryItems.includes(ent.id)
})
} else if (this.shelf.type === 'episode') {
this.shelf.entities.forEach((ent) => {
var component = this.$refs[`shelf-episode-${ent.recentEpisode.id}`]
if (!component || !component.length) return
component = component[0]
component.setSelectionMode(val)
component.selected = selectedLibraryItems.includes(ent.id)
})
}
},
selectItem(libraryItem) {
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
this.$nextTick(() => {
this.$eventBus.$emit('item-selected', libraryItem)
})
},
itemSelectedEvt() {
this.updateSelectionMode(this.isSelectionMode)
},
scrolled() {
clearTimeout(this.scrollTimer)
@@ -160,6 +182,14 @@ export default {
this.canScrollLeft = false
}
}
},
mounted() {
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
},
beforeDestroy() {
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
}
}
</script>

View File

@@ -14,16 +14,28 @@
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8">
<template v-if="page !== 'search' && page !== 'podcast-search' && !isHome">
<p v-if="!selectedSeries" class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p>
<div v-else class="items-center hidden md:flex">
<div v-else class="items-center hidden md:flex w-full">
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
<span class="material-icons text-2xl text-white">west</span>
</div>
<p class="pl-4 font-book text-lg">
{{ selectedSeries }}
{{ seriesName }}
</p>
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
<span class="font-mono">{{ numShowing }}</span>
</div>
<div class="flex-grow" />
<ui-btn color="primary" small :loading="processingSeries" class="flex items-center" @click="markSeriesFinished">
<div class="h-5 w-5">
<svg v-if="isSeriesFinished" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" />
</svg>
</div>
<span class="pl-2"> Mark Series {{ isSeriesFinished ? 'Not Finished' : 'Finished' }}</span></ui-btn
>
</div>
<div class="flex-grow hidden sm:inline-block" />
@@ -38,6 +50,8 @@
<span class="material-icons" style="font-size: 1.4rem">view_list</span>
</div>
</div> -->
<ui-btn v-if="isIssuesFilter && userCanDelete" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">Remove All {{ numShowing }} {{ entityName }}</ui-btn>
</template>
<template v-else-if="page === 'search'">
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
@@ -56,7 +70,10 @@ export default {
props: {
page: String,
isHome: Boolean,
selectedSeries: String,
selectedSeries: {
type: Object,
default: () => null
},
searchQuery: String,
viewMode: String
},
@@ -66,10 +83,15 @@ export default {
hasInit: false,
totalEntities: 0,
keywordFilter: null,
keywordTimeout: null
keywordTimeout: null,
processingSeries: false,
processingIssues: false
}
},
computed: {
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
},
@@ -103,9 +125,68 @@ export default {
},
showLibrary() {
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
},
seriesName() {
return this.selectedSeries ? this.selectedSeries.name : null
},
seriesProgress() {
return this.selectedSeries ? this.selectedSeries.progress : null
},
seriesLibraryItemIds() {
if (!this.seriesProgress) return []
return this.seriesProgress.libraryItemIds || []
},
isSeriesFinished() {
return this.seriesProgress && !!this.seriesProgress.isFinished
},
filterBy() {
return this.$store.getters['user/getUserSetting']('filterBy')
},
isIssuesFilter() {
return this.filterBy === 'issues'
}
},
methods: {
removeAllIssues() {
if (confirm(`Are you sure you want to remove all library items with issues?\n\nNote: This will not delete any files`)) {
this.processingIssues = true
this.$axios
.$delete(`/api/libraries/${this.currentLibraryId}/issues`)
.then(() => {
this.$toast.success('Removed library items with issues')
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
this.processingIssues = false
})
.catch((error) => {
console.error('Failed to remove library items with issues', error)
this.$toast.error('Failed to remove library items with issues')
this.processingIssues = false
})
}
},
markSeriesFinished() {
var newIsFinished = !this.isSeriesFinished
this.processingSeries = true
var updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
return {
id: lid,
isFinished: newIsFinished
}
})
console.log('Progress payloads', updateProgressPayloads)
this.$axios
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => {
this.$toast.success('Series update success')
this.selectedSeries.progress.isFinished = newIsFinished
this.processingSeries = false
})
.catch((error) => {
this.$toast.error('Series update failed')
console.error('Failed to batch update read/not read', error)
this.processingSeries = false
})
},
searchBackArrow() {
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
},

View File

@@ -61,7 +61,6 @@ export default {
totalShelves: 0,
bookshelfMarginLeft: 0,
isSelectionMode: false,
isSelectAll: false,
currentSFQueryString: null,
pendingReset: false,
keywordFilter: null,
@@ -90,9 +89,12 @@ export default {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
},
emptyMessage() {
if (this.page === 'series') return `You have no series`
if (this.page === 'series') return 'You have no series'
if (this.page === 'collections') return "You haven't made any collections yet"
if (this.hasFilter) return `No Results for filter "${this.filterName}: ${this.filterValue}"`
if (this.hasFilter) {
if (this.filterName === 'Issues') return 'No Issues'
return `No Results for filter "${this.filterName}: ${this.filterValue}"`
}
return 'No results'
},
entityName() {
@@ -217,7 +219,6 @@ export default {
clearSelectedEntities() {
this.updateBookSelectionMode(false)
this.isSelectionMode = false
this.isSelectAll = false
},
selectEntity(entity) {
if (this.entityName === 'books' || this.entityName === 'series-books') {
@@ -339,7 +340,6 @@ export default {
this.totalEntities = 0
this.currentPage = 0
this.isSelectionMode = false
this.isSelectAll = false
this.initialized = false
this.initSizeData()

View File

@@ -112,6 +112,9 @@ export default {
showLibrary() {
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
},
filterBy() {
return this.$store.getters['user/getUserSetting']('filterBy')
},
showingIssues() {
if (!this.$route.query) return false
return this.libraryBookshelfPage && this.$route.query.filter === 'issues'

View File

@@ -69,7 +69,8 @@ export default {
sleepTimerTime: 0,
sleepTimerRemaining: 0,
sleepTimer: null,
displayTitle: null
displayTitle: null,
initialPlaybackRate: 1
}
},
computed: {
@@ -204,6 +205,7 @@ export default {
this.playerHandler.setVolume(volume)
},
setPlaybackRate(playbackRate) {
this.initialPlaybackRate = playbackRate
this.playerHandler.setPlaybackRate(playbackRate)
},
seek(time) {
@@ -253,7 +255,7 @@ export default {
libraryItem: session.libraryItem,
episodeId: session.episodeId
})
this.playerHandler.prepareOpenSession(session)
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate)
},
streamOpen(session) {
console.log(`[StreamContainer] Stream session open`, session)
@@ -311,7 +313,7 @@ export default {
episodeId
})
this.playerHandler.load(libraryItem, episodeId, true)
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
},
pauseItem() {
this.playerHandler.pause()

View File

@@ -35,8 +35,8 @@
</div>
</div>
<!-- No progress shown for collapsed series in library and podcasts -->
<div v-if="!booksInSeries && !isPodcast" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<!-- No progress shown for collapsed series in library and podcasts (unless showing podcast episode) -->
<div v-if="!booksInSeries && (!isPodcast || episodeProgress)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<!-- Overlay is not shown if collapsing series in library -->
<div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
@@ -60,7 +60,7 @@
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
</div>
<div ref="moreIcon" v-show="!isSelectionMode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
<div ref="moreIcon" v-show="!isSelectionMode && !recentEpisode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
</div>
</div>
@@ -77,13 +77,18 @@
</div>
</ui-tooltip>
<!-- Volume number -->
<!-- Series sequence -->
<div v-if="seriesSequence && showSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
</div>
<!-- Podcast Episode # -->
<div v-if="recentEpisodeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
</div>
<!-- Podcast Num Episodes -->
<div v-if="numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<div v-else-if="numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
</div>
</div>
@@ -145,6 +150,10 @@ export default {
_libraryItem() {
return this.libraryItem || {}
},
isFile() {
// Library item is not in a folder
return this._libraryItem.isFile
},
media() {
return this._libraryItem.media || {}
},
@@ -190,6 +199,17 @@ export default {
processingBatch() {
return this.store.state.processingBatch
},
recentEpisode() {
// Only added to item when getting currently listening podcasts
return this._libraryItem.recentEpisode
},
recentEpisodeNumber() {
if (!this.recentEpisode) return null
if (this.recentEpisode.episode) {
return this.recentEpisode.episode.replace(/^#/, '')
}
return this.recentEpisode.index
},
collapsedSeries() {
// Only added to item object when collapseSeries is enabled
return this._libraryItem.collapsedSeries
@@ -240,7 +260,13 @@ export default {
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
return null
},
episodeProgress() {
// Only used on home page currently listening podcast shelf
if (!this.recentEpisode) return null
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
},
userProgress() {
if (this.episodeProgress) return this.episodeProgress
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
userProgressPercent() {
@@ -250,7 +276,8 @@ export default {
return this.userProgress ? !!this.userProgress.isFinished : false
},
showError() {
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
if (this.recentEpisode) return false // Dont show podcast error on episode card
return this.numInvalidAudioFiles || this.numMissingParts || this.isMissing || this.isInvalid
},
isStreaming() {
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
@@ -259,7 +286,7 @@ export default {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
@@ -270,22 +297,27 @@ export default {
isInvalid() {
return this._libraryItem.isInvalid
},
hasMissingParts() {
return this._libraryItem.hasMissingParts
numMissingParts() {
if (this.isPodcast) return 0
return this.media.numMissingParts
},
hasInvalidParts() {
return this._libraryItem.hasInvalidParts
numInvalidAudioFiles() {
if (this.isPodcast) return 0
return this.media.numInvalidAudioFiles
},
errorText() {
if (this.isMissing) return 'Item directory is missing!'
else if (this.isInvalid) return 'Item has no audio tracks & ebook'
var txt = ''
if (this.hasMissingParts) {
txt = `${this.hasMissingParts} missing parts.`
else if (this.isInvalid) {
if (this.isPodcast) return 'Podcast has no episodes'
return 'Item has no audio tracks & ebook'
}
if (this.hasInvalidParts) {
if (this.hasMissingParts) txt += ' '
txt += `${this.hasInvalidParts} invalid parts.`
var txt = ''
if (this.numMissingParts) {
txt += `${this.numMissingParts} missing parts.`
}
if (this.numInvalidAudioFiles) {
if (txt) txt += ' '
txt += `${this.numInvalidAudioFiles} invalid audio files.`
}
return txt || 'Unknown Error'
},
@@ -337,7 +369,7 @@ export default {
text: 'Match'
})
}
if (this.userIsRoot) {
if (this.userIsRoot && !this.isFile) {
items.push({
func: 'rescan',
text: 'Re-Scan'
@@ -406,6 +438,9 @@ export default {
}
},
editClick() {
if (this.recentEpisode) {
return this.$emit('edit', { libraryItem: this.libraryItem, episode: this.recentEpisode })
}
this.$emit('edit', this.libraryItem)
},
toggleFinished() {
@@ -529,7 +564,8 @@ export default {
play() {
var eventBus = this.$eventBus || this.$nuxt.$eventBus
eventBus.$emit('play-item', {
libraryItemId: this.libraryItemId
libraryItemId: this.libraryItemId,
episodeId: this.recentEpisode ? this.recentEpisode.id : null
})
},
mouseover() {

View File

@@ -61,6 +61,9 @@ export default {
books() {
return this.series ? this.series.books || [] : []
},
addedAt() {
return this.series ? this.series.addedAt : 0
},
seriesBookProgress() {
return this.books
.map((libraryItem) => {

View File

@@ -132,6 +132,11 @@ export default {
text: 'Tag',
value: 'tags',
sublist: true
},
{
text: 'Issues',
value: 'issues',
sublist: false
}
]
}
@@ -166,26 +171,26 @@ export default {
selectedText() {
if (!this.selected) return ''
var parts = this.selected.split('.')
var filterName = this.selectItems.find((i) => i.value === parts[0]);
var filterValue = null;
var filterName = this.selectItems.find((i) => i.value === parts[0])
var filterValue = null
if (parts.length > 1) {
var decoded = this.$decode(parts[1])
if (decoded.startsWith('aut_')) {
var author = this.authors.find((au) => au.id == decoded)
if (author) filterValue = author.name;
if (author) filterValue = author.name
} else if (decoded.startsWith('ser_')) {
var series = this.series.find((se) => se.id == decoded)
if (series) filterValue = series.name
} else {
filterValue = decoded;
filterValue = decoded
}
}
if (filterName && filterValue) {
return `${filterName.text}: ${filterValue}`;
return `${filterName.text}: ${filterValue}`
} else if (filterName) {
return filterName.text;
return filterName.text
} else if (filterValue) {
return filterValue;
return filterValue
} else {
return ''
}
@@ -212,7 +217,7 @@ export default {
return ['Finished', 'In Progress', 'Not Started']
},
missing() {
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Volume Number', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language', ]
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
},
sublistItems() {
return (this[this.sublist] || []).map((item) => {

View File

@@ -22,7 +22,7 @@
<p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
<template v-for="item in bookResults">
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/item/${item.id}`">
<nuxt-link :to="`/item/${item.libraryItem.id}`">
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
</nuxt-link>
</li>
@@ -31,7 +31,7 @@
<p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Podcasts</p>
<template v-for="item in podcastResults">
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/item/${item.id}`">
<nuxt-link :to="`/item/${item.libraryItem.id}`">
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
</nuxt-link>
</li>

View File

@@ -131,6 +131,9 @@ export default {
this.selectedDesc = !this.selectedDesc
} else {
this.selected = val
if (val == 'media.metadata.title' || val == 'media.metadata.author' || val == 'media.metadata.authorName' || val == 'media.metadata.authorNameLF') {
this.selectedDesc = false
}
}
this.showMenu = false
this.$nextTick(() => this.$emit('change', val))

View File

@@ -181,15 +181,18 @@ export default {
if (this.currentBookshelfIndex - 1 < 0) return
var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]
this.processing = true
var prevBook = await this.$axios.$get(`/api/items/${prevBookId}`).catch((error) => {
var prevBook = await this.$axios.$get(`/api/items/${prevBookId}?expanded=1`).catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
this.$toast.error(errorMsg)
return null
})
this.processing = false
if (prevBook) {
this.$store.commit('showEditModalOnTab', { libraryItem: prevBook, tab: this.selectedTab })
this.$nextTick(this.init)
this.unregisterListeners()
this.libraryItem = prevBook
this.selectedTab = 'details'
this.$store.commit('setSelectedLibraryItem', prevBook)
this.$nextTick(this.registerListeners)
} else {
console.error('Book not found', prevBookId)
}
@@ -198,15 +201,18 @@ export default {
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
this.processing = true
var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1]
var nextBook = await this.$axios.$get(`/api/items/${nextBookId}`).catch((error) => {
var nextBook = await this.$axios.$get(`/api/items/${nextBookId}?expanded=1`).catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
this.$toast.error(errorMsg)
return null
})
this.processing = false
if (nextBook) {
this.$store.commit('showEditModalOnTab', { libraryItem: nextBook, tab: this.selectedTab })
this.$nextTick(this.init)
this.unregisterListeners()
this.libraryItem = nextBook
this.selectedTab = 'details'
this.$store.commit('setSelectedLibraryItem', nextBook)
this.$nextTick(this.registerListeners)
} else {
console.error('Book not found', nextBookId)
}

View File

@@ -14,7 +14,7 @@
</ui-tooltip>
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
<ui-btn v-if="isRootUser && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
</ui-tooltip>
<ui-btn @click="submitForm">Submit</ui-btn>
@@ -49,6 +49,9 @@ export default {
this.$emit('update:processing', val)
}
},
isFile() {
return !!this.libraryItem && this.libraryItem.isFile
},
isRootUser() {
return this.$store.getters['user/getIsRoot']
},

View File

@@ -1,9 +1,5 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<template v-for="audiobook in audiobooks">
<tables-tracks-table :key="audiobook.id" :title="`Audiobook Tracks (${audiobook.name})`" :audiobook-id="audiobook.id" :tracks="audiobook.tracks" class="mb-4" />
</template>
<tables-library-files-table expanded :files="libraryFiles" :library-item-id="libraryItem.id" :is-missing="isMissing" />
</div>
</template>
@@ -51,12 +47,6 @@ export default {
},
showDownload() {
return this.userCanDownload && !this.isMissing
},
audiobooks() {
return this.media.audiobooks || []
},
ebooks() {
return this.media.ebooks || []
}
},
methods: {

View File

@@ -93,7 +93,9 @@ export default {
icon: 'database',
mediaType: 'book',
settings: {
disableWatcher: false
disableWatcher: false,
skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false,
}
}
},

View File

@@ -8,6 +8,18 @@
</div>
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
</div>
<div class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
<p class="pl-4 text-lg">Skip matching books that already have an ASIN</p>
</div>
</div>
<div class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
<p class="pl-4 text-lg">Skip matching books that already have an ISBN</p>
</div>
</div>
</div>
</template>
@@ -23,7 +35,9 @@ export default {
data() {
return {
provider: null,
disableWatcher: false
disableWatcher: false,
skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false,
}
},
computed: {
@@ -45,7 +59,9 @@ export default {
getLibraryData() {
return {
settings: {
disableWatcher: !!this.disableWatcher
disableWatcher: !!this.disableWatcher,
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn
}
}
},
@@ -54,6 +70,8 @@ export default {
},
init() {
this.disableWatcher = !!this.librarySettings.disableWatcher
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
}
},
mounted() {

View File

@@ -35,17 +35,6 @@
<script>
export default {
props: {
value: Boolean,
libraryItem: {
type: Object,
default: () => {}
},
episode: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false,
@@ -72,12 +61,18 @@ export default {
computed: {
show: {
get() {
return this.value
return this.$store.state.globals.showEditPodcastEpisode
},
set(val) {
this.$emit('input', val)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', val)
}
},
libraryItem() {
return this.$store.state.selectedLibraryItem
},
episode() {
return this.$store.state.globals.selectedEpisode
},
episodeId() {
return this.episode ? this.episode.id : null
},

View File

@@ -124,7 +124,13 @@ export default {
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
}
console.log('Podcast payload', episodesToDownload)
var payloadSize = JSON.stringify(episodesToDownload).length
var sizeInMb = payloadSize / 1024 / 1024
var sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
console.log('Request size', sizeInMb)
if (sizeInMb > 4.99) {
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
}
this.processing = true
this.$axios
@@ -144,7 +150,8 @@ export default {
init() {
for (let i = 0; i < this.episodes.length; i++) {
var episode = this.episodes[i]
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) { // Do not include episodes already downloaded
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {
// Do not include episodes already downloaded
this.$set(this.selectedEpisodes, String(i), false)
}
}

View File

@@ -1,63 +1,42 @@
<template>
<modals-modal v-model="show" name="new-podcast-modal" :width="1200" :height="'unset'" :processing="processing">
<modals-modal v-model="show" name="new-podcast-modal" :width="1000" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
<div class="flex flex-wrap">
<div class="w-full md:w-1/2 p-4">
<p class="text-lg font-semibold mb-2">Details</p>
<div class="flex flex-wrap">
<div v-if="podcast.imageUrl" class="p-1 w-full">
<img :src="podcast.imageUrl" class="h-16 w-16 object-contain" />
</div>
<div class="p-1 w-full">
<ui-text-input-with-label v-model="podcast.title" label="Title" @input="titleUpdated" />
</div>
<div class="p-1 w-full">
<ui-text-input-with-label v-model="podcast.author" label="Author" />
</div>
<div class="p-1 w-full">
<ui-text-input-with-label v-model="podcast.feedUrl" label="Feed URL" readonly />
</div>
<div class="p-1 w-full">
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" label="Genres" />
</div>
<div class="p-1 w-full">
<ui-textarea-with-label v-model="podcast.description" label="Description" />
</div>
<div class="p-1 w-full">
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" @input="folderUpdated" />
</div>
<div class="p-1 w-full">
<ui-text-input-with-label v-model="fullPath" label="Podcast Path" readonly />
</div>
<div class="w-full p-4">
<p class="text-lg font-semibold mb-2">Details</p>
<div v-if="podcast.imageUrl" class="p-1 w-full">
<img :src="podcast.imageUrl" class="h-16 w-16 object-contain" />
</div>
<div class="flex">
<div class="w-full md:w-1/2 p-2">
<ui-text-input-with-label v-model="podcast.title" label="Title" @input="titleUpdated" />
</div>
<div class="w-full md:w-1/2 p-2">
<ui-text-input-with-label v-model="podcast.author" label="Author" />
</div>
</div>
<div class="w-full md:w-1/2 p-4">
<p class="text-lg font-semibold mb-2">Episodes</p>
<div ref="episodeContainer" id="episodes-scroll" class="w-full overflow-x-hidden overflow-y-auto">
<div class="relative">
<div class="absolute top-0 left-0 h-full flex items-center p-2">
<ui-checkbox v-model="selectAll" small checkbox-bg="primary" border-color="gray-600" />
</div>
<div class="px-8 py-2">
<p class="font-semibold text-gray-200">Select all episodes</p>
</div>
</div>
<div v-for="(episode, index) in episodes" :key="index" class="relative cursor-pointer" :class="selectedEpisodes[String(index)] ? 'bg-success bg-opacity-10' : index % 2 == 0 ? 'bg-primary bg-opacity-25 hover:bg-opacity-40' : 'bg-primary bg-opacity-5 hover:bg-opacity-25'" @click="toggleSelectEpisode(index)">
<div class="absolute top-0 left-0 h-full flex items-center p-2">
<ui-checkbox v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
</div>
<div class="px-8 py-2">
<p v-if="episode.episode" class="font-semibold text-gray-200">#{{ episode.episode }}</p>
<p class="break-words mb-1">{{ episode.title }}</p>
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
</div>
</div>
<div class="flex">
<div class="w-full md:w-1/2 p-2">
<ui-text-input-with-label v-model="podcast.feedUrl" label="Feed URL" readonly />
</div>
<div class="w-full md:w-1/2 p-2">
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" label="Genres" />
</div>
</div>
<div class="p-2 w-full">
<ui-textarea-with-label v-model="podcast.description" label="Description" :rows="3" />
</div>
<div class="flex">
<div class="w-full md:w-1/2 p-2">
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" @input="folderUpdated" />
</div>
<div class="w-full md:w-1/2 p-2">
<ui-text-input-with-label v-model="fullPath" label="Podcast Path" readonly />
</div>
</div>
</div>
@@ -66,7 +45,7 @@
<div class="px-4">
<ui-checkbox v-model="podcast.autoDownloadEpisodes" label="Auto Download Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
<ui-btn color="success" :disabled="disableSubmit" @click="submit">{{ buttonText }}</ui-btn>
<ui-btn color="success" @click="submit">Add Podcast</ui-btn>
</div>
</div>
</modals-modal>
@@ -104,8 +83,7 @@ export default {
itunesId: '',
itunesArtistId: '',
autoDownloadEpisodes: false
},
selectedEpisodes: {}
}
}
},
watch: {
@@ -127,16 +105,6 @@ export default {
this.$emit('input', val)
}
},
selectAll: {
get() {
return this.episodesSelected.length == this.episodes.length
},
set(val) {
for (const key in this.selectedEpisodes) {
this.selectedEpisodes[key] = val
}
}
},
title() {
return this._podcastData.title
},
@@ -166,17 +134,6 @@ export default {
if (!this.podcastFeedData) return []
return this.podcastFeedData.episodes || []
},
episodesSelected() {
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
},
disableSubmit() {
return !this.episodesSelected.length && !this.podcast.autoDownloadEpisodes
},
buttonText() {
if (!this.episodesSelected.length) return 'Add Podcast'
if (this.episodesSelected.length == 1) return 'Add Podcast & Download 1 Episode'
return `Add Podcast & Download ${this.episodesSelected.length} Episodes`
},
selectedFolder() {
return this.folders.find((f) => f.id === this.selectedFolderId)
},
@@ -196,15 +153,7 @@ export default {
}
this.fullPath = Path.join(this.selectedFolderPath, this.podcast.title)
},
toggleSelectEpisode(index) {
this.selectedEpisodes[String(index)] = !this.selectedEpisodes[String(index)]
},
submit() {
var episodesToDownload = []
if (this.episodesSelected.length) {
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
}
const podcastPayload = {
path: this.fullPath,
folderId: this.selectedFolderId,
@@ -224,8 +173,7 @@ export default {
language: this.podcast.language
},
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
},
episodesToDownload
}
}
console.log('Podcast payload', podcastPayload)
@@ -260,10 +208,6 @@ export default {
this.podcast.language = this._podcastData.language || ''
this.podcast.autoDownloadEpisodes = false
for (let i = 0; i < this.episodes.length; i++) {
this.$set(this.selectedEpisodes, String(i), false)
}
if (this.folderItems[0]) {
this.selectedFolderId = this.folderItems[0].value
this.folderUpdated()

View File

@@ -8,7 +8,7 @@
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
<nuxt-link v-if="userCanUpdate && !isFile" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
<ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
@@ -59,7 +59,8 @@ export default {
type: Array,
default: () => []
},
libraryItemId: String
libraryItemId: String,
isFile: Boolean
},
data() {
return {

View File

@@ -1,5 +1,5 @@
<template>
<div class="w-full my-4" @mousedown.prevent @mouseup.prevent>
<div class="w-full my-4">
<div class="w-full bg-primary px-6 py-1 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-4">{{ title }}</p>
<span class="bg-black-400 rounded-xl py-0.5 px-2 text-sm font-mono">{{ files.length }}</span>

View File

@@ -14,12 +14,12 @@
</div>
</div>
</div>
<div class="w-80 h-full px-2 flex items-center">
<div>
<div class="flex-grow max-w-md h-full px-2 flex items-center">
<div class="truncate px-1">
<nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline">{{ bookTitle }}</nuxt-link>
</div>
</div>
<div class="flex-grow flex items-center">
<div class="w-20 flex items-center">
<p class="font-mono text-sm">{{ bookDuration }}</p>
</div>

View File

@@ -16,8 +16,6 @@
</template>
</transition-group>
</draggable>
<modals-podcast-edit-episode v-model="showEditEpisodeModal" :library-item="libraryItem" :episode="selectedEpisode" />
</div>
</template>
@@ -40,8 +38,6 @@ export default {
sortDesc: true,
drag: false,
episodesCopy: [],
selectedEpisode: null,
showEditEpisodeModal: false,
orderChanged: false,
savingOrder: false
}
@@ -97,8 +93,9 @@ export default {
return false
},
editEpisode(episode) {
this.selectedEpisode = episode
this.showEditEpisodeModal = true
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
},
draggableUpdate() {
this.orderChanged = this.checkHasOrderChanged()

View File

@@ -16,7 +16,7 @@
</div>
</div>
<tables-tracks-table :title="`Audiobook Tracks`" :tracks="media.tracks" :library-item-id="libraryItemId" class="mt-6" />
<tables-tracks-table :title="`Audiobook Tracks`" :tracks="media.tracks" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
</div>
</template>
@@ -27,7 +27,8 @@ export default {
media: {
type: Object,
default: () => {}
}
},
isFile: Boolean
},
data() {
return {}

View File

@@ -10,6 +10,7 @@
<modals-user-collections-modal />
<modals-edit-collection-modal />
<modals-bookshelf-texture-modal />
<modals-podcast-edit-episode />
<readers-reader />
</div>
</template>
@@ -484,6 +485,25 @@ export default {
},
resize() {
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
},
checkVersionUpdate() {
// Version check is only run if time since last check was 5 minutes
const VERSION_CHECK_BUFF = 1000 * 60 * 5 // 5 minutes
var lastVerCheck = localStorage.getItem('lastVerCheck') || 0
if (Date.now() - Number(lastVerCheck) > VERSION_CHECK_BUFF) {
this.$store
.dispatch('checkForUpdate')
.then((res) => {
localStorage.setItem('lastVerCheck', Date.now())
if (res && res.hasUpdate) this.showUpdateToast(res)
})
.catch((err) => console.error(err))
if (this.$route.query.error) {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)
}
}
}
},
beforeMount() {
@@ -502,17 +522,7 @@ export default {
this.$store.commit('setExperimentalFeatures', true)
}
this.$store
.dispatch('checkForUpdate')
.then((res) => {
if (res && res.hasUpdate) this.showUpdateToast(res)
})
.catch((err) => console.error(err))
if (this.$route.query.error) {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)
}
this.checkVersionUpdate()
},
beforeDestroy() {
window.removeEventListener('resize', this.resize)

View File

@@ -174,8 +174,8 @@ export default {
if (mediaType === 'podcast') return this.cleanPodcast(item, index)
return this.cleanBook(item, index)
},
async getItemsFromDataTransferItems(items, mediaType) {
var files = await this.getFilesDropped(items)
async getItemsFromDataTransferItems(dataTransferItems, mediaType) {
var files = await this.getFilesDropped(dataTransferItems)
if (!files || !files.length) return { error: 'No files found ' }
var itemData = this.fileTreeToItems(files, mediaType)
if (!itemData.items.length && !itemData.ignoredFiles.length) {
@@ -189,7 +189,7 @@ export default {
if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles)
}
return ab.itemFiles.length
}).map(ab => this.cleanItem(ab, index++))
}).map(ab => this.cleanItem(ab, mediaType, index++))
return {
items,
ignoredFiles

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.0.1",
"version": "2.0.5",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {

View File

@@ -107,6 +107,10 @@ export default {
console.error('Invalid media type')
return redirect('/')
}
if (libraryItem.isFile) {
console.error('No need to edit library item that is 1 file...')
return redirect('/')
}
return {
libraryItem,
files: libraryItem.media.audioFiles ? libraryItem.media.audioFiles.map((af) => ({ ...af, include: !af.exclude })) : []

View File

@@ -20,6 +20,14 @@
<p class="pl-4 text-lg">Number of backups to keep</p>
</div>
<div class="flex items-center py-2">
<ui-text-input type="number" v-model="maxBackupSize" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateBackupsSettings" />
<ui-tooltip :text="maxBackupSizeTooltip">
<p class="pl-4 text-lg">Maximum backup size (in GB) <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
<tables-backups-table />
</div>
</div>
@@ -32,6 +40,7 @@ export default {
updatingServerSettings: false,
dailyBackups: true,
backupsToKeep: 2,
maxBackupSize: 1,
newServerSettings: {}
}
},
@@ -47,19 +56,27 @@ export default {
dailyBackupsTooltip() {
return 'Runs at 1am every day (your server time). Saved in /metadata/backups.'
},
maxBackupSizeTooltip() {
return 'As a safeguard against misconfiguration, backups will fail if they exceed the configured size.'
},
serverSettings() {
return this.$store.state.serverSettings
}
},
methods: {
updateBackupsSettings() {
if (isNaN(this.maxBackupSize) || this.maxBackupSize <= 0) {
this.$toast.error('Invalid maximum backup size')
return
}
if (isNaN(this.backupsToKeep) || this.backupsToKeep <= 0 || this.backupsToKeep > 99) {
this.$toast.error('Invalid number of backups to keep')
return
}
var updatePayload = {
backupSchedule: this.dailyBackups ? '0 1 * * *' : false,
backupsToKeep: Number(this.backupsToKeep)
backupsToKeep: Number(this.backupsToKeep),
maxBackupSize: Number(this.maxBackupSize)
}
this.updateServerSettings(updatePayload)
},
@@ -81,6 +98,7 @@ export default {
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
this.dailyBackups = !!this.newServerSettings.backupSchedule
this.maxBackupSize = this.newServerSettings.maxBackupSize || 1
}
},
mounted() {

View File

@@ -95,6 +95,21 @@
<p class="ml-4">Book has no audio tracks but has valid ebook files. The e-reader is experimental and can be turned on in config.</p>
</div>
<div v-if="episodeDownloadsQueued.length" class="px-4 py-2 mt-4 bg-info bg-opacity-40 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
<div class="flex items-center">
<p class="text-sm py-1">{{ episodeDownloadsQueued.length }} Episode{{ episodeDownloadsQueued.length === 1 ? '' : 's' }} queued for download</p>
<span class="material-icons hover:text-error text-xl ml-3 cursor-pointer" @click="clearDownloadQueue">close</span>
</div>
</div>
<div v-if="episodesDownloading.length" class="px-4 py-2 mt-4 bg-success bg-opacity-20 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
<div v-for="episode in episodesDownloading" :key="episode.id" class="flex items-center">
<widgets-loading-spinner />
<p class="text-sm py-1 pl-4">Downloading episode "{{ episode.episodeDisplayTitle }}"</p>
</div>
</div>
<!-- Progress -->
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
<p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
@@ -144,7 +159,13 @@
<p class="text-base text-gray-100 whitespace-pre-line">{{ description }}</p>
</div>
<widgets-audiobook-data v-if="tracks.length" :library-item-id="libraryItemId" :media="media" />
<div v-if="invalidAudioFiles.length" class="bg-error border-red-800 shadow-md p-4">
<p class="text-sm mb-2">Invalid audio files</p>
<p v-for="audioFile in invalidAudioFiles" :key="audioFile.id" class="text-xs pl-2">- {{ audioFile.metadata.filename }} ({{ audioFile.error }})</p>
</div>
<widgets-audiobook-data v-if="tracks.length" :library-item-id="libraryItemId" :is-file="isFile" :media="media" />
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" />
@@ -163,7 +184,9 @@ export default {
if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`)
}
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=authors`).catch((error) => {
// Include episode downloads for podcasts
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=authors,downloads`).catch((error) => {
console.error('Failed', error)
return false
})
@@ -181,10 +204,15 @@ export default {
isProcessingReadUpdate: false,
fetchingRSSFeed: false,
showPodcastEpisodeFeed: false,
podcastFeedEpisodes: []
podcastFeedEpisodes: [],
episodesDownloading: [],
episodeDownloadsQueued: []
}
},
computed: {
isFile() {
return this.libraryItem.isFile
},
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
},
@@ -209,6 +237,10 @@ export default {
isInvalid() {
return this.libraryItem.isInvalid
},
invalidAudioFiles() {
if (this.isPodcast) return []
return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
},
showPlayButton() {
if (this.isMissing || this.isInvalid) return false
if (this.isPodcast) return this.podcastEpisodes.length
@@ -333,6 +365,20 @@ export default {
}
},
methods: {
clearDownloadQueue() {
if (confirm('Are you sure you want to clear episode download queue?')) {
this.$axios
.$get(`/api/podcasts/${this.libraryItemId}/clear-queue`)
.then(() => {
this.$toast.success('Episode download queue cleared')
this.episodeDownloadQueued = []
})
.catch((error) => {
console.error('Failed to clear queue', error)
this.$toast.error('Failed to clear queue')
})
}
},
async findEpisodesClick() {
if (!this.mediaMetadata.feedUrl) {
return this.$toast.error('Podcast does not have an RSS Feed')
@@ -425,17 +471,44 @@ export default {
collectionsClick() {
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShowUserCollectionsModal', true)
},
episodeDownloadQueued(episodeDownload) {
if (episodeDownload.libraryItemId === this.libraryItemId) {
this.episodeDownloadsQueued.push(episodeDownload)
}
},
episodeDownloadStarted(episodeDownload) {
if (episodeDownload.libraryItemId === this.libraryItemId) {
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
this.episodesDownloading.push(episodeDownload)
}
},
episodeDownloadFinished(episodeDownload) {
if (episodeDownload.libraryItemId === this.libraryItemId) {
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
}
}
},
mounted() {
if (this.libraryItem.episodesDownloading) {
this.episodeDownloadsQueued = this.libraryItem.episodesDownloading || []
}
// use this items library id as the current
if (this.libraryId) {
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
}
this.$root.socket.on('item_updated', this.libraryItemUpdated)
this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
},
beforeDestroy() {
this.$root.socket.off('item_updated', this.libraryItemUpdated)
this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
}
}
</script>

View File

@@ -24,7 +24,7 @@ export default {
return redirect(`/library/${libraryId}`)
}
var series = await app.$axios.$get(`/api/series/${params.id}`).catch((error) => {
var series = await app.$axios.$get(`/api/series/${params.id}?include=progress`).catch((error) => {
console.error('Failed', error)
return false
})
@@ -33,7 +33,7 @@ export default {
}
return {
series: series.name,
series,
seriesId: params.id
}
},

View File

@@ -195,7 +195,8 @@ export default {
e.preventDefault()
this.isDragging = false
var items = e.dataTransfer.items || []
var itemResults = await this.uploadHelpers.getItemsFromDrop(items)
var itemResults = await this.uploadHelpers.getItemsFromDrop(items, this.selectedLibraryMediaType)
this.setResults(itemResults)
},
inputChanged(e) {

View File

@@ -10,6 +10,7 @@ export default class PlayerHandler {
this.displayTitle = null
this.displayAuthor = null
this.playWhenReady = false
this.initialPlaybackRate = 1
this.player = null
this.playerState = 'IDLE'
this.isHlsTranscode = false
@@ -46,12 +47,13 @@ export default class PlayerHandler {
return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId)
}
load(libraryItem, episodeId, playWhenReady) {
load(libraryItem, episodeId, playWhenReady, playbackRate) {
if (!this.player) this.switchPlayer()
this.libraryItem = libraryItem
this.episodeId = episodeId
this.playWhenReady = playWhenReady
this.initialPlaybackRate = playbackRate
this.prepare()
}
@@ -113,6 +115,7 @@ export default class PlayerHandler {
console.log('[PlayerHandler] Player state change', state)
this.playerState = state
if (this.playerState === 'PLAYING') {
this.setPlaybackRate(this.initialPlaybackRate)
this.startPlayInterval()
} else {
this.stopPlayInterval()
@@ -151,11 +154,12 @@ export default class PlayerHandler {
this.prepareSession(session)
}
prepareOpenSession(session) { // Session opened on init socket
prepareOpenSession(session, playbackRate) { // Session opened on init socket
if (!this.player) this.switchPlayer()
this.libraryItem = session.libraryItem
this.playWhenReady = false
this.initialPlaybackRate = playbackRate
this.prepareSession(session)
}
@@ -292,6 +296,7 @@ export default class PlayerHandler {
}
setPlaybackRate(playbackRate) {
this.initialPlaybackRate = playbackRate // Might be loaded from settings before player is started
if (!this.player) return
this.player.setPlaybackRate(playbackRate)
}

View File

@@ -33,11 +33,12 @@ export async function checkForUpdate() {
return
}
var largestVer = null
await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/tags`).then((res) => {
var tags = res.data
if (tags && tags.length) {
tags.forEach((tag) => {
var verObj = parseSemver(tag.name)
await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/releases`).then((res) => {
var releases = res.data
if (releases && releases.length) {
releases.forEach((release) => {
var tagName = release.tag_name
var verObj = parseSemver(tagName)
if (verObj) {
if (!largestVer || largestVer.total < verObj.total) {
largestVer = verObj
@@ -50,6 +51,7 @@ export async function checkForUpdate() {
console.error('No valid version tags to compare with')
return
}
return {
hasUpdate: largestVer.total > currVerObj.total,
latestVersion: largestVer.version,

View File

@@ -5,6 +5,8 @@ export const state = () => ({
showBatchUserCollectionModal: false,
showUserCollectionsModal: false,
showEditCollectionModal: false,
showEditPodcastEpisode: false,
selectedEpisode: null,
selectedCollection: null,
showBookshelfTextureModal: false,
isCasting: false, // Actively casting
@@ -46,10 +48,16 @@ export const mutations = {
setShowEditCollectionModal(state, val) {
state.showEditCollectionModal = val
},
setShowEditPodcastEpisodeModal(state, val) {
state.showEditPodcastEpisode = val
},
setEditCollection(state, collection) {
state.selectedCollection = collection
state.showEditCollectionModal = true
},
setSelectedEpisode(state, episode) {
state.selectedEpisode = episode
},
setShowBookshelfTextureModal(state, val) {
state.showBookshelfTextureModal = val
},

View File

@@ -6,6 +6,7 @@ module.exports = {
safelist: [
'bg-success',
'bg-red-600',
'text-green-500',
'py-1.5',
'bg-info'
]

View File

@@ -3,10 +3,10 @@ version: "3.7"
services:
audiobookshelf:
image: advplyr/audiobookshelf
image: ghcr.io/advplyr/audiobookshelf
ports:
- 13378:80
volumes:
- /audiobooks:/audiobooks
- /metadata:/metadata
- /config:/config
- /config:/config

View File

@@ -1,7 +1,7 @@
<?xml version="1.0"?>
<Container version="2">
<Name>audiobookshelf</Name>
<Repository>advplyr/audiobookshelf</Repository>
<Repository>ghcr.io/advplyr/audiobookshelf</Repository>
<Registry>https://hub.docker.com/r/advplyr/audiobookshelf/</Registry>
<Network>bridge</Network>
<MyIP/>
@@ -9,8 +9,8 @@
<Privileged>false</Privileged>
<Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support>
<Project>https://github.com/advplyr/audiobookshelf</Project>
<Overview>**(Android app is live)** Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free &amp; open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Overview>
<Category>MediaApp:Books MediaServer:Books</Category>
<Overview>Self-hosted audiobook and podcast server and web app. Supports multi-user w/ permissions and keeps progress in sync across devices. Free &amp; open source mobile apps. Consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Overview>
<Category>MediaApp:Books MediaServer:Books MediaApp:Other MediaServer:Other</Category>
<WebUI>http://[IP]:[PORT:80]</WebUI>
<TemplateURL>https://raw.githubusercontent.com/advplyr/docker-templates/master/audiobookshelf.xml</TemplateURL>
<Icon>https://github.com/advplyr/audiobookshelf/raw/master/client/static/Logo.png</Icon>
@@ -20,7 +20,7 @@
<DateInstalled>1629238508</DateInstalled>
<DonateText/>
<DonateLink/>
<Description>Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free &amp; open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Description>
<Description>Self-hosted audiobook and podcast server and web app. Supports multi-user w/ permissions and keeps progress in sync across devices. Free &amp; open source mobile apps. Consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Description>
<Networking>
<Mode>bridge</Mode>
<Publish>
@@ -65,4 +65,4 @@
<Config Name="Config" Target="/config" Default="/mnt/user/appdata/audiobookshelf/config/" Mode="rw" Description="Container Path: /config" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/audiobookshelf/config/</Config>
<Config Name="Metadata" Target="/metadata" Default="/mnt/user/appdata/audiobookshelf/metadata/" Mode="rw" Description="Container Path: /metadata" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/audiobookshelf/metadata/</Config>
<Config Name="Web UI Port" Target="80" Default="13378" Mode="tcp" Description="Container Port: 80" Type="Port" Display="always" Required="false" Mask="false">13378</Config>
</Container>
</Container>

View File

@@ -1 +1,49 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 6702.73 1277.37"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:url(#linear-gradient);}.cls-3{font-size:800px;fill:#c9c9c9;font-family:GentiumBookBasic, Gentium Book Basic;}.cls-4{font-size:420px;fill:#474747;font-family:GentiumBasic, Gentium Basic;}</style><linearGradient id="linear-gradient" x1="617.37" y1="20.7" x2="617.37" y2="1216.56" gradientUnits="userSpaceOnUse"><stop offset="0.32" stop-color="#cd9d49"/><stop offset="0.99" stop-color="#875d27"/></linearGradient></defs><title>bgAsset 6</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_2-2" data-name="Layer 2"><g id="Layer_4" data-name="Layer 4"><g id="Layer_5" data-name="Layer 5"><circle class="cls-1" cx="618.63" cy="618.63" r="618.63"/></g><circle class="cls-2" cx="617.37" cy="618.63" r="597.93"/></g><path class="cls-1" d="M1005.57,574.08c-4.84-4-12.37-10-22.58-17v-79.2c0-201.93-163.69-365.63-365.62-365.63h0c-201.93,0-365.63,163.7-365.63,365.63v79.2c-10.21,7-17.74,13-22.58,17A18.15,18.15,0,0,0,222.63,588v94.89a18.15,18.15,0,0,0,6.53,14c11.29,9.4,37.19,29.1,77.52,49.31v9.22c0,24.88,16,45,35.84,45h0c19.79,0,35.84-20.16,35.84-45V527.83c0-24.87-16.05-45-35.84-45h0c-19,0-34.48,18.51-35.75,41.94l-.09,0v-46.9c0-171.59,139.1-310.69,310.69-310.69h0c171.58,0,310.68,139.1,310.68,310.69v46.9l-.08,0c-1.27-23.43-16.79-41.94-35.76-41.94h0c-19.79,0-35.83,20.17-35.83,45V755.4c0,24.88,16,45,35.83,45h0c19.8,0,35.84-20.16,35.84-45v-9.22c40.33-20.21,66.24-39.91,77.52-49.31a18.15,18.15,0,0,0,6.53-14V588A18.15,18.15,0,0,0,1005.57,574.08Z"/><path class="cls-1" d="M489.87,969.71a43.31,43.31,0,0,0,43.3-43.3V441.64a43.3,43.3,0,0,0-43.3-43.29H445.15a43.3,43.3,0,0,0-43.3,43.29V926.41a43.31,43.31,0,0,0,43.3,43.3Zm-71.69-455.1h98.67v10.31H418.18Z"/><path class="cls-1" d="M639.73,969.71A43.3,43.3,0,0,0,683,926.41V441.64a43.29,43.29,0,0,0-43.29-43.29H595a43.29,43.29,0,0,0-43.29,43.29V926.41A43.3,43.3,0,0,0,595,969.71ZM568,514.61H666.7v10.31H568Z"/><path class="cls-1" d="M789.59,969.71a43.3,43.3,0,0,0,43.29-43.3V441.64a43.29,43.29,0,0,0-43.29-43.29H744.86a43.3,43.3,0,0,0-43.3,43.29V926.41a43.31,43.31,0,0,0,43.3,43.3Zm-71.7-455.1h98.67v10.31H717.89Z"/><rect class="cls-1" x="294.5" y="984.69" width="645.74" height="65.25" rx="32.63"/></g><g id="Layer_6" data-name="Layer 6"><text class="cls-3" transform="translate(1492.27 670.42)">audiobookshelf</text><text class="cls-4" transform="translate(1492.27 1128.69)">self-hosted audiobook server</text></g></g></svg>
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 6702.7 1277.4" style="enable-background:new 0 0 6702.7 1277.4;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:url(#SVGID_1_);}
.st2{fill:#C9C9C9;}
.st3{font-family:'GentiumBookBasic';}
.st4{font-size:800px;}
.st5{fill:#474747;}
.st6{font-family:'GentiumBasic';}
.st7{font-size:305px;}
</style>
<title>bgAsset 6</title>
<g id="Layer_2_1_">
<g id="Layer_2-2">
<g id="Layer_4">
<g id="Layer_5">
<circle class="st0" cx="618.6" cy="618.6" r="618.6"/>
</g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="617.37" y1="1257.3" x2="617.37" y2="61.4399" gradientTransform="matrix(1 0 0 -1 0 1278)">
<stop offset="0.32" style="stop-color:#CD9D49"/>
<stop offset="0.99" style="stop-color:#875D27"/>
</linearGradient>
<circle class="st1" cx="617.4" cy="618.6" r="597.9"/>
</g>
<path class="st0" d="M1005.6,574.1c-4.8-4-12.4-10-22.6-17v-79.2c0-201.9-163.7-365.6-365.6-365.6l0,0
c-201.9,0-365.6,163.7-365.6,365.6v79.2c-10.2,7-17.7,13-22.6,17c-4.1,3.4-6.5,8.5-6.5,13.9v94.9c0,5.4,2.4,10.5,6.5,14
c11.3,9.4,37.2,29.1,77.5,49.3v9.2c0,24.9,16,45,35.8,45l0,0c19.8,0,35.8-20.2,35.8-45V527.8c0-24.9-16-45-35.8-45l0,0
c-19,0-34.5,18.5-35.8,41.9h-0.1v-46.9c0-171.6,139.1-310.7,310.7-310.7l0,0C789,167.2,928,306.3,928,477.9v46.9H928
c-1.3-23.4-16.8-41.9-35.8-41.9l0,0c-19.8,0-35.8,20.2-35.8,45v227.6c0,24.9,16,45,35.8,45l0,0c19.8,0,35.8-20.2,35.8-45v-9.2
c40.3-20.2,66.2-39.9,77.5-49.3c4.2-3.5,6.5-8.6,6.5-14V588C1012.1,582.6,1009.7,577.5,1005.6,574.1z"/>
<path class="st0" d="M489.9,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3h-44.7
c-23.9,0-43.3,19.4-43.3,43.3v484.8c0,23.9,19.4,43.3,43.3,43.3L489.9,969.7z M418.2,514.6h98.7v10.3h-98.7V514.6z"/>
<path class="st0" d="M639.7,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3H595c-23.9,0-43.3,19.4-43.3,43.3
v484.8c0,23.9,19.4,43.3,43.3,43.3H639.7z M568,514.6h98.7v10.3H568V514.6z"/>
<path class="st0" d="M789.6,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3h-44.7
c-23.9,0-43.3,19.4-43.3,43.3v484.8c0,23.9,19.4,43.3,43.3,43.3L789.6,969.7z M717.9,514.6h98.7v10.3h-98.7V514.6z"/>
<path class="st0" d="M327.1,984.7h580.5c18,0,32.6,14.6,32.6,32.6v0c0,18-14.6,32.6-32.6,32.6H327.1c-18,0-32.6-14.6-32.6-32.6v0
C294.5,999.3,309.1,984.7,327.1,984.7z"/>
</g>
<g id="Layer_6">
<text transform="matrix(1 0 0 1 1492.27 735.42)" class="st2 st3 st4">audiobookshelf</text>
<text id="self-hosted_audiobook_and_podcast_server" transform="matrix(1 0 0 1 1492.27 1103.6899)" class="st5 st6 st7">self-hosted audiobook and podcast server</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.0.1",
"version": "2.0.5",
"description": "Self-hosted audiobook server for managing and playing audiobooks",
"main": "index.js",
"scripts": {

View File

@@ -14,20 +14,23 @@
# About
Audiobookshelf is a self-hosted audiobook server for managing and playing your audiobooks.
Audiobookshelf is a self-hosted audiobook and podcast server.
### Features
* Fully **open-source**, including the [android & iOS app](https://github.com/advplyr/audiobookshelf-app) *(in beta)*
* Stream all audiobook formats on the fly
* Stream all audio formats on the fly
* Search and add podcasts to download episodes w/ auto-download
* Multi-user support w/ custom permissions
* Keeps progress per user and syncs across devices
* Auto-detects library updates, no need to re-scan
* Upload audiobooks w/ bulk upload drag and drop folders
* Upload books and podcasts w/ bulk upload drag and drop folders
* Backup your metadata + automated daily backups
* Progressive Web App (PWA)
* Chromecast support on the web app
* Chromecast support on the web app and android app
* Fetch metadata and cover art from several sources
* Basic ebook support and e-reader *(experimental)*
* Merge your audio files into a single m4b w/ metadata and embedded cover *(experimental)*
Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/audiobookshelf/issues/new/choose)
@@ -71,7 +74,15 @@ docker run -d \
-v </path/to/config>:/config \
-v </path/to/metadata>:/metadata \
--name audiobookshelf \
--rm advplyr/audiobookshelf
ghcr.io/advplyr/audiobookshelf
```
### Docker Update
```bash
docker stop audiobookshelf
docker pull ghcr.io/advplyr/audiobookshelf
docker start audiobookshelf
```
### Running with Docker Compose
@@ -80,7 +91,7 @@ docker run -d \
### docker-compose.yml ###
services:
audiobookshelf:
image: advplyr/audiobookshelf
image: ghcr.io/advplyr/audiobookshelf
ports:
- 13378:80
volumes:

View File

@@ -316,6 +316,39 @@ class Db {
})
}
async bulkInsertEntities(entityName, entities, batchSize = 500) {
// Group entities in batches of size batchSize
var entityBatches = []
var batch = []
var index = 0
entities.forEach((ent) => {
batch.push(ent)
index++
if (index >= batchSize) {
entityBatches.push(batch)
index = 0
batch = []
}
})
if (batch.length) entityBatches.push(batch)
Logger.info(`[Db] bulkInsertEntities: ${entities.length} ${entityName} to ${entityBatches.length} batches of max size ${batchSize}`)
// Start inserting batches
var batchIndex = 1
for (const entityBatch of entityBatches) {
Logger.info(`[Db] bulkInsertEntities: Start inserting batch ${batchIndex} of ${entityBatch.length} for ${entityName}`)
var success = await this.insertEntities(entityName, entityBatch)
if (success) {
Logger.info(`[Db] bulkInsertEntities: Success inserting batch ${batchIndex} for ${entityName}`)
} else {
Logger.info(`[Db] bulkInsertEntities: Failed inserting batch ${batchIndex} for ${entityName}`)
}
batchIndex++
}
return true
}
updateEntities(entityName, entities) {
var entityDb = this.getEntityDb(entityName)
@@ -378,7 +411,9 @@ class Db {
removeEntity(entityName, entityId) {
var entityDb = this.getEntityDb(entityName)
return entityDb.delete((record) => record.id === entityId).then((results) => {
return entityDb.delete((record) => {
return record.id === entityId
}).then((results) => {
Logger.debug(`[DB] Deleted entity ${entityName}: ${results.deleted}`)
var arrayKey = this.getEntityArrayKey(entityName)
if (this[arrayKey]) {

View File

@@ -10,6 +10,7 @@ const { version } = require('../package.json')
// Utils
const dbMigration = require('./utils/dbMigration')
const filePerms = require('./utils/filePerms')
const Logger = require('./Logger')
// Classes
@@ -46,9 +47,18 @@ class Server {
global.MetadataPath = global.MetadataPath.replace(/\\/g, '/')
}
fs.ensureDirSync(global.ConfigPath, 0o774)
fs.ensureDirSync(global.MetadataPath, 0o774)
fs.ensureDirSync(global.AudiobookPath, 0o774)
if (!fs.pathExistsSync(global.ConfigPath)) {
fs.mkdirSync(global.ConfigPath)
filePerms.setDefaultDirSync(global.ConfigPath, false)
}
if (!fs.pathExistsSync(global.MetadataPath)) {
fs.mkdirSync(global.MetadataPath)
filePerms.setDefaultDirSync(global.MetadataPath, false)
}
if (!fs.pathExistsSync(global.AudiobookPath)) {
fs.mkdirSync(global.AudiobookPath)
filePerms.setDefaultDirSync(global.AudiobookPath, false)
}
this.db = new Db()
this.watcher = new Watcher()
@@ -153,8 +163,8 @@ class Server {
app.use(this.auth.cors)
app.use(fileUpload())
app.use(express.urlencoded({ extended: true, limit: "3mb" }));
app.use(express.json({ limit: "3mb" }))
app.use(express.urlencoded({ extended: true, limit: "5mb" }));
app.use(express.json({ limit: "5mb" }))
// Static path to generated nuxt
const distPath = Path.join(global.appRoot, '/client/dist')

View File

@@ -176,12 +176,29 @@ class LibraryController {
sortKey += 'IgnorePrefix'
}
// Start sort
var direction = payload.sortDesc ? 'desc' : 'asc'
libraryItems = naturalSort(libraryItems)[direction]((li) => {
var sortArray = [
{
[direction]: (li) => {
// Supports dot notation strings i.e. "media.metadata.title"
return sortKey.split('.').reduce((a, b) => a[b], li)
}
}
]
// Supports dot notation strings i.e. "media.metadata.title"
return sortKey.split('.').reduce((a, b) => a[b], li)
})
// Secondary sort when sorting by book author use series sort title
if (payload.mediaType === 'book' && payload.sortBy.includes('author')) {
sortArray.push({
asc: (li) => {
if (li.media.metadata.series && li.media.metadata.series.length) {
return li.media.metadata.getSeriesSortTitle(li.media.metadata.series[0])
}
return null
}
})
}
libraryItems = naturalSort(libraryItems).by(sortArray)
}
// TODO: Potentially implement collapse series again
@@ -209,6 +226,22 @@ class LibraryController {
res.json(payload)
}
async removeLibraryItemsWithIssues(req, res) {
var libraryItemsWithIssues = req.libraryItems.filter(li => li.hasIssues)
if (!libraryItemsWithIssues.length) {
Logger.warn(`[LibraryController] No library items have issues`)
return res.sendStatus(200)
}
Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`)
for (const libraryItem of libraryItemsWithIssues) {
Logger.info(`[LibraryController] Removing library item "${libraryItem.media.metadata.title}"`)
await this.handleDeleteLibraryItem(libraryItem)
}
res.sendStatus(200)
}
// api/libraries/:id/series
async getAllSeriesForLibrary(req, res) {
var libraryItems = req.libraryItems
@@ -276,19 +309,32 @@ class LibraryController {
}
// api/libraries/:id/personalized
// New and improved personalized call only loops through library items once
async getLibraryUserPersonalizedOptimal(req, res) {
const mediaType = req.library.mediaType
const libraryItems = req.libraryItems
const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 10
const categories = libraryHelpers.buildPersonalizedShelves(req.user, libraryItems, mediaType, this.db.series, this.db.authors, limitPerShelf)
res.json(categories)
}
// TODO: Remove old personalized function with all its helper functions
// old personalized function looped through the library items many times
// api/libraries/:id/personalized-old
async getLibraryUserPersonalized(req, res) {
var mediaType = req.library.mediaType
var isPodcastLibrary = mediaType == 'podcast'
var libraryItems = req.libraryItems
var limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
var minified = req.query.minified === '1'
var minified = req.query.minified == '1'
var itemsWithUserProgress = libraryHelpers.getItemsWithUserProgress(req.user, libraryItems)
var itemsWithUserProgress = libraryHelpers.getMediaProgressWithItems(req.user, libraryItems)
var categories = [
{
id: 'continue-listening',
label: 'Continue Listening',
type: req.library.mediaType,
type: isPodcastLibrary ? 'episode' : req.library.mediaType,
entities: libraryHelpers.getItemsMostRecentlyListened(itemsWithUserProgress, limitPerShelf, minified)
},
{
@@ -300,14 +346,13 @@ class LibraryController {
{
id: 'listen-again',
label: 'Listen Again',
type: req.library.mediaType,
type: isPodcastLibrary ? 'episode' : req.library.mediaType,
entities: libraryHelpers.getItemsMostRecentlyFinished(itemsWithUserProgress, limitPerShelf, minified)
}
].filter(cats => { // Remove categories with no items
return cats.entities.length
})
// New Series section
// TODO: optimize and move to libraryHelpers
if (!isPodcastLibrary) {
@@ -355,57 +400,17 @@ class LibraryController {
entities: authors
})
}
}
res.json(categories)
}
// LEGACY
// api/libraries/:id/books/categories
async getLibraryCategories(req, res) {
var library = req.library
var books = this.db.audiobooks.filter(ab => ab.libraryId === library.id)
var limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
var minified = req.query.minified === '1'
var booksWithUserAb = libraryHelpers.getItemsWithUserProgress(req.user, books)
var series = libraryHelpers.getSeriesFromBooks(books, minified)
var seriesWithUserAb = libraryHelpers.getSeriesWithProgressFromBooks(req.user, books)
var categories = [
{
id: 'continue-reading',
label: 'Continue Reading',
type: 'books',
entities: libraryHelpers.getBooksMostRecentlyRead(booksWithUserAb, limitPerShelf, minified)
},
{
id: 'continue-series',
label: 'Continue Series',
type: 'books',
entities: libraryHelpers.getBooksNextInSeries(seriesWithUserAb, limitPerShelf, minified)
},
{
id: 'recently-added',
label: 'Recently Added',
type: 'books',
entities: libraryHelpers.getBooksMostRecentlyAdded(books, limitPerShelf, minified)
},
{
id: 'read-again',
label: 'Read Again',
type: 'books',
entities: libraryHelpers.getBooksMostRecentlyFinished(booksWithUserAb, limitPerShelf, minified)
},
{
id: 'recent-series',
label: 'Recent Series',
type: 'series',
entities: libraryHelpers.getSeriesMostRecentlyAdded(series, limitPerShelf)
} else {
var episodesRecentlyAdded = libraryHelpers.getEpisodesRecentlyAdded(libraryItems, limitPerShelf, minified)
if (episodesRecentlyAdded.length) {
categories.splice(1, 0, {
id: 'episodes-recently-added',
label: 'Newest Episodes',
type: 'episode',
entities: episodesRecentlyAdded
})
}
].filter(cats => { // Remove categories with no items
return cats.entities.length
})
}
res.json(categories)
}
@@ -544,7 +549,8 @@ class LibraryController {
})
}
})
res.json(Object.values(authors))
res.json(naturalSort(Object.values(authors)).asc(au => au.name))
}
async matchAll(req, res) {

View File

@@ -11,6 +11,12 @@ class LibraryItemController {
if (req.query.expanded == 1) {
var item = req.libraryItem.toJSONExpanded()
// Include users media progress
if (includeEntities.includes('progress')) {
var episodeId = req.query.episode || null
item.userMediaProgress = req.user.getMediaProgress(item.id, episodeId)
}
if (item.mediaType == 'book') {
if (includeEntities.includes('authors')) {
item.media.metadata.authors = item.media.metadata.authors.map(au => {
@@ -21,6 +27,9 @@ class LibraryItemController {
}
}).filter(au => au)
}
} else if (includeEntities.includes('downloads')) {
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
item.episodesDownloading = downloadsInQueue.map(d => d.toJSONForClient())
}
return res.json(item)
@@ -333,6 +342,12 @@ class LibraryItemController {
Logger.error(`[LibraryItemController] Non-root user attempted to scan library item`, req.user)
return res.sendStatus(403)
}
if (req.libraryItem.isFile) {
Logger.error(`[LibraryItemController] Re-scanning file library items not yet supported`)
return res.sendStatus(500)
}
var result = await this.scanner.scanLibraryItemById(req.libraryItem.id)
res.json({
result: Object.keys(ScanResult).find(key => ScanResult[key] == result)

View File

@@ -29,8 +29,8 @@ class PodcastController {
var podcastPath = payload.path.replace(/\\/g, '/')
if (await fs.pathExists(podcastPath)) {
Logger.error(`[PodcastController] Attempt to create podcast when folder path already exists "${podcastPath}"`)
return res.status(400).send('Path already exists')
Logger.error(`[PodcastController] Podcast folder already exists "${podcastPath}"`)
return res.status(400).send('Podcast already exists')
}
var success = await fs.ensureDir(podcastPath).then(() => true).catch((error) => {
@@ -63,7 +63,8 @@ class PodcastController {
// Download and save cover image
if (payload.media.metadata.imageUrl) {
// TODO: Scan cover image to library files
var coverResponse = await this.coverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl)
// Podcast cover will always go into library item folder
var coverResponse = await this.coverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true)
if (coverResponse) {
if (coverResponse.error) {
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
@@ -101,6 +102,7 @@ class PodcastController {
Logger.error('Invalid podcast feed request response')
return res.status(500).send('Bad response from feed request')
}
Logger.debug(`[PdocastController] Podcast feed size ${(data.data.length / 1024 / 1024).toFixed(2)}MB`)
var payload = await parsePodcastRssFeedXml(data.data, includeRaw)
if (!payload) {
return res.status(500).send('Invalid podcast RSS feed')
@@ -128,6 +130,26 @@ class PodcastController {
})
}
clearEpisodeDownloadQueue(req, res) {
if (!req.user.canUpdate) {
Logger.error(`[PodcastController] User attempting to clear download queue without permission "${req.user.username}"`)
return res.sendStatus(500)
}
this.podcastManager.clearDownloadQueue(req.params.id)
res.sendStatus(200)
}
getEpisodeDownloads(req, res) {
var libraryItem = this.db.getLibraryItem(req.params.id)
if (!libraryItem || libraryItem.mediaType !== 'podcast') {
return res.sendStatus(404)
}
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
res.json({
downloads: downloadsInQueue.map(d => d.toJSONForClient())
})
}
async downloadEpisodes(req, res) {
var libraryItem = this.db.getLibraryItem(req.params.id)
if (!libraryItem || libraryItem.mediaType !== 'podcast') {

View File

@@ -4,7 +4,25 @@ class SeriesController {
constructor() { }
async findOne(req, res) {
return res.json(req.series)
var include = (req.query.include || '').split(',')
var seriesJson = req.series.toJSON()
// Add progress map with isFinished flag
if (include.includes('progress')) {
var libraryItemsInSeries = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(seriesJson.id))
var libraryItemsFinished = libraryItemsInSeries.filter(li => {
var mediaProgress = req.user.getMediaProgress(li.id)
return mediaProgress && mediaProgress.isFinished
})
seriesJson.progress = {
libraryItemIds: libraryItemsInSeries.map(li => li.id),
libraryItemIdsFinished: libraryItemsFinished.map(li => li.id),
isFinished: libraryItemsFinished.length === libraryItemsInSeries.length
}
}
return res.json(seriesJson)
}
async search(req, res) {

View File

@@ -23,9 +23,6 @@ class BackupManager {
this.scheduleTask = null
this.backups = []
// If backup exceeds this value it will be aborted
this.MaxBytesBeforeAbort = 1000000000 // ~ 1GB
}
get serverSettings() {
@@ -263,7 +260,8 @@ class BackupManager {
reject(err)
})
archive.on('progress', ({ fs: fsobj }) => {
if (fsobj.processedBytes > this.MaxBytesBeforeAbort) {
const maxBackupSizeInBytes = this.serverSettings.maxBackupSize * 1000 * 1000 * 1000
if (fsobj.processedBytes > maxBackupSizeInBytes) {
Logger.error(`[BackupManager] Archiver is too large - aborting to prevent endless loop, Bytes Processed: ${fsobj.processedBytes}`)
archive.abort()
setTimeout(() => {

View File

@@ -19,7 +19,7 @@ class CoverManager {
}
getCoverDirectory(libraryItem) {
if (this.db.serverSettings.storeCoverWithItem) {
if (this.db.serverSettings.storeCoverWithItem && !libraryItem.isFile) {
return libraryItem.path
} else {
return Path.posix.join(this.ItemMetadataPath, libraryItem.id)
@@ -113,15 +113,17 @@ class CoverManager {
Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.metadata.title}"`)
await filePerms.setDefault(coverFullPath)
libraryItem.updateMediaCover(coverFullPath)
return {
cover: coverFullPath
}
}
async downloadCoverFromUrl(libraryItem, url) {
async downloadCoverFromUrl(libraryItem, url, forceLibraryItemFolder = false) {
try {
var coverDirPath = this.getCoverDirectory(libraryItem)
// Force save cover with library item is used for adding new podcasts
var coverDirPath = forceLibraryItemFolder ? libraryItem.path : this.getCoverDirectory(libraryItem)
await fs.ensureDir(coverDirPath)
var temppath = Path.posix.join(coverDirPath, 'cover')
@@ -150,6 +152,7 @@ class CoverManager {
Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}" for "${libraryItem.media.metadata.title}"`)
await filePerms.setDefault(coverFullPath)
libraryItem.updateMediaCover(coverFullPath)
return {
cover: coverFullPath
@@ -249,6 +252,8 @@ class CoverManager {
var success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath)
if (success) {
await filePerms.setDefault(coverFilePath)
libraryItem.updateMediaCover(coverFilePath)
return coverFilePath
}

View File

@@ -78,6 +78,13 @@ class PlaybackSessionManager {
}
async startSession(user, libraryItem, episodeId, options) {
// Close any sessions already open for user
var userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id)
for (const session of userSessions) {
Logger.info(`[PlaybackSessionManager] startSession: Closing open session "${session.displayTitle}" for user "${user.username}"`)
await this.closeSession(user, session, null)
}
var shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options, episodeId))
var mediaPlayer = options.mediaPlayer || 'unknown'

View File

@@ -35,6 +35,23 @@ class PodcastManager {
}
}
getEpisodeDownloadsInQueue(libraryItemId) {
return this.downloadQueue.filter(d => d.libraryItemId === libraryItemId)
}
clearDownloadQueue(libraryItemId = null) {
if (!this.downloadQueue.length) return
if (!libraryItemId) {
Logger.info(`[PodcastManager] Clearing all downloads in queue (${this.downloadQueue.length})`)
this.downloadQueue = []
} else {
var itemDownloads = this.getEpisodeDownloadsInQueue(libraryItemId)
Logger.info(`[PodcastManager] Clearing downloads in queue for item "${libraryItemId}" (${itemDownloads.length})`)
this.downloadQueue = this.downloadQueue.filter(d => d.libraryItemId !== libraryItemId)
}
}
async downloadPodcastEpisodes(libraryItem, episodesToDownload) {
var index = libraryItem.media.episodes.length + 1
episodesToDownload.forEach((ep) => {
@@ -50,8 +67,11 @@ class PodcastManager {
async startPodcastEpisodeDownload(podcastEpisodeDownload) {
if (this.currentDownload) {
this.downloadQueue.push(podcastEpisodeDownload)
this.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient())
return
}
this.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient())
this.currentDownload = podcastEpisodeDownload
// Ignores all added files to this dir
@@ -65,11 +85,17 @@ class PodcastManager {
success = await this.scanAddPodcastEpisodeAudioFile()
if (!success) {
await fs.remove(this.currentDownload.targetPath)
this.currentDownload.setFinished(false)
} else {
Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.podcastEpisode.title}"`)
this.currentDownload.setFinished(true)
}
} else {
this.currentDownload.setFinished(false)
}
this.emitter('episode_download_finished', this.currentDownload.toJSONForClient())
this.watcher.removeIgnoreDir(this.currentDownload.libraryItem.path)
this.currentDownload = null
if (this.downloadQueue.length) {
@@ -96,6 +122,10 @@ class PodcastManager {
var podcastEpisode = this.currentDownload.podcastEpisode
podcastEpisode.audioFile = audioFile
libraryItem.media.addPodcastEpisode(podcastEpisode)
if (libraryItem.isInvalid) {
// First episode added to an empty podcast
libraryItem.isInvalid = false
}
libraryItem.libraryFiles.push(libraryFile)
libraryItem.updatedAt = Date.now()
await this.db.updateLibraryItem(libraryItem)

View File

@@ -18,6 +18,7 @@ class LibraryItem {
this.path = null
this.relPath = null
this.isFile = false
this.mtimeMs = null
this.ctimeMs = null
this.birthtimeMs = null
@@ -51,6 +52,7 @@ class LibraryItem {
this.folderId = libraryItem.folderId
this.path = libraryItem.path
this.relPath = libraryItem.relPath
this.isFile = !!libraryItem.isFile
this.mtimeMs = libraryItem.mtimeMs || 0
this.ctimeMs = libraryItem.ctimeMs || 0
this.birthtimeMs = libraryItem.birthtimeMs || 0
@@ -82,6 +84,7 @@ class LibraryItem {
folderId: this.folderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
@@ -105,6 +108,7 @@ class LibraryItem {
folderId: this.folderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
@@ -128,6 +132,7 @@ class LibraryItem {
folderId: this.folderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
@@ -460,7 +465,7 @@ class LibraryItem {
this.isSavingMetadata = true
var metadataPath = Path.join(global.MetadataPath, 'items', this.id)
if (global.ServerSettings.storeMetadataWithItem) {
if (global.ServerSettings.storeMetadataWithItem && !this.isFile) {
metadataPath = this.path
} else {
// Make sure metadata book dir exists

View File

@@ -10,22 +10,42 @@ class PodcastEpisodeDownload {
this.libraryItem = null
this.isDownloading = false
this.isFinished = false
this.failed = false
this.startedAt = null
this.createdAt = null
this.finishedAt = null
}
toJSONForClient() {
return {
id: this.id,
// podcastEpisode: this.podcastEpisode ? this.podcastEpisode.toJSON() : null,
episodeDisplayTitle: this.podcastEpisode ? this.podcastEpisode.bestFilename : null,
url: this.url,
libraryItemId: this.libraryItem ? this.libraryItem.id : null,
isDownloading: this.isDownloading,
isFinished: this.isFinished,
failed: this.failed,
startedAt: this.startedAt,
createdAt: this.createdAt,
finishedAt: this.finishedAt
}
}
get targetFilename() {
return sanitizeFilename(`${this.podcastEpisode.bestFilename}.mp3`)
}
get targetPath() {
return Path.join(this.libraryItem.path, this.targetFilename)
}
get targetRelPath() {
return this.targetFilename
}
get libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
}
setData(podcastEpisode, libraryItem) {
this.id = getId('epdl')
@@ -34,5 +54,11 @@ class PodcastEpisodeDownload {
this.libraryItem = libraryItem
this.createdAt = Date.now()
}
setFinished(success) {
this.finishedAt = Date.now()
this.isFinished = true
this.failed = !success
}
}
module.exports = PodcastEpisodeDownload

View File

@@ -1,3 +1,4 @@
const Logger = require('../../Logger')
const { getId } = require('../../utils/index')
class Author {
@@ -19,7 +20,7 @@ class Author {
construct(author) {
this.id = author.id
this.asin = author.asin
this.name = author.name
this.name = author.name || ''
this.description = author.description || null
this.imagePath = author.imagePath
this.relImagePath = author.relImagePath
@@ -81,6 +82,10 @@ class Author {
checkNameEquals(name) {
if (!name) return false
if (this.name === null) {
Logger.error(`[Author] Author name is null (${this.id})`)
return false
}
return this.name.toLowerCase() == name.toLowerCase().trim()
}
}

View File

@@ -65,6 +65,7 @@ class Book {
numAudioFiles: this.audioFiles.length,
numChapters: this.chapters.length,
numMissingParts: this.missingParts.length,
numInvalidAudioFiles: this.invalidAudioFiles.length,
duration: this.duration,
size: this.size,
ebookFormat: this.ebookFile ? this.ebookFile.ebookFormat : null
@@ -106,8 +107,11 @@ class Book {
get hasEmbeddedCoverArt() {
return this.audioFiles.some(af => af.embeddedCoverArt)
}
get invalidAudioFiles() {
return this.audioFiles.filter(af => af.invalid)
}
get hasIssues() {
return this.missingParts.length || this.audioFiles.some(af => af.invalid)
return this.missingParts.length || this.invalidAudioFiles.length
}
get tracks() {
var startOffset = 0

View File

@@ -257,5 +257,9 @@ class Podcast {
if (!episode) return 0
return episode.duration
}
getEpisode(episodeId) {
return this.episodes.find(ep => ep.id == episodeId)
}
}
module.exports = Podcast

View File

@@ -162,6 +162,11 @@ class BookMetadata {
if (!series) return null
return series.sequence || ''
}
getSeriesSortTitle(series) {
if (!series) return ''
if (!series.sequence) return series.name
return `${series.name} #${series.sequence}`
}
update(payload) {
var json = this.toJSON()

View File

@@ -4,6 +4,8 @@ const Logger = require('../../Logger')
class LibrarySettings {
constructor(settings) {
this.disableWatcher = false
this.skipMatchingMediaWithAsin = false
this.skipMatchingMediaWithIsbn = false
if (settings) {
this.construct(settings)
@@ -12,11 +14,15 @@ class LibrarySettings {
construct(settings) {
this.disableWatcher = !!settings.disableWatcher
this.skipMatchingMediaWithAsin = !!settings.skipMatchingMediaWithAsin
this.skipMatchingMediaWithIsbn = !!settings.skipMatchingMediaWithIsbn
}
toJSON() {
return {
disableWatcher: this.disableWatcher
disableWatcher: this.disableWatcher,
skipMatchingMediaWithAsin: this.skipMatchingMediaWithAsin,
skipMatchingMediaWithIsbn: this.skipMatchingMediaWithIsbn
}
}

View File

@@ -29,6 +29,7 @@ class ServerSettings {
// this.backupSchedule = '0 1 * * *' // If false then auto-backups are disabled (default every day at 1am)
this.backupSchedule = false
this.backupsToKeep = 2
this.maxBackupSize = 1
this.backupMetadataCovers = true
// Logger
@@ -78,6 +79,7 @@ class ServerSettings {
this.backupSchedule = settings.backupSchedule || false
this.backupsToKeep = settings.backupsToKeep || 2
this.maxBackupSize = settings.maxBackupSize || 1
this.backupMetadataCovers = settings.backupMetadataCovers !== false
this.loggerDailyLogsToKeep = settings.loggerDailyLogsToKeep || 7
@@ -114,6 +116,7 @@ class ServerSettings {
rateLimitLoginWindow: this.rateLimitLoginWindow,
backupSchedule: this.backupSchedule,
backupsToKeep: this.backupsToKeep,
maxBackupSize: this.maxBackupSize,
backupMetadataCovers: this.backupMetadataCovers,
loggerDailyLogsToKeep: this.loggerDailyLogsToKeep,
loggerScannerLogsToKeep: this.loggerScannerLogsToKeep,

View File

@@ -2,7 +2,7 @@ const Logger = require('../../Logger')
class MediaProgress {
constructor(progress) {
this.id = null // Same as library item id
this.id = null
this.libraryItemId = null
this.episodeId = null // For podcasts

View File

@@ -57,7 +57,7 @@ class User {
mobileOrderBy: 'recent',
mobileOrderDesc: true,
mobileFilterBy: 'all',
orderBy: 'book.title',
orderBy: 'media.metadata.title',
orderDesc: false,
filterBy: 'all',
playbackRate: 1,
@@ -251,6 +251,11 @@ class User {
})
}
getAllMediaProgressForLibraryItem(libraryItemId) {
if (!this.mediaProgress) return []
return this.mediaProgress.filter(li => li.libraryItemId === libraryItemId)
}
createUpdateMediaProgress(libraryItem, updatePayload, episodeId = null) {
var itemProgress = this.mediaProgress.find(li => {
if (episodeId && li.episodeId !== episodeId) return false

View File

@@ -58,9 +58,11 @@ class ApiRouter {
this.router.delete('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.delete.bind(this))
this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this))
this.router.delete('/libraries/:id/issues', LibraryController.middleware.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this))
this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this))
this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalized.bind(this))
this.router.get('/libraries/:id/personalized-old', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalized.bind(this))
this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalizedOptimal.bind(this))
this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this))
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
@@ -126,10 +128,10 @@ class ApiRouter {
//
this.router.get('/me/listening-sessions', MeController.getListeningSessions.bind(this))
this.router.get('/me/listening-stats', MeController.getListeningStats.bind(this))
this.router.patch('/me/progress/batch/update', MeController.batchUpdateMediaProgress.bind(this))
this.router.patch('/me/progress/:id', MeController.createUpdateMediaProgress.bind(this))
this.router.delete('/me/progress/:id', MeController.removeMediaProgress.bind(this))
this.router.patch('/me/progress/:id/:episodeId', MeController.createUpdateEpisodeMediaProgress.bind(this))
this.router.patch('/me/progress/batch/update', MeController.batchUpdateMediaProgress.bind(this))
this.router.post('/me/item/:id/bookmark', MeController.createBookmark.bind(this))
this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this))
this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this))
@@ -178,6 +180,8 @@ class ApiRouter {
this.router.post('/podcasts', PodcastController.create.bind(this))
this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this))
this.router.get('/podcasts/:id/checknew', PodcastController.checkNewEpisodes.bind(this))
this.router.get('/podcasts/:id/downloads', PodcastController.getEpisodeDownloads.bind(this))
this.router.get('/podcasts/:id/clear-queue', PodcastController.clearEpisodeDownloadQueue.bind(this))
this.router.post('/podcasts/:id/download-episodes', PodcastController.downloadEpisodes.bind(this))
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.updateEpisode.bind(this))

View File

@@ -235,7 +235,7 @@ class Scanner {
var hasMediaFile = dataFound.libraryFiles.some(lf => lf.isMediaFile)
if (!hasMediaFile) {
libraryScan.addLog(LogLevel.WARN, `Directory found "${libraryItemDataFound.path}" has no media files`)
libraryScan.addLog(LogLevel.WARN, `Item found "${libraryItemDataFound.path}" has no media files`)
} else {
var audioFileSize = 0
dataFound.libraryFiles.filter(lf => lf.fileType == 'audio').forEach(lf => audioFileSize += lf.metadata.size)
@@ -518,7 +518,7 @@ class Scanner {
var altDir = `${itemDir}/${firstNest}`
var fullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), itemDir)
var childLibraryItem = this.db.libraryItems.find(li => li.path !== fullPath && li.fullPath.startsWith(fullPath))
var childLibraryItem = this.db.libraryItems.find(li => li.path !== fullPath && li.path.startsWith(fullPath))
if (!childLibraryItem) {
continue;
}
@@ -642,7 +642,7 @@ class Scanner {
}
// Update media metadata if not set OR overrideDetails flag
const detailKeysToUpdate = ['title', 'subtitle', 'narrator', 'publisher', 'publishedYear', 'asin', 'isbn']
const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'asin', 'isbn']
const updatePayload = {}
for (const key in matchData) {
if (matchData[key] && detailKeysToUpdate.includes(key)) {
@@ -726,6 +726,21 @@ class Scanner {
for (let i = 0; i < itemsInLibrary.length; i++) {
var libraryItem = itemsInLibrary[i]
if (libraryItem.media.metadata.asin && library.settings.skipMatchingMediaWithAsin) {
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${
libraryItem.media.metadata.title
}" because it already has an ASIN (${i + 1} of ${itemsInLibrary.length})`)
continue;
}
if (libraryItem.media.metadata.isbn && library.settings.skipMatchingMediaWithIsbn) {
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${
libraryItem.media.metadata.title
}" because it already has an ISBN (${i + 1} of ${itemsInLibrary.length})`)
continue;
}
Logger.debug(`[Scanner] matchLibraryItems: Quick matching "${libraryItem.media.metadata.title}" (${i + 1} of ${itemsInLibrary.length})`)
var result = await this.quickMatchLibraryItem(libraryItem, { provider })
if (result.warning) {

View File

@@ -242,10 +242,10 @@ async function migrateLibraryItems(db) {
var libraryItems = audiobooks.map((ab) => makeLibraryItemFromOldAb(ab))
Logger.info(`>>> ${libraryItems.length} Library Items made`)
await db.insertEntities('libraryItem', libraryItems)
await db.bulkInsertEntities('libraryItem', libraryItems)
if (authorsToAdd.length) {
Logger.info(`>>> ${authorsToAdd.length} Authors made`)
await db.insertEntities('author', authorsToAdd)
await db.bulkInsertEntities('author', authorsToAdd)
}
if (seriesToAdd.length) {
Logger.info(`>>> ${seriesToAdd.length} Series made`)

View File

@@ -94,4 +94,21 @@ module.exports.setDefault = (path, silent = false) => {
if (!silent) Logger.debug(`[FilePerms] Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)
chmodr(path, mode, uid, gid, resolve)
})
}
// Default permissions 0o744 and global Uid/Gid
// Used for setting default permission to initial config/metadata directories
module.exports.setDefaultDirSync = (path, silent = false) => {
const mode = 0o744
const uid = global.Uid
const gid = global.Gid
if (!silent) Logger.debug(`[FilePerms] Setting dir permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)
try {
fs.chmodSync(path, mode)
fs.chownSync(path, uid, gid)
return true
} catch (error) {
Logger.error(`[FilePerms] Error setting dir permissions for path "${path}"`, error)
return false
}
}

View File

@@ -1,6 +1,7 @@
const fs = require('fs-extra')
const rra = require('recursive-readdir-async')
const axios = require('axios')
const Path = require('path')
const Logger = require('../Logger')
async function getFileStat(path) {
@@ -104,14 +105,27 @@ async function recurseFiles(path, relPathToReplace = null) {
return []
}
const directoriesToIgnore = []
list = list.filter((item) => {
if (item.error) {
Logger.error(`[fileUtils] Recurse files file "${item.fullName}" has error`, item.error)
Logger.error(`[fileUtils] Recurse files file "${item.fullname}" has error`, item.error)
return false
}
var relpath = item.fullname.replace(relPathToReplace, '')
var reldirname = Path.dirname(relpath)
if (reldirname === '.') reldirname = ''
var dirname = Path.dirname(item.fullname)
// Directory has a file named ".ignore" flag directory and ignore
if (item.name === '.ignore' && reldirname && reldirname !== '.' && !directoriesToIgnore.includes(dirname)) {
Logger.debug(`[fileUtils] .ignore found - ignoring directory "${reldirname}"`)
directoriesToIgnore.push(dirname)
return false
}
// Ignore any file if a directory or the filename starts with "."
var relpath = item.fullname.replace(relPathToReplace, '')
var pathStartsWithPeriod = relpath.split('/').find(p => p.startsWith('.'))
if (pathStartsWithPeriod) {
Logger.debug(`[fileUtils] Ignoring path has . "${relpath}"`)
@@ -119,15 +133,25 @@ async function recurseFiles(path, relPathToReplace = null) {
}
return true
}).map((item) => ({
name: item.name,
path: item.fullname.replace(relPathToReplace, ''),
dirpath: item.path,
reldirpath: item.path.replace(relPathToReplace, ''),
fullpath: item.fullname,
extension: item.extension,
deep: item.deep
}))
}).filter(item => {
// Filter out items in ignore directories
if (directoriesToIgnore.includes(Path.dirname(item.fullname))) {
Logger.debug(`[fileUtils] Ignoring path in dir with .ignore "${item.fullname}"`)
return false
}
return true
}).map((item) => {
var isInRoot = (item.path + '/' === relPathToReplace)
return {
name: item.name,
path: item.fullname.replace(relPathToReplace, ''),
dirpath: item.path,
reldirpath: isInRoot ? '' : item.path.replace(relPathToReplace, ''),
fullpath: item.fullname,
extension: item.extension,
deep: item.deep
}
})
// Sort from least deep to most
list.sort((a, b) => a.deep - b.deep)

View File

@@ -1,4 +1,5 @@
const { sort, createNewSortInstance } = require('fast-sort')
const Logger = require('../Logger')
const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
})
@@ -19,13 +20,13 @@ module.exports = {
if (group === 'genres') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.genres.includes(filter))
else if (group === 'tags') filtered = filtered.filter(li => li.media.tags.includes(filter))
else if (group === 'series') {
if (filter === 'No Series') filtered = filtered.filter(li => li.media.metadata && !li.media.metadata.series.length)
if (filter === 'No Series') filtered = filtered.filter(li => li.mediaType === 'book' && !li.media.metadata.series.length)
else {
filtered = filtered.filter(li => li.media.metadata && li.media.metadata.hasSeries(filter))
filtered = filtered.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(filter))
}
}
else if (group === 'authors') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.hasAuthor(filter))
else if (group === 'narrators') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.hasNarrator(filter))
else if (group === 'authors') filtered = filtered.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(filter))
else if (group === 'narrators') filtered = filtered.filter(li => li.mediaType === 'book' && li.media.metadata.hasNarrator(filter))
else if (group === 'progress') {
filtered = filtered.filter(li => {
var itemProgress = user.getMediaProgress(li.id)
@@ -36,29 +37,28 @@ module.exports = {
})
} else if (group == 'missing') {
filtered = filtered.filter(li => {
if (filter === 'ASIN' && li.media.metadata.asin === null) return true;
if (filter === 'ISBN' && li.media.metadata.isbn === null) return true;
if (filter === 'Subtitle' && li.media.metadata.subtitle === null) return true;
if (filter === 'Author' && li.media.metadata.authors.length === 0) return true;
if (filter === 'Publish Year' && li.media.metadata.publishedYear === null) return true;
if (filter === 'Series' && li.media.metadata.series.length === 0) return true;
if (filter === 'Volume Number' && (li.media.metadata.series.length === 0 || li.media.metadata.series[0].sequence === null)) return true;
if (filter === 'Description' && li.media.metadata.description === null) return true;
if (filter === 'Genres' && li.media.metadata.genres.length === 0) return true;
if (filter === 'Tags' && li.media.tags.length === 0) return true;
if (filter === 'Narrator' && li.media.metadata.narrators.length === 0) return true;
if (filter === 'Publisher' && li.media.metadata.publisher === null) return true;
if (filter === 'Language' && li.media.metadata.language === null) return true;
if (li.mediaType === 'book') {
if (filter === 'ASIN' && li.media.metadata.asin === null) return true;
if (filter === 'ISBN' && li.media.metadata.isbn === null) return true;
if (filter === 'Subtitle' && li.media.metadata.subtitle === null) return true;
if (filter === 'Author' && li.media.metadata.authors.length === 0) return true;
if (filter === 'Publish Year' && li.media.metadata.publishedYear === null) return true;
if (filter === 'Series' && li.media.metadata.series.length === 0) return true;
if (filter === 'Description' && li.media.metadata.description === null) return true;
if (filter === 'Genres' && li.media.metadata.genres.length === 0) return true;
if (filter === 'Tags' && li.media.tags.length === 0) return true;
if (filter === 'Narrator' && li.media.metadata.narrators.length === 0) return true;
if (filter === 'Publisher' && li.media.metadata.publisher === null) return true;
if (filter === 'Language' && li.media.metadata.language === null) return true;
} else {
return false
}
})
} else if (group === 'languages') {
filtered = filtered.filter(li => li.media.metadata && li.media.metadata.language === filter)
}
} else if (filterBy === 'issues') {
filtered = filtered.filter(ab => {
// TODO: Update filter for issues
return ab.isMissing
// return ab.numMissingParts || ab.numInvalidParts || ab.isMissing || ab.isInvalid
})
filtered = filtered.filter(li => li.hasIssues)
}
return filtered
@@ -102,10 +102,10 @@ module.exports = {
}
if (mediaMetadata.language && !data.languages.includes(mediaMetadata.language)) data.languages.push(mediaMetadata.language)
})
data.authors = naturalSort(data.authors).asc()
data.authors = naturalSort(data.authors).asc(au => au.name)
data.genres = naturalSort(data.genres).asc()
data.tags = naturalSort(data.tags).asc()
data.series = naturalSort(data.series).asc()
data.series = naturalSort(data.series).asc(se => se.name)
data.narrators = naturalSort(data.narrators).asc()
data.languages = naturalSort(data.languages).asc()
return data
@@ -172,14 +172,28 @@ module.exports = {
})
},
getItemsWithUserProgress(user, libraryItems) {
return libraryItems.map(li => {
var itemProgress = user.getMediaProgress(li.id)
return {
userProgress: itemProgress ? itemProgress.toJSON() : null,
libraryItem: li
}
}).filter(b => !!b.userProgress)
getMediaProgressWithItems(user, libraryItems) {
var mediaProgress = []
libraryItems.forEach(li => {
var itemProgress = user.getAllMediaProgressForLibraryItem(li.id).map(mp => {
var episode = null
if (mp.episodeId) {
episode = li.media.getEpisode(mp.episodeId)
if (!episode) {
// Episode not found for library item
return null
}
}
return {
userProgress: mp.toJSON(),
libraryItem: li,
episode
}
}).filter(mp => !!mp)
mediaProgress = mediaProgress.concat(itemProgress)
})
return mediaProgress
},
getItemsMostRecentlyListened(itemsWithUserProgress, limit, minified = false) {
@@ -187,7 +201,13 @@ module.exports = {
itemsInProgress.sort((a, b) => {
return b.userProgress.lastUpdate - a.userProgress.lastUpdate
})
return itemsInProgress.map(b => minified ? b.libraryItem.toJSONMinified() : b.libraryItem.toJSONExpanded()).slice(0, limit)
return itemsInProgress.map(b => {
var libjson = minified ? b.libraryItem.toJSONMinified() : b.libraryItem.toJSONExpanded()
if (b.episode) {
libjson.recentEpisode = b.episode
}
return libjson
}).slice(0, limit)
},
getBooksNextInSeries(seriesWithUserAb, limit, minified = false) {
@@ -202,17 +222,39 @@ module.exports = {
return booksNextInSeries.sort((a, b) => { return b.DateLastReadSeries - a.DateLastReadSeries }).map(b => minified ? b.book.toJSONMinified() : b.book.toJSONExpanded()).slice(0, limit)
},
getItemsMostRecentlyAdded(libraryItems, limit, minified = false) {
var itemsSortedByAddedAt = sort(libraryItems).desc(li => li.addedAt)
return itemsSortedByAddedAt.map(b => minified ? b.toJSONMinified() : b.toJSONExpanded()).slice(0, limit)
},
getItemsMostRecentlyFinished(itemsWithUserProgress, limit, minified = false) {
var itemsFinished = itemsWithUserProgress.filter((data) => data.userProgress && data.userProgress.isFinished)
itemsFinished.sort((a, b) => {
return b.userProgress.finishedAt - a.userProgress.finishedAt
})
return itemsFinished.map(i => minified ? i.libraryItem.toJSONMinified() : i.libraryItem.toJSONExpanded()).slice(0, limit)
return itemsFinished.map(i => {
var libjson = minified ? i.libraryItem.toJSONMinified() : i.libraryItem.toJSONExpanded()
if (i.episode) {
libjson.recentEpisode = i.episode
}
return libjson
}).slice(0, limit)
},
getItemsMostRecentlyAdded(libraryItems, limit, minified = false) {
var itemsSortedByAddedAt = sort(libraryItems).desc(li => li.addedAt)
return itemsSortedByAddedAt.map(b => minified ? b.toJSONMinified() : b.toJSONExpanded()).slice(0, limit)
},
getEpisodesRecentlyAdded(libraryItems, limit, minified = false) {
var libraryItemsWithEpisode = []
libraryItems.forEach((li) => {
if (li.mediaType !== 'podcast' || !li.media.hasMediaEntities) return
var libjson = minified ? li.toJSONMinified() : li.toJSONExpanded()
var episodes = sort(li.media.episodes || []).desc(ep => ep.addedAt)
episodes.forEach((ep) => {
var lie = { ...libjson }
lie.recentEpisode = ep
libraryItemsWithEpisode.push(lie)
})
})
libraryItemsWithEpisode = sort(libraryItemsWithEpisode).desc(lie => lie.recentEpisode.addedAt)
return libraryItemsWithEpisode.slice(0, limit)
},
getSeriesMostRecentlyAdded(series, limit) {
@@ -307,5 +349,313 @@ module.exports = {
}
return libraryItemJson
}).filter(li => li)
},
buildPersonalizedShelves(user, libraryItems, mediaType, allSeries, allAuthors, maxEntitiesPerShelf = 10) {
const isPodcastLibrary = mediaType === 'podcast'
const shelves = [
{
id: 'continue-listening',
label: 'Continue Listening',
type: isPodcastLibrary ? 'episode' : mediaType,
entities: [],
category: 'recentlyListened'
},
{
id: 'recently-added',
label: 'Recently Added',
type: mediaType,
entities: [],
category: 'newestItems'
},
{
id: 'listen-again',
label: 'Listen Again',
type: isPodcastLibrary ? 'episode' : mediaType,
entities: [],
category: 'recentlyFinished'
},
{
id: 'recent-series',
label: 'Recent Series',
type: 'series',
entities: [],
category: 'newestSeries'
},
{
id: 'newest-authors',
label: 'Newest Authors',
type: 'authors',
entities: [],
category: 'newestAuthors'
},
{
id: 'episodes-recently-added',
label: 'Newest Episodes',
type: 'episode',
entities: [],
category: 'newestEpisodes'
}
]
const categories = ['recentlyListened', 'newestEpisodes', 'newestItems', 'newestSeries', 'recentlyFinished', 'newestAuthors']
const categoryMap = {}
categories.forEach((cat) => {
categoryMap[cat] = {
category: cat,
biggest: 0,
smallest: 0,
items: []
}
})
const seriesMap = {}
const authorMap = {}
for (const libraryItem of libraryItems) {
if (libraryItem.addedAt > categoryMap.newestItems.smallest) {
var indexToPut = categoryMap.newestItems.items.findIndex(i => libraryItem.addedAt > i.addedAt)
if (indexToPut >= 0) {
categoryMap.newestItems.items.splice(indexToPut, 0, libraryItem.toJSONMinified())
} else {
categoryMap.newestItems.items.push(libraryItem.toJSONMinified())
}
if (categoryMap.newestItems.items.length > maxEntitiesPerShelf) {
// Remove last item
categoryMap.newestItems.items.pop()
categoryMap.newestItems.smallest = categoryMap.newestItems.items[categoryMap.newestItems.items.length - 1].addedAt
}
categoryMap.newestItems.biggest = categoryMap.newestItems.items[0].addedAt
}
var allItemProgress = user.getAllMediaProgressForLibraryItem(libraryItem.id)
if (libraryItem.isPodcast) {
// Podcast categories
const podcastEpisodes = libraryItem.media.episodes || []
for (const episode of podcastEpisodes) {
// Newest episodes
if (episode.addedAt > categoryMap.newestEpisodes.smallest) {
const libraryItemWithEpisode = {
...libraryItem.toJSONMinified(),
recentEpisode: episode.toJSON()
}
var indexToPut = categoryMap.newestEpisodes.items.findIndex(i => episode.addedAt > i.recentEpisode.addedAt)
if (indexToPut >= 0) {
categoryMap.newestEpisodes.items.splice(indexToPut, 0, libraryItemWithEpisode)
} else {
categoryMap.newestEpisodes.items.push(libraryItemWithEpisode)
}
if (categoryMap.newestEpisodes.items.length > maxEntitiesPerShelf) {
// Remove last item
categoryMap.newestEpisodes.items.pop()
categoryMap.newestEpisodes.smallest = categoryMap.newestEpisodes.items[categoryMap.newestEpisodes.items.length - 1].recentEpisode.addedAt
}
categoryMap.newestEpisodes.biggest = categoryMap.newestEpisodes.items[0].recentEpisode.addedAt
}
// Episode recently listened and finished
var mediaProgress = allItemProgress.find(mp => mp.episodeId === episode.id)
if (mediaProgress) {
if (mediaProgress.isFinished) {
if (mediaProgress.finishedAt > categoryMap.recentlyFinished.smallest) { // Item belongs on shelf
const libraryItemWithEpisode = {
...libraryItem.toJSONMinified(),
recentEpisode: episode.toJSON(),
finishedAt: mediaProgress.finishedAt
}
var indexToPut = categoryMap.recentlyFinished.items.findIndex(i => mediaProgress.finishedAt > i.finishedAt)
if (indexToPut >= 0) {
categoryMap.recentlyFinished.items.splice(indexToPut, 0, libraryItemWithEpisode)
} else {
categoryMap.recentlyFinished.items.push(libraryItemWithEpisode)
}
if (categoryMap.recentlyFinished.items.length > maxEntitiesPerShelf) {
// Remove last item
categoryMap.recentlyFinished.items.pop()
categoryMap.recentlyFinished.smallest = categoryMap.recentlyFinished.items[categoryMap.recentlyFinished.items.length - 1].finishedAt
}
categoryMap.recentlyFinished.biggest = categoryMap.recentlyFinished.items[0].finishedAt
}
} else if (mediaProgress.progress > 0) { // Handle most recently listened
if (mediaProgress.lastUpdate > categoryMap.recentlyListened.smallest) { // Item belongs on shelf
const libraryItemWithEpisode = {
...libraryItem.toJSONMinified(),
recentEpisode: episode.toJSON(),
progressLastUpdate: mediaProgress.lastUpdate
}
var indexToPut = categoryMap.recentlyListened.items.findIndex(i => mediaProgress.lastUpdate > i.progressLastUpdate)
if (indexToPut >= 0) {
categoryMap.recentlyListened.items.splice(indexToPut, 0, libraryItemWithEpisode)
} else {
categoryMap.recentlyListened.items.push(libraryItemWithEpisode)
}
if (categoryMap.recentlyListened.items.length > maxEntitiesPerShelf) {
// Remove last item
categoryMap.recentlyListened.items.pop()
categoryMap.recentlyListened.smallest = categoryMap.recentlyListened.items[categoryMap.recentlyListened.items.length - 1].progressLastUpdate
}
categoryMap.recentlyListened.biggest = categoryMap.recentlyListened.items[0].progressLastUpdate
}
}
}
}
} else {
// Book categories
// Newest series
if (libraryItem.media.metadata.series.length) {
for (const librarySeries of libraryItem.media.metadata.series) {
if (!seriesMap[librarySeries.id]) {
const seriesObj = allSeries.find(se => se.id === librarySeries.id)
if (seriesObj) {
var series = {
...seriesObj.toJSON(),
books: []
}
if (series.addedAt > categoryMap.newestSeries.smallest) {
const libraryItemJson = libraryItem.toJSONMinified()
libraryItemJson.seriesSequence = librarySeries.sequence
series.books.push(libraryItemJson)
var indexToPut = categoryMap.newestSeries.items.findIndex(i => series.addedAt > i.addedAt)
if (indexToPut >= 0) {
categoryMap.newestSeries.items.splice(indexToPut, 0, series)
} else {
categoryMap.newestSeries.items.push(series)
}
// Max series is 5
if (categoryMap.newestSeries.items.length > 5) {
categoryMap.newestSeries.items.pop()
categoryMap.newestSeries.smallest = categoryMap.newestSeries.items[categoryMap.newestSeries.items.length - 1].addedAt
}
categoryMap.newestSeries.biggest = categoryMap.newestSeries.items[0].addedAt
seriesMap[librarySeries.id] = series
}
}
} else {
// series already in map - add book
const libraryItemJson = libraryItem.toJSONMinified()
libraryItemJson.seriesSequence = librarySeries.sequence
seriesMap[librarySeries.id].books.push(libraryItemJson)
}
}
}
// Newest authors
if (libraryItem.media.metadata.authors.length) {
for (const libraryAuthor of libraryItem.media.metadata.authors) {
if (!authorMap[libraryAuthor.id]) {
const authorObj = allAuthors.find(au => au.id === libraryAuthor.id)
if (authorObj) {
var author = {
...authorObj.toJSON(),
numBooks: 1
}
if (author.addedAt > categoryMap.newestAuthors.smallest) {
var indexToPut = categoryMap.newestAuthors.items.findIndex(i => author.addedAt > i.addedAt)
if (indexToPut >= 0) {
categoryMap.newestAuthors.items.splice(indexToPut, 0, author)
} else {
categoryMap.newestAuthors.items.push(author)
}
// Max authors is 10
if (categoryMap.newestAuthors.items.length > 10) {
categoryMap.newestAuthors.items.pop()
categoryMap.newestAuthors.smallest = categoryMap.newestAuthors.items[categoryMap.newestAuthors.items.length - 1].addedAt
}
categoryMap.newestAuthors.biggest = categoryMap.newestAuthors.items[0].addedAt
}
authorMap[libraryAuthor.id] = author
}
} else {
authorMap[libraryAuthor.id].numBooks++
}
}
}
// Book listening and finished
var mediaProgress = allItemProgress.length ? allItemProgress[0] : null
if (mediaProgress) {
// Handle most recently finished
if (mediaProgress.isFinished) {
if (mediaProgress.finishedAt > categoryMap.recentlyFinished.smallest) { // Item belongs on shelf
const libraryItemObj = {
...libraryItem.toJSONMinified(),
finishedAt: mediaProgress.finishedAt
}
var indexToPut = categoryMap.recentlyFinished.items.findIndex(i => mediaProgress.finishedAt > i.finishedAt)
if (indexToPut >= 0) {
categoryMap.recentlyFinished.items.splice(indexToPut, 0, libraryItemObj)
} else {
categoryMap.recentlyFinished.items.push(libraryItemObj)
}
if (categoryMap.recentlyFinished.items.length > maxEntitiesPerShelf) {
// Remove last item
categoryMap.recentlyFinished.items.pop()
categoryMap.recentlyFinished.smallest = categoryMap.recentlyFinished.items[categoryMap.recentlyFinished.items.length - 1].finishedAt
}
categoryMap.recentlyFinished.biggest = categoryMap.recentlyFinished.items[0].finishedAt
}
} else if (mediaProgress.inProgress) { // Handle most recently listened
if (mediaProgress.lastUpdate > categoryMap.recentlyListened.smallest) { // Item belongs on shelf
const libraryItemObj = {
...libraryItem.toJSONMinified(),
progressLastUpdate: mediaProgress.lastUpdate
}
var indexToPut = categoryMap.recentlyListened.items.findIndex(i => mediaProgress.lastUpdate > i.progressLastUpdate)
if (indexToPut >= 0) {
categoryMap.recentlyListened.items.splice(indexToPut, 0, libraryItemObj)
} else { // Should only happen when array is < max
categoryMap.recentlyListened.items.push(libraryItemObj)
}
if (categoryMap.recentlyListened.items.length > maxEntitiesPerShelf) {
// Remove last item
categoryMap.recentlyListened.items.pop()
categoryMap.recentlyListened.smallest = categoryMap.recentlyListened.items[categoryMap.recentlyListened.items.length - 1].progressLastUpdate
}
categoryMap.recentlyListened.biggest = categoryMap.recentlyListened.items[0].progressLastUpdate
}
}
}
}
}
// Sort series books by sequence
if (categoryMap.newestSeries.items.length) {
for (const seriesItem of categoryMap.newestSeries.items) {
seriesItem.books = naturalSort(seriesItem.books).asc(li => li.seriesSequence)
}
}
var categoriesWithItems = Object.values(categoryMap).filter(cat => cat.items.length)
return categoriesWithItems.map(cat => {
var shelf = shelves.find(s => s.category === cat.category)
shelf.entities = cat.items
return shelf
})
}
}

View File

@@ -79,6 +79,9 @@ module.exports.parse = (nameString) => {
}
}
// Filter out names that have no first and last
names = names.filter(n => n.first_name || n.last_name)
var namesArray = names.map(a => a.first_name ? `${a.first_name} ${a.last_name}` : a.last_name)
var firstLast = names.length ? namesArray.join(', ') : ''
var lastFirst = names.length ? names.map(a => a.first_name ? `${a.last_name}, ${a.first_name}` : a.last_name).join(', ') : ''

View File

@@ -5,9 +5,9 @@ const { recurseFiles, getFileTimestampsWithIno } = require('./fileUtils')
const globals = require('./globals')
const LibraryFile = require('../objects/files/LibraryFile')
function isMediaFile(mediaType, path) {
if (!path) return false
var ext = Path.extname(path)
function isMediaFile(mediaType, ext) {
// if (!path) return false
// var ext = Path.extname(path)
if (!ext) return false
var extclean = ext.slice(1).toLowerCase()
if (mediaType === 'podcast') return globals.SupportedAudioTypes.includes(extclean)
@@ -62,40 +62,47 @@ module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths
// Input: array of relative file items (see recurseFiles)
// Output: map of files grouped into potential libarary item dirs
function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
// Step 1: Filter out files in root dir (with depth of 0)
var itemsFiltered = fileItems.filter(i => i.deep > 0)
// Step 1: Filter out non-media files in root dir (with depth of 0)
var itemsFiltered = fileItems.filter(i => {
return i.deep > 0 || isMediaFile(mediaType, i.extension)
})
// Step 2: Seperate media files and other files
// - Directories without a media file will not be included
var mediaFileItems = []
var otherFileItems = []
itemsFiltered.forEach(item => {
if (isMediaFile(mediaType, item.fullpath)) mediaFileItems.push(item)
if (isMediaFile(mediaType, item.extension)) mediaFileItems.push(item)
else otherFileItems.push(item)
})
// Step 3: Group audio files in library items
var libraryItemGroup = {}
mediaFileItems.forEach((item) => {
var dirparts = item.reldirpath.split('/')
var dirparts = item.reldirpath.split('/').filter(p => !!p)
var numparts = dirparts.length
var _path = ''
// Iterate over directories in path
for (let i = 0; i < numparts; i++) {
var dirpart = dirparts.shift()
_path = Path.posix.join(_path, dirpart)
if (!dirparts.length) {
// Media file in root
libraryItemGroup[item.name] = item.name
} else {
// Iterate over directories in path
for (let i = 0; i < numparts; i++) {
var dirpart = dirparts.shift()
_path = Path.posix.join(_path, dirpart)
if (libraryItemGroup[_path]) { // Directory already has files, add file
var relpath = Path.posix.join(dirparts.join('/'), item.name)
libraryItemGroup[_path].push(relpath)
return
} else if (!dirparts.length) { // This is the last directory, create group
libraryItemGroup[_path] = [item.name]
return
} else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group
libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)]
return
if (libraryItemGroup[_path]) { // Directory already has files, add file
var relpath = Path.posix.join(dirparts.join('/'), item.name)
libraryItemGroup[_path].push(relpath)
return
} else if (!dirparts.length) { // This is the last directory, create group
libraryItemGroup[_path] = [item.name]
return
} else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group
libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)]
return
}
}
}
})
@@ -140,19 +147,44 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) {
}
var fileItems = await recurseFiles(folderPath)
var basePath = folderPath
const isOpenAudibleFolder = fileItems.find(fi => fi.deep === 0 && fi.name === 'books.json')
if (isOpenAudibleFolder) {
Logger.info(`[scandir] Detected Open Audible Folder, looking in books folder`)
basePath = Path.posix.join(folderPath, 'books')
fileItems = await recurseFiles(basePath)
Logger.debug(`[scandir] ${fileItems.length} files found in books folder`)
}
var libraryItemGrouping = groupFileItemsIntoLibraryItemDirs(libraryMediaType, fileItems)
if (!Object.keys(libraryItemGrouping).length) {
Logger.error('Root path has no media folders', fileItems.length)
Logger.error(`Root path has no media folders: ${folderPath}`)
return []
}
var isFile = false // item is not in a folder
var items = []
for (const libraryItemPath in libraryItemGrouping) {
var libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath, serverSettings)
var libraryItemData = null
var fileObjs = []
if (libraryItemPath === libraryItemGrouping[libraryItemPath]) {
// Media file in root only get title
libraryItemData = {
mediaMetadata: {
title: Path.basename(libraryItemPath, Path.extname(libraryItemPath))
},
path: Path.posix.join(basePath, libraryItemPath),
relPath: libraryItemPath
}
fileObjs = await cleanFileObjects(basePath, [libraryItemPath])
isFile = true
} else {
libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath, serverSettings)
fileObjs = await cleanFileObjects(libraryItemData.path, libraryItemGrouping[libraryItemPath])
}
var fileObjs = await cleanFileObjects(libraryItemData.path, libraryItemGrouping[libraryItemPath])
var libraryItemFolderStats = await getFileTimestampsWithIno(libraryItemData.path)
items.push({
folderId: folder.id,
@@ -163,6 +195,7 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) {
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
path: libraryItemData.path,
relPath: libraryItemData.relPath,
isFile,
media: {
metadata: libraryItemData.mediaMetadata || null
},
@@ -242,7 +275,6 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) {
}
}
// Subtitle can be parsed from the title if user enabled
// Subtitle is everything after " - "
var subtitle = null
@@ -290,7 +322,7 @@ function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettin
}
}
// Called from Scanner.js
async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, serverSettings = {}) {
var fileItems = await recurseFiles(libraryItemPath)