Compare commits

...

48 Commits

Author SHA1 Message Date
advplyr
54389e3c25 Version bump v2.0.8 2022-04-30 13:19:07 -05:00
advplyr
bf0da1c6ec Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-04-30 12:33:54 -05:00
advplyr
591a866f8c Fix:Removing section from upload page #530 2022-04-30 12:31:58 -05:00
advplyr
fc8473ed84 Add:Putting back in the Continue Series shelf on the home page #541 2022-04-30 12:24:48 -05:00
advplyr
b19442e440 Remove old home page personalized API route 2022-04-30 11:36:05 -05:00
advplyr
7a51e0693d Merge pull request #534 from cassieesposito/tooltips_for_appbar
Added tooltips missing from Appbar buttons
2022-04-30 11:33:22 -05:00
Cassie Esposito
21785c8e72 Merge branch 'advplyr:master' into tooltips_for_appbar 2022-04-30 09:27:48 -07:00
Cassie Esposito
bdf6ccbd2d Removed duplicate conditional from line 62 of client/components/app/Appbar.vue 2022-04-30 09:21:27 -07:00
advplyr
ceb163570f Fix:Set next accessible library when currently selected library is removed 2022-04-29 18:57:46 -05:00
advplyr
049ae73d74 Update:Guest user accounts cannot change the account password #537 2022-04-29 18:38:13 -05:00
advplyr
729fdd5c9f Update:User type admin permissions to create podcasts and download episodes #507 2022-04-29 18:29:40 -05:00
advplyr
4dac8ac16c Fix:Account type select dropdown & add root user change password button 2022-04-29 18:19:04 -05:00
advplyr
220bbc3d2d Fix:Series covers on home page not spread out correctly #505, Update:Server settings are now returned with auth requests 2022-04-29 17:43:46 -05:00
advplyr
c2a4b32192 Fix:Series on search page not directing to series page #533 2022-04-29 17:12:02 -05:00
advplyr
09d0d47549 Fix:Setting user can access all libraries/tags 2022-04-29 16:50:06 -05:00
advplyr
4185807da4 Add:Check for new episodes manual check and update last check time, Update:Adding new podcasts and downloading podcast episodes restricted to admin users 2022-04-29 16:42:40 -05:00
advplyr
8abda14e0f Version bump v2.0.7 2022-04-29 13:16:29 -05:00
advplyr
619e5c0895 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-04-29 13:14:19 -05:00
advplyr
3a2594cde9 Version bump v2.0.6 2022-04-29 13:13:54 -05:00
advplyr
5cca2d0155 Update docker-build.yml 2022-04-29 13:01:12 -05:00
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
Cassie Esposito
620bf7990f Added tooltips for edit, delete, and deselect all buttons to client/components/app/Appbar.vue 2022-04-28 15:44:07 -07: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
54 changed files with 649 additions and 403 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,ghcr.io/${{ github.repository_owner }}/audiobookshelf
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

@@ -54,10 +54,16 @@
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
</ui-tooltip>
<template v-if="userCanUpdate && numLibraryItemsSelected < 50">
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
<ui-tooltip text="Edit" direction="bottom">
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
</ui-tooltip>
</template>
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
<ui-tooltip v-if="userCanDelete" text="Delete" direction="bottom">
<ui-icon-btn :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
</ui-tooltip>
<ui-tooltip text="Deselect All" direction="bottom">
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
</ui-tooltip>
</div>
</div>
</div>
@@ -229,4 +235,4 @@ export default {
#appbar {
box-shadow: 0px 5px 5px #11111155;
}
</style>
</style>

View File

@@ -128,8 +128,7 @@ export default {
type: 'series',
entities: this.results.series.map((seriesObj) => {
return {
name: seriesObj.series.name,
series: seriesObj.series,
...seriesObj.series,
books: seriesObj.books,
type: 'series'
}

View File

@@ -52,7 +52,7 @@
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<icons-podcast-svg class="w-6 h-6" />
<p class="font-book pt-1.5" style="font-size: 0.9rem">Search</p>
@@ -82,6 +82,9 @@ export default {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
paramId() {
return this.$route.params ? this.$route.params.id || '' : ''
},

View File

@@ -78,7 +78,7 @@
</ui-tooltip>
<!-- 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` }">
<div v-if="seriesSequence && !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>
@@ -110,7 +110,6 @@ export default {
default: 192
},
bookCoverAspectRatio: Number,
showSequence: Boolean,
bookshelfView: Number,
bookMount: {
// Book can be passed as prop or set with setEntity()
@@ -150,6 +149,10 @@ export default {
_libraryItem() {
return this.libraryItem || {}
},
isFile() {
// Library item is not in a folder
return this._libraryItem.isFile
},
media() {
return this._libraryItem.media || {}
},
@@ -172,7 +175,7 @@ export default {
return this._libraryItem.id
},
series() {
// Only included when filtering by series or collapse series
// Only included when filtering by series or collapse series or Continue Series shelf on home page
return this.mediaMetadata.series
},
seriesSequence() {
@@ -365,7 +368,7 @@ export default {
text: 'Match'
})
}
if (this.userIsRoot) {
if (this.userIsRoot && !this.isFile) {
items.push({
func: 'rescan',
text: 'Re-Scan'

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

@@ -44,6 +44,14 @@ export default {
this.$nextTick(this.init)
}
}
},
width: {
handler(newVal) {
if (newVal) {
this.isInit = false
this.$nextTick(this.init)
}
}
}
},
computed: {

View File

@@ -1,5 +1,5 @@
<template>
<modals-modal v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
<modals-modal ref="modal" v-model="show" name="account" :width="800" :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>
@@ -8,20 +8,20 @@
<form @submit.prevent="submitForm">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
<div class="w-full p-8">
<div class="flex py-2 -mx-2">
<div class="flex py-2">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="newUser.username" label="Username" class="mx-2" />
<ui-text-input-with-label v-model="newUser.username" label="Username" />
</div>
<div class="w-1/2 px-2">
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? 'Password' : 'Change Password'" type="password" class="mx-2" />
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? 'Password' : 'Change Password'" type="password" />
</div>
</div>
<div class="flex py-2">
<div class="px-2">
<ui-input-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :editable="false" :items="accountTypes" @input="userTypeUpdated" />
<div v-show="!isEditingRoot" class="flex py-2">
<div class="px-2 w-52">
<ui-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :items="accountTypes" @input="userTypeUpdated" />
</div>
<div class="flex-grow" />
<div v-show="!isEditingRoot" class="flex items-center pt-4 px-2">
<div class="flex items-center pt-4 px-2">
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">Is Active</p>
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
</div>
@@ -92,7 +92,8 @@
</div>
</div>
<div class="flex pt-4">
<div class="flex pt-4 px-2">
<ui-btn v-if="isEditingRoot" to="/account">Change Root Password</ui-btn>
<div class="flex-grow" />
<ui-btn color="success" type="submit">Submit</ui-btn>
</div>
@@ -116,7 +117,20 @@ export default {
processing: false,
newUser: {},
isNew: true,
accountTypes: ['guest', 'user', 'admin'],
accountTypes: [
{
text: 'Guest',
value: 'guest'
},
{
text: 'User',
value: 'user'
},
{
text: 'Admin',
value: 'admin'
}
],
tags: [],
loadingTags: false
}
@@ -124,6 +138,7 @@ export default {
watch: {
show: {
handler(newVal) {
console.log('accoutn modal show change', newVal)
if (newVal) {
this.init()
}
@@ -140,7 +155,7 @@ export default {
}
},
title() {
return this.isNew ? 'Add New Account' : `Update Account: ${(this.account || {}).username}`
return this.isNew ? 'Add New Account' : `Update ${(this.account || {}).username}`
},
isEditingRoot() {
return this.account && this.account.type === 'root'
@@ -161,6 +176,10 @@ export default {
}
},
methods: {
close() {
// Force close when navigating - used in UsersTable
if (this.$refs.modal) this.$refs.modal.setHide()
},
accessAllTagsToggled(val) {
if (!val && !this.newUser.itemTagsAccessible.length) {
this.newUser.itemTagsAccessible = this.libraries.map((l) => l.id)

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,11 +1,11 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div class="w-full mb-4">
<!-- <div class="flex items-center mb-4">
<p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p>
<div class="flex-grow" />
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check for new episodes</ui-btn>
</div> -->
<div v-if="userIsAdminOrUp" class="flex items-end justify-end mb-4">
<!-- <p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p> -->
<ui-text-input-with-label ref="lastCheckInput" v-model="lastEpisodeCheckInput" :disabled="checkingNewEpisodes" type="datetime-local" label="Look for new episodes after this date" class="max-w-xs mr-2" />
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check & Download New Episodes</ui-btn>
</div>
<div v-if="episodes.length" class="w-full p-4 bg-primary">
<p>Podcast Episodes</p>
@@ -51,10 +51,23 @@ export default {
},
data() {
return {
checkingNewEpisodes: false
checkingNewEpisodes: false,
lastEpisodeCheckInput: null
}
},
watch: {
lastEpisodeCheck: {
handler(newVal) {
if (newVal) {
this.setLastEpisodeCheckInput()
}
}
}
},
computed: {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
autoDownloadEpisodes() {
return !!this.media.autoDownloadEpisodes
},
@@ -72,8 +85,22 @@ export default {
}
},
methods: {
checkForNewEpisodes() {
async checkForNewEpisodes() {
if (this.$refs.lastCheckInput) {
this.$refs.lastCheckInput.blur()
}
this.checkingNewEpisodes = true
const lastEpisodeCheck = new Date(this.lastEpisodeCheckInput).valueOf()
// If last episode check changed then update it first
if (lastEpisodeCheck && lastEpisodeCheck !== this.lastEpisodeCheck) {
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, { lastEpisodeCheck }).catch((error) => {
console.error('Failed to update', error)
return false
})
console.log('updateResult', updateResult)
}
this.$axios
.$get(`/api/podcasts/${this.libraryItemId}/checknew`)
.then((response) => {
@@ -91,7 +118,13 @@ export default {
this.$toast.error(errorMsg)
this.checkingNewEpisodes = false
})
},
setLastEpisodeCheckInput() {
this.lastEpisodeCheckInput = this.lastEpisodeCheck ? this.$formatDate(this.lastEpisodeCheck, "yyyy-MM-dd'T'HH:mm") : null
}
},
mounted() {
this.setLastEpisodeCheckInput()
}
}
</script>

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

@@ -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

@@ -58,7 +58,7 @@
</table>
</div>
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
</div>
</template>
@@ -156,6 +156,10 @@ export default {
this.init()
},
beforeDestroy() {
if (this.$refs.accountModal) {
this.$refs.accountModal.close()
}
if (this.$root.socket) {
this.$root.socket.off('user_added', this.newUserAdded)
this.$root.socket.off('user_updated', this.userUpdated)

View File

@@ -1,6 +1,6 @@
<template>
<div class="relative w-full" v-click-outside="clickOutsideObj">
<p class="text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<p class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center">
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>

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

@@ -106,12 +106,6 @@ export default {
}
}
if (payload.serverSettings) {
this.$store.commit('setServerSettings', payload.serverSettings)
if (payload.serverSettings.chromecastEnabled) {
console.log('Chromecast enabled import script')
require('@/plugins/chromecast.js').default(this)
}
}
// Start scans currently running
@@ -167,8 +161,28 @@ export default {
libraryUpdated(library) {
this.$store.commit('libraries/addUpdate', library)
},
libraryRemoved(library) {
async libraryRemoved(library) {
this.$store.commit('libraries/remove', library)
// When removed currently selected library then set next accessible library
const currLibraryId = this.$store.state.libraries.currentLibraryId
if (currLibraryId === library.id) {
var nextLibrary = this.$store.getters['libraries/getNextAccessibleLibrary']
if (nextLibrary) {
await this.$store.dispatch('libraries/fetch', nextLibrary.id)
if (this.$route.name.startsWith('config')) {
// No need to refresh
} else if (this.$route.name.startsWith('library')) {
var newRoute = this.$route.path.replace(currLibraryId, nextLibrary.id)
this.$router.push(newRoute)
} else {
this.$router.push(`/library/${nextLibrary.id}`)
}
} else {
console.error('User has no accessible libraries')
}
}
},
libraryItemAdded(libraryItem) {
// this.$store.commit('libraries/updateFilterDataWithAudiobook', libraryItem)
@@ -485,6 +499,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() {
@@ -503,17 +536,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

@@ -54,7 +54,7 @@ export default {
bookCoverAspectRatio: this.bookCoverAspectRatio,
bookshelfView: this.bookshelfView
}
if (this.entityName === 'series-books') props.showSequence = true
if (this.entityName === 'books') {
props.filterBy = this.filterBy
props.orderBy = this.orderBy

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.3",
"version": "2.0.8",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {

View File

@@ -15,8 +15,8 @@
<div class="w-full h-px bg-primary my-4" />
<p class="mb-4 text-lg">Change Password</p>
<form @submit.prevent="submitChangePassword">
<p v-if="!isGuest" class="mb-4 text-lg">Change Password</p>
<form v-if="!isGuest" @submit.prevent="submitChangePassword">
<ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" label="Password" class="my-2" />
<ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" label="New Password" class="my-2" />
<ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" label="Confirm Password" class="my-2" />
@@ -60,6 +60,9 @@ export default {
},
isRoot() {
return this.usertype === 'root'
},
isGuest() {
return this.usertype === 'guest'
}
},
methods: {

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

@@ -95,14 +95,16 @@
<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>
<!-- Podcast episode downloads queue -->
<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>
<span v-if="userIsAdminOrUp" class="material-icons hover:text-error text-xl ml-3 cursor-pointer" @click="clearDownloadQueue">close</span>
</div>
</div>
<!-- Podcast episodes currently downloading -->
<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 />
@@ -150,7 +152,8 @@
<ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" />
</ui-tooltip>
<ui-tooltip v-if="isPodcast" text="Find Episodes" direction="top">
<!-- Only admin or root user can download new episodes -->
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" text="Find Episodes" direction="top">
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
</ui-tooltip>
</div>
@@ -165,7 +168,7 @@
<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" :media="media" />
<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" />
@@ -210,6 +213,12 @@ export default {
}
},
computed: {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
isFile() {
return this.libraryItem.isFile
},
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
},

View File

@@ -48,8 +48,15 @@ export default {
}
},
methods: {
setUser(user, defaultLibraryId) {
this.$store.commit('libraries/setCurrentLibrary', defaultLibraryId)
setUser({ user, userDefaultLibraryId, serverSettings }) {
this.$store.commit('setServerSettings', serverSettings)
if (serverSettings.chromecastEnabled) {
console.log('Chromecast enabled import script')
require('@/plugins/chromecast.js').default(this)
}
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
this.$store.commit('user/setUser', user)
},
async submitForm() {
@@ -69,7 +76,7 @@ export default {
if (authRes && authRes.error) {
this.error = authRes.error
} else if (authRes) {
this.setUser(authRes.user, authRes.userDefaultLibraryId)
this.setUser(authRes)
}
this.processing = false
},
@@ -87,7 +94,7 @@ export default {
}
})
.then((res) => {
this.setUser(res.user, res.userDefaultLibraryId)
this.setUser(res)
this.processing = false
})
.catch((error) => {

View File

@@ -53,8 +53,8 @@
</widgets-alert>
<!-- Item Upload cards -->
<template v-for="(item, index) in items">
<cards-item-upload-card :ref="`itemCard-${item.index}`" :key="index" :media-type="selectedLibraryMediaType" :item="item" :processing="processing" @remove="removeItem(item)" />
<template v-for="item in items">
<cards-item-upload-card :ref="`itemCard-${item.index}`" :key="item.index" :media-type="selectedLibraryMediaType" :item="item" :processing="processing" @remove="removeItem(item)" />
</template>
<!-- Upload/Reset btns -->
@@ -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

@@ -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

@@ -29,6 +29,19 @@ export const getters = {
var library = state.libraries.find(l => l.id === libraryId)
if (!library) return null
return library.provider
},
getNextAccessibleLibrary: (state, getters, rootState, rootGetters) => {
var librariesSorted = getters['getSortedLibraries']()
if (!librariesSorted.length) return null
var canAccessAllLibraries = rootGetters['user/getUserCanAccessAllLibraries']
var userAccessibleLibraries = rootGetters['user/getLibrariesAccessible']
if (canAccessAllLibraries) return librariesSorted[0]
librariesSorted = librariesSorted.filter((lib) => {
return userAccessibleLibraries.includes(lib.id)
})
if (!librariesSorted.length) return null
return librariesSorted[0]
}
}

View File

@@ -16,6 +16,7 @@ export const state = () => ({
export const getters = {
getIsRoot: (state) => state.user && state.user.type === 'root',
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
getToken: (state) => {
return state.user ? state.user.token : null
},

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,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.0.3",
"version": "2.0.8",
"description": "Self-hosted audiobook server for managing and playing audiobooks",
"main": "index.js",
"scripts": {

View File

@@ -74,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
@@ -83,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

@@ -100,6 +100,14 @@ class Auth {
})
}
getUserLoginResponsePayload(user) {
return {
user: user.toJSONForBrowser(),
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
serverSettings: this.db.serverSettings.toJSON()
}
}
async login(req, res) {
var username = (req.body.username || '').toLowerCase()
var password = req.body.password || ''
@@ -120,17 +128,14 @@ class Auth {
if (password) {
return res.status(401).send('Invalid root password (hint: there is none)')
} else {
return res.json({ user: user.toJSONForBrowser(), userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries) })
return res.json(this.getUserLoginResponsePayload(user))
}
}
// Check password match
var compare = await bcrypt.compare(password, user.pash)
if (compare) {
res.json({
user: user.toJSONForBrowser(),
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries)
})
res.json(this.getUserLoginResponsePayload(user))
} else {
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
if (req.rateLimit.remaining <= 2) {

View File

@@ -411,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

@@ -409,6 +409,7 @@ class Server {
await this.db.updateEntity('user', user)
const initialPayload = {
// TODO: this is sent with user auth now, update mobile app to use that then remove this
serverSettings: this.db.serverSettings.toJSON(),
audiobookPath: global.AudiobookPath,
metadataPath: global.MetadataPath,

View File

@@ -201,7 +201,6 @@ class LibraryController {
libraryItems = naturalSort(libraryItems).by(sortArray)
}
// TODO: Potentially implement collapse series again
if (payload.collapseseries) {
libraryItems = libraryHelpers.collapseBookSeries(libraryItems)
payload.total = libraryItems.length
@@ -319,102 +318,6 @@ class LibraryController {
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 itemsWithUserProgress = libraryHelpers.getMediaProgressWithItems(req.user, libraryItems)
var categories = [
{
id: 'continue-listening',
label: 'Continue Listening',
type: isPodcastLibrary ? 'episode' : req.library.mediaType,
entities: libraryHelpers.getItemsMostRecentlyListened(itemsWithUserProgress, limitPerShelf, minified)
},
{
id: 'recently-added',
label: 'Recently Added',
type: req.library.mediaType,
entities: libraryHelpers.getItemsMostRecentlyAdded(libraryItems, limitPerShelf, minified)
},
{
id: 'listen-again',
label: 'Listen Again',
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) {
var series = this.db.series.map(se => {
var books = libraryItems.filter(li => li.media.metadata.hasSeries(se.id))
if (!books.length) return null
books = books.map(b => {
var json = b.toJSONMinified()
json.sequence = b.media.metadata.getSeriesSequence(se.id)
return json
})
books = naturalSort(books).asc(b => b.sequence)
return {
id: se.id,
name: se.name,
type: 'series',
addedAt: se.addedAt,
books
}
}).filter(se => se).sort((a, b) => a.addedAt - b.addedAt).slice(0, 5)
if (series.length) {
categories.push({
id: 'recent-series',
label: 'Recent Series',
type: 'series',
entities: series
})
}
var authors = this.db.authors.map(author => {
var books = libraryItems.filter(li => li.media.metadata.hasAuthor(author.id))
if (!books.length) return null
// books = books.map(b => b.toJSONMinified())
return {
...author.toJSON(),
numBooks: books.length
}
}).filter(au => au).sort((a, b) => a.addedAt - b.addedAt).slice(0, 10)
if (authors.length) {
categories.push({
id: 'newest-authors',
label: 'Newest Authors',
type: 'authors',
entities: authors
})
}
} 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
})
}
}
res.json(categories)
}
// PATCH: Change the order of libraries
async reorder(req, res) {
if (!req.user.isRoot) {

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 => {
@@ -336,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

@@ -133,6 +133,10 @@ class MeController {
// PATCH: api/me/password
updatePassword(req, res) {
if (req.user.isGuest) {
Logger.error(`[MeController] Guest user attempted to change password`, req.user.username)
return res.sendStatus(500)
}
this.auth.userChangePassword(req, res)
}

View File

@@ -230,7 +230,12 @@ class MiscController {
Logger.error('Invalid user in authorize')
return res.sendStatus(401)
}
res.json({ user: req.user, userDefaultLibraryId: req.user.getDefaultLibraryId(this.db.libraries) })
const userResponse = {
user: req.user,
userDefaultLibraryId: req.user.getDefaultLibraryId(this.db.libraries),
serverSettings: this.db.serverSettings.toJSON()
}
res.json(userResponse)
}
getAllTags(req, res) {

View File

@@ -9,8 +9,8 @@ const filePerms = require('../utils/filePerms')
class PodcastController {
async create(req, res) {
if (!req.user.isRoot) {
Logger.error(`[PodcastController] Non-root user attempted to create podcast`, req.user)
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user attempted to create podcast`, req.user)
return res.sendStatus(500)
}
const payload = req.body
@@ -115,24 +115,33 @@ class PodcastController {
}
async checkNewEpisodes(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user attempted to check/download episodes`, req.user)
return res.sendStatus(500)
}
var libraryItem = this.db.getLibraryItem(req.params.id)
if (!libraryItem || libraryItem.mediaType !== 'podcast') {
return res.sendStatus(404)
}
if (!req.user.checkCanAccessLibrary(libraryItem.libraryId)) {
Logger.error(`[PodcastController] User attempted to check/download episodes for a library without permission`, req.user)
return res.sendStatus(500)
}
if (!libraryItem.media.metadata.feedUrl) {
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${libraryItem.id}`)
return res.status(500).send('Podcast has no rss feed url')
}
var newEpisodes = await this.podcastManager.checkPodcastForNewEpisodes(libraryItem)
var newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(libraryItem)
res.json({
episodes: newEpisodes || []
})
}
clearEpisodeDownloadQueue(req, res) {
if (!req.user.canUpdate) {
Logger.error(`[PodcastController] User attempting to clear download queue without permission "${req.user.username}"`)
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user attempting to clear download queue "${req.user.username}"`)
return res.sendStatus(500)
}
this.podcastManager.clearDownloadQueue(req.params.id)
@@ -151,11 +160,17 @@ class PodcastController {
}
async downloadEpisodes(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user)
return res.sendStatus(500)
}
var libraryItem = this.db.getLibraryItem(req.params.id)
if (!libraryItem || libraryItem.mediaType !== 'podcast') {
return res.sendStatus(404)
}
if (!req.user.canUpload || !req.user.checkCanAccessLibrary(libraryItem.libraryId)) {
if (!req.user.checkCanAccessLibrary(libraryItem.libraryId)) {
Logger.error(`[PodcastController] User attempted to download episodes for library without permission`, req.user)
return res.sendStatus(404)
}

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)

View File

@@ -208,8 +208,27 @@ class PodcastManager {
}
// Filter new and not already has
var newEpisodes = feed.episodes.filter(ep => ep.publishedAt > podcastLibraryItem.media.lastEpisodeCheck && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url))
// Max new episodes for safety = 2
newEpisodes = newEpisodes.slice(0, 2)
// Max new episodes for safety = 3
newEpisodes = newEpisodes.slice(0, 3)
return newEpisodes
}
async checkAndDownloadNewEpisodes(libraryItem) {
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem)
if (newEpisodes.length) {
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
this.downloadPodcastEpisodes(libraryItem, newEpisodes)
} else {
Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.metadata.title}"`)
}
libraryItem.media.lastEpisodeCheck = Date.now()
libraryItem.updatedAt = Date.now()
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
return newEpisodes
}

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

@@ -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

@@ -79,7 +79,7 @@ class ServerSettings {
this.backupSchedule = settings.backupSchedule || false
this.backupsToKeep = settings.backupsToKeep || 2
this.maxBackupSize = settings.maxBackupSize || 1
this.maxBackupSize = settings.maxBackupSize || 1
this.backupMetadataCovers = settings.backupMetadataCovers !== false
this.loggerDailyLogsToKeep = settings.loggerDailyLogsToKeep || 7

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

@@ -30,6 +30,15 @@ class User {
get isRoot() {
return this.type === 'root'
}
get isAdmin() {
return this.type === 'admin'
}
get isGuest() {
return this.type === 'guest'
}
get isAdminOrUp() {
return this.isAdmin || this.isRoot
}
get canDelete() {
return !!this.permissions.delete && this.isActive
}
@@ -57,7 +66,7 @@ class User {
mobileOrderBy: 'recent',
mobileOrderDesc: true,
mobileFilterBy: 'all',
orderBy: 'book.title',
orderBy: 'media.metadata.title',
orderDesc: false,
filterBy: 'all',
playbackRate: 1,
@@ -186,6 +195,7 @@ class User {
}
}
})
// And update permissions
if (payload.permissions) {
for (const key in payload.permissions) {
@@ -195,8 +205,15 @@ class User {
}
}
}
// Update accessible libraries
if (payload.librariesAccessible !== undefined) {
if (this.permissions.accessAllLibraries) {
// Access all libraries
if (this.librariesAccessible.length) {
this.librariesAccessible = []
hasUpdates = true
}
} else if (payload.librariesAccessible !== undefined) {
if (payload.librariesAccessible.length) {
if (payload.librariesAccessible.join(',') !== this.librariesAccessible.join(',')) {
hasUpdates = true
@@ -208,8 +225,14 @@ class User {
}
}
// Update accessible libraries
if (payload.itemTagsAccessible !== undefined) {
// Update accessible tags
if (this.permissions.accessAllTags) {
// Access all tags
if (this.itemTagsAccessible.length) {
this.itemTagsAccessible = []
hasUpdates = true
}
} else if (payload.itemTagsAccessible !== undefined) {
if (payload.itemTagsAccessible.length) {
if (payload.itemTagsAccessible.join(',') !== this.itemTagsAccessible.join(',')) {
hasUpdates = true

View File

@@ -61,7 +61,6 @@ class ApiRouter {
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-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))

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)
@@ -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

@@ -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

@@ -20,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)
@@ -37,18 +37,22 @@ 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 === '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)
@@ -132,80 +136,6 @@ module.exports = {
})
},
getSeriesWithProgressFromBooks(user, books) {
return []
// var _series = {}
// books.forEach((audiobook) => {
// if (audiobook.book.series) {
// var bookWithUserAb = { userAudiobook: user.getMediaProgress(audiobook.id), book: audiobook }
// if (!_series[audiobook.book.series]) {
// _series[audiobook.book.series] = {
// id: audiobook.book.series,
// name: audiobook.book.series,
// type: 'series',
// books: [bookWithUserAb]
// }
// } else {
// _series[audiobook.book.series].books.push(bookWithUserAb)
// }
// }
// })
// return Object.values(_series).map((series) => {
// series.books = naturalSort(series.books).asc(ab => ab.book.book.volumeNumber)
// return series
// }).filter((series) => series.books.some((book) => book.userAudiobook && book.userAudiobook.isRead))
},
sortSeriesBooks(books, seriesId, minified = false) {
return naturalSort(books).asc(li => {
if (!li.media.metadata.series) return null
var series = li.media.metadata.series.find(se => se.id === seriesId)
if (!series) return null
return series.sequence
}).map(li => {
if (minified) return li.toJSONMinified()
return li.toJSONExpanded()
})
},
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) {
var itemsInProgress = itemsWithUserProgress.filter((data) => data.userProgress && data.userProgress.progress > 0 && !data.userProgress.isFinished)
itemsInProgress.sort((a, b) => {
return b.userProgress.lastUpdate - a.userProgress.lastUpdate
})
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) {
var incompleteSeires = seriesWithUserAb.filter((series) => series.books.some((book) => !book.userAudiobook || (!book.userAudiobook.isRead && book.userAudiobook.progress == 0)))
var booksNextInSeries = []
@@ -218,49 +148,6 @@ module.exports = {
return booksNextInSeries.sort((a, b) => { return b.DateLastReadSeries - a.DateLastReadSeries }).map(b => minified ? b.book.toJSONMinified() : b.book.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 => {
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) {
var seriesSortedByAddedAt = sort(series).desc(_series => {
var booksSortedByMostRecent = sort(_series.books).desc(b => b.addedAt)
return booksSortedByMostRecent[0].addedAt
})
return seriesSortedByAddedAt.slice(0, limit)
},
getGenresWithCount(libraryItems) {
var genresMap = {}
libraryItems.forEach((li) => {
@@ -350,7 +237,6 @@ module.exports = {
buildPersonalizedShelves(user, libraryItems, mediaType, allSeries, allAuthors, maxEntitiesPerShelf = 10) {
const isPodcastLibrary = mediaType === 'podcast'
const shelves = [
{
id: 'continue-listening',
@@ -359,6 +245,13 @@ module.exports = {
entities: [],
category: 'recentlyListened'
},
{
id: 'continue-series',
label: 'Continue Series',
type: mediaType,
entities: [],
category: 'continueSeries'
},
{
id: 'recently-added',
label: 'Recently Added',
@@ -396,7 +289,7 @@ module.exports = {
}
]
const categories = ['recentlyListened', 'newestEpisodes', 'newestItems', 'newestSeries', 'recentlyFinished', 'newestAuthors']
const categories = ['recentlyListened', 'continueSeries', 'newestEpisodes', 'newestItems', 'newestSeries', 'recentlyFinished', 'newestAuthors']
const categoryMap = {}
categories.forEach((cat) => {
categoryMap[cat] = {
@@ -512,20 +405,24 @@ module.exports = {
// Newest series
if (libraryItem.media.metadata.series.length) {
for (const librarySeries of libraryItem.media.metadata.series) {
const mediaProgress = allItemProgress.length ? allItemProgress[0] : null
const bookInProgress = mediaProgress && mediaProgress.inProgress
const libraryItemJson = libraryItem.toJSONMinified()
libraryItemJson.seriesSequence = librarySeries.sequence
if (!seriesMap[librarySeries.id]) {
const seriesObj = allSeries.find(se => se.id === librarySeries.id)
if (seriesObj) {
var series = {
...seriesObj.toJSON(),
books: []
books: [libraryItemJson],
inProgress: bookInProgress,
bookInProgressLastUpdate: bookInProgress ? mediaProgress.lastUpdate : null,
sequenceInProgress: bookInProgress ? libraryItemJson.seriesSequence : null
}
seriesMap[librarySeries.id] = series
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)
@@ -540,15 +437,19 @@ module.exports = {
}
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)
if (bookInProgress) { // Update if this series is in progress
seriesMap[librarySeries.id].inProgress = true
if (!seriesMap[librarySeries.id].sequenceInProgress) {
seriesMap[librarySeries.id].sequenceInProgress = librarySeries.sequence
seriesMap[librarySeries.id].bookInProgressLastUpdate = mediaProgress.lastUpdate
}
}
}
}
}
@@ -639,6 +540,38 @@ module.exports = {
}
}
// For Continue Series - Find next book in series for series that are in progress
for (const seriesId in seriesMap) {
if (seriesMap[seriesId].inProgress) {
seriesMap[seriesId].books = naturalSort(seriesMap[seriesId].books).asc(li => li.seriesSequence)
const nextBookInSeries = seriesMap[seriesId].books.find(li => {
if (!seriesMap[seriesId].sequenceInProgress) return true
// True if book series sequence is greater than the current book sequence in progress
return String(li.seriesSequence).localeCompare(String(seriesMap[seriesId].sequenceInProgress), undefined, { sensitivity: 'base', numeric: true }) > 0
})
if (nextBookInSeries) {
const bookForContinueSeries = {
...nextBookInSeries,
prevBookInProgressLastUpdate: seriesMap[seriesId].bookInProgressLastUpdate
}
bookForContinueSeries.media.metadata.series = {
id: seriesId,
name: seriesMap[seriesId].name,
sequence: nextBookInSeries.seriesSequence
}
const indexToPut = categoryMap.continueSeries.items.findIndex(i => i.prevBookInProgressLastUpdate < bookForContinueSeries.prevBookInProgressLastUpdate)
if (indexToPut >= 0) {
categoryMap.continueSeries.items.splice(indexToPut, 0, bookForContinueSeries)
} else if (categoryMap.continueSeries.items.length < 10) { // Max 10 books
categoryMap.continueSeries.items.push(bookForContinueSeries)
}
}
}
}
// Sort series books by sequence
if (categoryMap.newestSeries.items.length) {
for (const seriesItem of categoryMap.newestSeries.items) {

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)