Compare commits

...

85 Commits

Author SHA1 Message Date
advplyr ef0243f1d7 Version bump 2.0.10 2022-05-03 19:35:30 -05:00
advplyr 7a7d53f92e Update:Close author modal on update 2022-05-03 19:33:00 -05:00
advplyr 2e070227ab Update:Give full permissions to admin users except updating root or viewing root api token #137 2022-05-03 19:16:16 -05:00
advplyr 195a30096f Update:Experimental RSS feed setting custom slugs with default to library item id #553 2022-05-03 18:52:34 -05:00
advplyr 55c40658f2 Add:Sort by duration for audiobooks and sort by number of episodes for podcasts #558 2022-05-03 17:50:19 -05:00
advplyr db48a486e5 Fix:Drag and drop upload limits to 100 items per folder #560 2022-05-03 17:41:49 -05:00
advplyr d869a9836e Add:More menu for podcast episode cards with Mark as Finished and Edit Podcast #559 2022-05-03 17:21:22 -05:00
advplyr 55680cbc98 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-03 16:30:54 -05:00
advplyr 9b7e6a6058 Fix:Linux build script to use node16 2022-05-03 16:30:49 -05:00
advplyr a482e5d316 Merge pull request #555 from selfhost-alt/handle-corrupted-backups
Handle corrupted backups gracefully and continue loading other backups
2022-05-03 06:48:08 -05:00
Selfhost Alt 5ac342defd Handle corrupted backups gracefully and continue loading other backups 2022-05-02 22:47:16 -07:00
advplyr 944a5b3e92 Version bump v2.0.9 2022-05-02 19:04:57 -05:00
advplyr 9b9de84740 Add:Experimental embed audio metadata page 2022-05-02 18:48:00 -05:00
advplyr 2746e61cb3 Fix:Authors card hide edit & search icon for users without edit permission #549 2022-05-02 17:32:52 -05:00
advplyr 7f1d797fb2 Update:Submit edit details closes modal 2022-05-02 17:31:02 -05:00
advplyr 2059c9f14a Fix:Podcast RSS feed require fs 2022-05-02 17:21:16 -05:00
advplyr 0e16a9c8de Update:Many more debug logs for auto-download podcasts, add timeout for feed request, use anonymous function in cron job 2022-05-02 17:17:26 -05:00
advplyr b6a33bf7bb Merge pull request #551 from jflattery/main
docker compose and run changes
2022-05-02 16:58:03 -05:00
jflattery ce88ac9f33 Revert "add version number"
This reverts commit d4cd8c6db9.
2022-05-02 21:48:28 +00:00
advplyr 678dceefed Add:Experimental generate podcast RSS feed #553 2022-05-02 16:42:30 -05:00
advplyr 8b38dda229 Add:experimental generate podcast feed for testing 2022-05-02 14:41:59 -05:00
advplyr 7373c7159b Add additional logs during podcast episode checks and allow up to 3 failed feed requests 2022-05-01 19:54:33 -05:00
advplyr e34a39dde4 Update:Edit modal merge tab to manage 2022-05-01 19:39:52 -05:00
jflattery d4cd8c6db9 add version number 2022-05-02 00:38:24 +00:00
jflattery 9e93a3c7e6 align docker compose with run 2022-05-02 00:09:47 +00:00
advplyr 4a8bcc90ea Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-01 18:33:51 -05:00
advplyr 84c12a6e7e Add:Experimental embed metadata in audio files #141 2022-05-01 18:33:46 -05:00
advplyr 2a513ac8b8 Merge pull request #550 from mediacowboy/master
Docker Compose Update Instructions
2022-05-01 16:37:48 -05:00
MediaCowboy 97687c96cd Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-01 15:57:03 -05:00
MediaCowboy a42c13aec2 Docker Compose Update 2022-05-01 15:56:57 -05:00
advplyr 5f0f8b92d1 Fix:Continue series home page shelf to check for finished books in series #545 2022-05-01 15:31:07 -05:00
advplyr 78ca6aa679 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-01 15:12:26 -05:00
advplyr 22e3d4a150 Fix:Account tags accessible #542 2022-05-01 15:12:21 -05:00
advplyr e3fba1fb2b Merge pull request #548 from BeastleeUK/patch-1
Temp Fix for Unknown Error in App with Traefik
2022-05-01 13:46:20 -05:00
BeastleeUK 4d95250990 Temp Fix for Unknown Error in App with Traefik 2022-05-01 19:44:30 +01:00
advplyr 4776368501 Update docker-build.yml 2022-05-01 12:51:20 -05:00
advplyr 8b0ed2bf29 Update:readme ubuntu install section to point to website install docs 2022-05-01 12:40:28 -05:00
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
79 changed files with 1945 additions and 640 deletions
+76
View File
@@ -0,0 +1,76 @@
---
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
# 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
+1 -1
View File
@@ -48,7 +48,7 @@ Description: $DESCRIPTION"
echo "$controlfile" > dist/debian/DEBIAN/control; echo "$controlfile" > dist/debian/DEBIAN/control;
# Package debian # Package debian
pkg -t node12-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf . pkg -t node16-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
fakeroot dpkg-deb --build dist/debian fakeroot dpkg-deb --build dist/debian
+13 -7
View File
@@ -30,7 +30,7 @@
<span class="material-icons" aria-label="Upload Media" role="button">upload</span> <span class="material-icons" aria-label="Upload Media" role="button">upload</span>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1"> <nuxt-link v-if="userIsAdminOrUp" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons" aria-label="System Settings" role="button">settings</span> <span class="material-icons" aria-label="System Settings" role="button">settings</span>
</nuxt-link> </nuxt-link>
@@ -54,10 +54,16 @@
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" /> <ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
</ui-tooltip> </ui-tooltip>
<template v-if="userCanUpdate && numLibraryItemsSelected < 50"> <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> </template>
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" /> <ui-tooltip v-if="userCanDelete" text="Delete" 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-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> </div>
</div> </div>
@@ -94,8 +100,8 @@ export default {
user() { user() {
return this.$store.state.user.user return this.$store.state.user.user
}, },
isRootUser() { userIsAdminOrUp() {
return this.$store.getters['user/getIsRoot'] return this.$store.getters['user/getIsAdminOrUp']
}, },
username() { username() {
return this.user ? this.user.username : 'err' return this.user ? this.user.username : 'err'
@@ -229,4 +235,4 @@ export default {
#appbar { #appbar {
box-shadow: 0px 5px 5px #11111155; box-shadow: 0px 5px 5px #11111155;
} }
</style> </style>
@@ -7,9 +7,9 @@
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div> <div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
</div> </div>
<div v-if="loaded && !shelves.length && isRootUser && !search" class="w-full flex flex-col items-center justify-center py-12"> <div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p> <p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
<div class="flex"> <div v-if="userIsAdminOrUp" class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn> <ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn> <ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
</div> </div>
@@ -44,8 +44,8 @@ export default {
} }
}, },
computed: { computed: {
isRootUser() { userIsAdminOrUp() {
return this.$store.getters['user/getIsRoot'] return this.$store.getters['user/getIsAdminOrUp']
}, },
showExperimentalFeatures() { showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures return this.$store.state.showExperimentalFeatures
@@ -128,8 +128,7 @@ export default {
type: 'series', type: 'series',
entities: this.results.series.map((seriesObj) => { entities: this.results.series.map((seriesObj) => {
return { return {
name: seriesObj.series.name, ...seriesObj.series,
series: seriesObj.series,
books: seriesObj.books, books: seriesObj.books,
type: 'series' type: 'series'
} }
+6 -6
View File
@@ -4,12 +4,12 @@
<div class="w-full h-full pt-6"> <div class="w-full h-full pt-6">
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center"> <div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
<template v-for="(entity, index) in shelf.entities"> <template v-for="(entity, index) in shelf.entities">
<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" /> <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="editItem" />
</template> </template>
</div> </div>
<div v-if="shelf.type === 'episode'" class="flex items-center"> <div v-if="shelf.type === 'episode'" class="flex items-center">
<template v-for="(entity, index) in shelf.entities"> <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" /> <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" @editPodcast="editItem" @edit="editEpisode" />
</template> </template>
</div> </div>
<div v-if="shelf.type === 'series'" class="flex items-center"> <div v-if="shelf.type === 'series'" class="flex items-center">
@@ -101,10 +101,10 @@ export default {
this.selectedAuthor = author this.selectedAuthor = author
this.showAuthorModal = true this.showAuthorModal = true
}, },
editBook(audiobook) { editItem(libraryItem) {
var bookIds = this.shelf.entities.map((e) => e.id) var itemIds = this.shelf.entities.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds) this.$store.commit('setBookshelfBookIds', itemIds)
this.$store.commit('showEditModal', audiobook) this.$store.commit('showEditModal', libraryItem)
}, },
editEpisode({ libraryItem, episode }) { editEpisode({ libraryItem, episode }) {
this.$store.commit('setSelectedLibraryItem', libraryItem) this.$store.commit('setSelectedLibraryItem', libraryItem)
+3 -3
View File
@@ -25,11 +25,11 @@ export default {
return {} return {}
}, },
computed: { computed: {
userIsRoot() { userIsAdminOrUp() {
return this.$store.getters['user/getIsRoot'] return this.$store.getters['user/getIsAdminOrUp']
}, },
configRoutes() { configRoutes() {
if (!this.userIsRoot) { if (!this.userIsAdminOrUp) {
return [ return [
{ {
id: 'config-stats', id: 'config-stats',
+4 -4
View File
@@ -6,9 +6,9 @@
</div> </div>
</template> </template>
<div v-if="initialized && !totalShelves && !hasFilter && isRootUser && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12"> <div v-if="initialized && !totalShelves && !hasFilter && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p> <p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
<div class="flex"> <div v-if="userIsAdminOrUp" class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn> <ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn> <ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
</div> </div>
@@ -79,8 +79,8 @@ export default {
} }
}, },
computed: { computed: {
isRootUser() { userIsAdminOrUp() {
return this.$store.getters['user/getIsRoot'] return this.$store.getters['user/getIsAdminOrUp']
}, },
showExperimentalFeatures() { showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures return this.$store.state.showExperimentalFeatures
+4 -1
View File
@@ -52,7 +52,7 @@
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </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" /> <icons-podcast-svg class="w-6 h-6" />
<p class="font-book pt-1.5" style="font-size: 0.9rem">Search</p> <p class="font-book pt-1.5" style="font-size: 0.9rem">Search</p>
@@ -82,6 +82,9 @@ export default {
showExperimentalFeatures() { showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures return this.$store.state.showExperimentalFeatures
}, },
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
paramId() { paramId() {
return this.$route.params ? this.$route.params.id || '' : '' return this.$route.params ? this.$route.params.id || '' : ''
}, },
+5 -2
View File
@@ -11,10 +11,10 @@
</div> </div>
<!-- Search icon btn --> <!-- Search icon btn -->
<div v-show="!searching && isHovering" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="searchAuthor"> <div v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="searchAuthor">
<span class="material-icons text-lg">search</span> <span class="material-icons text-lg">search</span>
</div> </div>
<div v-show="isHovering && !searching" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="$emit('edit', author)"> <div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="$emit('edit', author)">
<span class="material-icons text-lg">edit</span> <span class="material-icons text-lg">edit</span>
</div> </div>
@@ -65,6 +65,9 @@ export default {
}, },
numBooks() { numBooks() {
return this._author.numBooks || 0 return this._author.numBooks || 0
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
} }
}, },
methods: { methods: {
+34 -9
View File
@@ -60,7 +60,8 @@
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span> <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>
<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"> <!-- More Menu Icon -->
<div ref="moreIcon" v-show="!isSelectionMode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-100" :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> <span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
</div> </div>
</div> </div>
@@ -78,7 +79,7 @@
</ui-tooltip> </ui-tooltip>
<!-- Series sequence --> <!-- 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> <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
</div> </div>
@@ -110,7 +111,6 @@ export default {
default: 192 default: 192
}, },
bookCoverAspectRatio: Number, bookCoverAspectRatio: Number,
showSequence: Boolean,
bookshelfView: Number, bookshelfView: Number,
bookMount: { bookMount: {
// Book can be passed as prop or set with setEntity() // Book can be passed as prop or set with setEntity()
@@ -150,6 +150,10 @@ export default {
_libraryItem() { _libraryItem() {
return this.libraryItem || {} return this.libraryItem || {}
}, },
isFile() {
// Library item is not in a folder
return this._libraryItem.isFile
},
media() { media() {
return this._libraryItem.media || {} return this._libraryItem.media || {}
}, },
@@ -172,7 +176,7 @@ export default {
return this._libraryItem.id return this._libraryItem.id
}, },
series() { 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 return this.mediaMetadata.series
}, },
seriesSequence() { seriesSequence() {
@@ -252,8 +256,9 @@ export default {
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs) if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs) if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt) if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false) if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size) if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
return null return null
}, },
episodeProgress() { episodeProgress() {
@@ -338,10 +343,23 @@ export default {
userCanDownload() { userCanDownload() {
return this.store.getters['user/getUserCanDownload'] return this.store.getters['user/getUserCanDownload']
}, },
userIsRoot() { userIsAdminOrUp() {
return this.store.getters['user/getIsRoot'] return this.$store.getters['user/getIsAdminOrUp']
}, },
moreMenuItems() { moreMenuItems() {
if (this.recentEpisode) {
return [
{
func: 'editPodcast',
text: 'Edit Podcast'
},
{
func: 'toggleFinished',
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
}
]
}
var items = [] var items = []
if (!this.isPodcast) { if (!this.isPodcast) {
items = [ items = [
@@ -365,7 +383,7 @@ export default {
text: 'Match' text: 'Match'
}) })
} }
if (this.userIsRoot) { if (this.userIsAdminOrUp && !this.isFile) {
items.push({ items.push({
func: 'rescan', func: 'rescan',
text: 'Re-Scan' text: 'Re-Scan'
@@ -444,10 +462,14 @@ export default {
isFinished: !this.itemIsFinished isFinished: !this.itemIsFinished
} }
this.isProcessingReadUpdate = true this.isProcessingReadUpdate = true
var apiEndpoint = `/api/me/progress/${this.libraryItemId}`
if (this.recentEpisode) apiEndpoint += `/${this.recentEpisode.id}`
var toast = this.$toast || this.$nuxt.$toast var toast = this.$toast || this.$nuxt.$toast
var axios = this.$axios || this.$nuxt.$axios var axios = this.$axios || this.$nuxt.$axios
axios axios
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload) .$patch(apiEndpoint, updatePayload)
.then(() => { .then(() => {
this.isProcessingReadUpdate = false this.isProcessingReadUpdate = false
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`) toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
@@ -458,6 +480,9 @@ export default {
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`) toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
}) })
}, },
editPodcast() {
this.$emit('editPodcast', this.libraryItem)
},
rescan() { rescan() {
this.rescanning = true this.rescanning = true
this.$axios this.$axios
@@ -52,6 +52,10 @@ export default {
text: 'Size', text: 'Size',
value: 'size' value: 'size'
}, },
{
text: 'Duration',
value: 'media.duration'
},
{ {
text: 'File Birthtime', text: 'File Birthtime',
value: 'birthtimeMs' value: 'birthtimeMs'
@@ -78,6 +82,10 @@ export default {
text: 'Size', text: 'Size',
value: 'size' value: 'size'
}, },
{
text: '# of Episodes',
value: 'media.numTracks'
},
{ {
text: 'File Birthtime', text: 'File Birthtime',
value: 'birthtimeMs' value: 'birthtimeMs'
@@ -131,6 +139,9 @@ export default {
this.selectedDesc = !this.selectedDesc this.selectedDesc = !this.selectedDesc
} else { } else {
this.selected = val 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.showMenu = false
this.$nextTick(() => this.$emit('change', val)) this.$nextTick(() => this.$emit('change', val))
+8
View File
@@ -44,6 +44,14 @@ export default {
this.$nextTick(this.init) this.$nextTick(this.init)
} }
} }
},
width: {
handler(newVal) {
if (newVal) {
this.isInit = false
this.$nextTick(this.init)
}
}
} }
}, },
computed: { computed: {
+35 -15
View File
@@ -1,5 +1,5 @@
<template> <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> <template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden"> <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> <p class="font-book text-3xl text-white truncate">{{ title }}</p>
@@ -8,20 +8,20 @@
<form @submit.prevent="submitForm"> <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="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="w-full p-8">
<div class="flex py-2 -mx-2"> <div class="flex py-2">
<div class="w-1/2 px-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>
<div class="w-1/2 px-2"> <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> </div>
<div class="flex py-2"> <div v-show="!isEditingRoot" class="flex py-2">
<div class="px-2"> <div class="px-2 w-52">
<ui-input-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :editable="false" :items="accountTypes" @input="userTypeUpdated" /> <ui-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :items="accountTypes" @input="userTypeUpdated" />
</div> </div>
<div class="flex-grow" /> <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> <p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">Is Active</p>
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" /> <ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
</div> </div>
@@ -86,13 +86,13 @@
<ui-toggle-switch v-model="newUser.permissions.accessAllTags" @input="accessAllTagsToggled" /> <ui-toggle-switch v-model="newUser.permissions.accessAllTags" @input="accessAllTagsToggled" />
</div> </div>
</div> </div>
<div v-if="!newUser.permissions.accessAllTags" class="my-4"> <div v-if="!newUser.permissions.accessAllTags" class="my-4">
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" label="Tags Accessible to User" /> <ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" label="Tags Accessible to User" />
</div> </div>
</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" /> <div class="flex-grow" />
<ui-btn color="success" type="submit">Submit</ui-btn> <ui-btn color="success" type="submit">Submit</ui-btn>
</div> </div>
@@ -116,7 +116,20 @@ export default {
processing: false, processing: false,
newUser: {}, newUser: {},
isNew: true, isNew: true,
accountTypes: ['guest', 'user', 'admin'], accountTypes: [
{
text: 'Guest',
value: 'guest'
},
{
text: 'User',
value: 'user'
},
{
text: 'Admin',
value: 'admin'
}
],
tags: [], tags: [],
loadingTags: false loadingTags: false
} }
@@ -124,6 +137,7 @@ export default {
watch: { watch: {
show: { show: {
handler(newVal) { handler(newVal) {
console.log('accoutn modal show change', newVal)
if (newVal) { if (newVal) {
this.init() this.init()
} }
@@ -140,7 +154,7 @@ export default {
} }
}, },
title() { title() {
return this.isNew ? 'Add New Account' : `Update Account: ${(this.account || {}).username}` return this.isNew ? 'Add New Account' : `Update ${(this.account || {}).username}`
}, },
isEditingRoot() { isEditingRoot() {
return this.account && this.account.type === 'root' return this.account && this.account.type === 'root'
@@ -161,10 +175,12 @@ export default {
} }
}, },
methods: { methods: {
close() {
// Force close when navigating - used in UsersTable
if (this.$refs.modal) this.$refs.modal.setHide()
},
accessAllTagsToggled(val) { accessAllTagsToggled(val) {
if (!val && !this.newUser.itemTagsAccessible.length) { if (val && this.newUser.itemTagsAccessible.length) {
this.newUser.itemTagsAccessible = this.libraries.map((l) => l.id)
} else if (val && this.newUser.itemTagsAccessible.length) {
this.newUser.itemTagsAccessible = [] this.newUser.itemTagsAccessible = []
} }
}, },
@@ -197,6 +213,10 @@ export default {
this.$toast.error('Must select at least one library') this.$toast.error('Must select at least one library')
return return
} }
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsAccessible.length) {
this.$toast.error('Must select at least one tag')
return
}
if (this.isNew) { if (this.isNew) {
this.submitCreateAccount() this.submitCreateAccount()
@@ -112,8 +112,10 @@ export default {
return null return null
}) })
if (result) { if (result) {
if (result.updated) this.$toast.success('Author updated') if (result.updated) {
else this.$toast.info('No updates were needed') this.$toast.success('Author updated')
this.show = false
} else this.$toast.info('No updates were needed')
} }
this.processing = false this.processing = false
}, },
+6 -6
View File
@@ -62,9 +62,9 @@ export default {
component: 'modals-item-tabs-match' component: 'modals-item-tabs-match'
}, },
{ {
id: 'merge', id: 'manage',
title: 'Merge', title: 'Manage',
component: 'modals-item-tabs-merge', component: 'modals-item-tabs-manage',
experimental: true experimental: true
} }
] ]
@@ -123,12 +123,12 @@ export default {
if (!this.userCanUpdate && !this.userCanDownload) return [] if (!this.userCanUpdate && !this.userCanDownload) return []
return this.tabs.filter((tab) => { return this.tabs.filter((tab) => {
if (tab.experimental && !this.showExperimentalFeatures) return false if (tab.experimental && !this.showExperimentalFeatures) return false
if (tab.id === 'merge' && (this.isMissing || this.mediaType !== 'book')) return false if (tab.id === 'manage' && (this.isMissing || this.mediaType !== 'book')) return false
if (this.mediaType == 'podcast' && tab.id == 'chapters') return false if (this.mediaType == 'podcast' && tab.id == 'chapters') return false
if (this.mediaType == 'book' && tab.id == 'episodes') return false if (this.mediaType == 'book' && tab.id == 'episodes') return false
if ((tab.id === 'merge' || tab.id === 'files') && this.userCanDownload) return true if ((tab.id === 'manage' || tab.id === 'files') && this.userCanDownload) return true
if (tab.id !== 'merge' && tab.id !== 'files' && this.userCanUpdate) return true if (tab.id !== 'manage' && tab.id !== 'files' && this.userCanUpdate) return true
if (tab.id === 'match' && this.userCanUpdate) return true if (tab.id === 'match' && this.userCanUpdate) return true
return false return false
}) })
@@ -10,11 +10,11 @@
<div class="flex-grow" /> <div class="flex-grow" />
<ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4"> <ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn> <ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
</ui-tooltip> </ui-tooltip>
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4"> <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="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
</ui-tooltip> </ui-tooltip>
<ui-btn @click="submitForm">Submit</ui-btn> <ui-btn @click="submitForm">Submit</ui-btn>
@@ -49,8 +49,11 @@ export default {
this.$emit('update:processing', val) this.$emit('update:processing', val)
} }
}, },
isRootUser() { isFile() {
return this.$store.getters['user/getIsRoot'] return !!this.libraryItem && this.libraryItem.isFile
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
}, },
isMissing() { isMissing() {
return !!this.libraryItem && !!this.libraryItem.isMissing return !!this.libraryItem && !!this.libraryItem.isMissing
@@ -163,7 +166,7 @@ export default {
if (updateResult) { if (updateResult) {
if (updateResult.updated) { if (updateResult.updated) {
this.$toast.success('Item details updated') this.$toast.success('Item details updated')
// this.$emit('close') this.$emit('close')
} else { } else {
this.$toast.info('No updates were necessary') this.$toast.info('No updates were necessary')
} }
@@ -1,11 +1,11 @@
<template> <template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6"> <div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div class="w-full mb-4"> <div class="w-full mb-4">
<!-- <div class="flex items-center mb-4"> <div v-if="userIsAdminOrUp" class="flex items-end justify-end mb-4">
<p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p> <!-- <p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p> -->
<div class="flex-grow" /> <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 for new episodes</ui-btn> <ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check & Download New Episodes</ui-btn>
</div> --> </div>
<div v-if="episodes.length" class="w-full p-4 bg-primary"> <div v-if="episodes.length" class="w-full p-4 bg-primary">
<p>Podcast Episodes</p> <p>Podcast Episodes</p>
@@ -51,10 +51,23 @@ export default {
}, },
data() { data() {
return { return {
checkingNewEpisodes: false checkingNewEpisodes: false,
lastEpisodeCheckInput: null
}
},
watch: {
lastEpisodeCheck: {
handler(newVal) {
if (newVal) {
this.setLastEpisodeCheckInput()
}
}
} }
}, },
computed: { computed: {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
autoDownloadEpisodes() { autoDownloadEpisodes() {
return !!this.media.autoDownloadEpisodes return !!this.media.autoDownloadEpisodes
}, },
@@ -72,8 +85,22 @@ export default {
} }
}, },
methods: { methods: {
checkForNewEpisodes() { async checkForNewEpisodes() {
if (this.$refs.lastCheckInput) {
this.$refs.lastCheckInput.blur()
}
this.checkingNewEpisodes = true 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 this.$axios
.$get(`/api/podcasts/${this.libraryItemId}/checknew`) .$get(`/api/podcasts/${this.libraryItemId}/checknew`)
.then((response) => { .then((response) => {
@@ -91,7 +118,13 @@ export default {
this.$toast.error(errorMsg) this.$toast.error(errorMsg)
this.checkingNewEpisodes = false this.checkingNewEpisodes = false
}) })
},
setLastEpisodeCheckInput() {
this.lastEpisodeCheckInput = this.lastEpisodeCheck ? this.$formatDate(this.lastEpisodeCheck, "yyyy-MM-dd'T'HH:mm") : null
} }
},
mounted() {
this.setLastEpisodeCheckInput()
} }
} }
</script> </script>
@@ -1,9 +1,5 @@
<template> <template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6"> <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" /> <tables-library-files-table expanded :files="libraryFiles" :library-item-id="libraryItem.id" :is-missing="isMissing" />
</div> </div>
</template> </template>
@@ -51,12 +47,6 @@ export default {
}, },
showDownload() { showDownload() {
return this.userCanDownload && !this.isMissing return this.userCanDownload && !this.isMissing
},
audiobooks() {
return this.media.audiobooks || []
},
ebooks() {
return this.media.ebooks || []
} }
}, },
methods: { methods: {
@@ -1,10 +1,11 @@
<template> <template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6"> <div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<!-- Merge to m4b -->
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8"> <div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center"> <div class="flex items-center">
<div> <div>
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p> <p class="text-lg">Make M4B Audiobook File <span class="text-error">*</span></p>
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p> <p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<div> <div>
@@ -24,13 +25,55 @@
</div> </div>
</div> </div>
<p class="text-left text-base mb-4 py-4"> <!-- Split to mp3 -->
<div v-if="showMp3Split" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">Split M4B to MP3's</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate multiple MP3's split by chapters with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
</div>
<div class="flex-grow" />
<div>
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
<ui-btn v-if="abmergeStatus !== $constants.DownloadStatus.READY" :loading="abmergeStatus === $constants.DownloadStatus.PENDING" :disabled="true" @click="startAudiobookMerge">Not yet implemented</ui-btn>
<div v-else>
<div class="flex">
<ui-btn @click="downloadWithProgress(abmergeDownload)">Download</ui-btn>
<ui-icon-btn small icon="delete" bg-color="error" class="ml-2" @click="removeDownload" />
</div>
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(abmergeDownload.size) }}</p>
</div>
</div>
</div>
</div>
<!-- Embed Metadata -->
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">Embed Metadata</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Embed metadata into audio files including cover image and chapters. <br /><span class="text-warning">*</span> Modifies audio files.</p>
</div>
<div class="flex-grow" />
<div>
<ui-btn :to="`/item/${libraryItemId}/manage`" class="flex items-center"
>Open Manager
<span class="material-icons text-lg ml-2">launch</span>
</ui-btn>
</div>
</div>
</div>
<p v-if="showM4bDownload" class="text-left text-base mb-4 py-4">
<span class="text-error">* <strong>Experimental</strong></span <span class="text-error">* <strong>Experimental</strong></span
>&nbsp;-&nbsp;M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes. >&nbsp;-&nbsp;M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes.
</p> </p>
<p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> <!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> -->
<p v-else-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks to merge</p> <p v-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks to merge</p>
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center"> <div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20"> <div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
@@ -97,9 +140,16 @@ export default {
isSingleM4b() { isSingleM4b() {
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b' return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
}, },
chapters() {
return this.media.chapters || []
},
showM4bDownload() { showM4bDownload() {
if (this.libraryItem.isMissing || !this.mediaTracks.length) return false if (!this.mediaTracks.length) return false
return !this.isSingleM4b && this.mediaTracks.length > 0 return !this.isSingleM4b
},
showMp3Split() {
if (!this.mediaTracks.length) return false
return this.isSingleM4b && this.chapters.length
} }
}, },
methods: { methods: {
@@ -93,7 +93,9 @@ export default {
icon: 'database', icon: 'database',
mediaType: 'book', mediaType: 'book',
settings: { settings: {
disableWatcher: false disableWatcher: false,
skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false,
} }
} }
}, },
@@ -8,6 +8,18 @@
</div> </div>
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p> <p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
</div> </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> </div>
</template> </template>
@@ -23,7 +35,9 @@ export default {
data() { data() {
return { return {
provider: null, provider: null,
disableWatcher: false disableWatcher: false,
skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false,
} }
}, },
computed: { computed: {
@@ -45,7 +59,9 @@ export default {
getLibraryData() { getLibraryData() {
return { return {
settings: { settings: {
disableWatcher: !!this.disableWatcher disableWatcher: !!this.disableWatcher,
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn
} }
} }
}, },
@@ -54,6 +70,8 @@ export default {
}, },
init() { init() {
this.disableWatcher = !!this.librarySettings.disableWatcher this.disableWatcher = !!this.librarySettings.disableWatcher
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
} }
}, },
mounted() { mounted() {
@@ -0,0 +1,153 @@
<template>
<modals-modal v-model="show" name="rss-feed-modal" :width="600" :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" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
<div v-if="currentFeedUrl" class="w-full">
<p class="text-lg font-semibold mb-4">Podcast RSS Feed is Open</p>
<div class="w-full relative">
<ui-text-input v-model="currentFeedUrl" readonly />
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeedUrl)">content_copy</span>
</div>
</div>
<div v-else class="w-full">
<p class="text-lg font-semibold mb-4">Open RSS Feed</p>
<div class="w-full relative">
<ui-text-input-with-label v-model="newFeedSlug" label="RSS Feed Slug" />
<p class="text-xs text-gray-400 py-0.5 px-1">Feed will be {{ demoFeedUrl }}</p>
</div>
</div>
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
<div class="flex-grow" />
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
<ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
libraryItem: {
type: Object,
default: () => null
},
feedUrl: String
},
data() {
return {
processing: false,
newFeedSlug: null,
currentFeedUrl: null
}
},
watch: {
show: {
immediate: true,
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
libraryItemId() {
return this.libraryItem.id
},
media() {
return this.libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
title() {
return this.mediaMetadata.title
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
demoFeedUrl() {
return `${window.origin}/feed/${this.newFeedSlug}`
}
},
methods: {
openFeed() {
if (!this.newFeedSlug) {
this.$toast.error('Must set a feed slug')
return
}
var sanitized = this.$sanitizeSlug(this.newFeedSlug)
if (this.newFeedSlug !== sanitized) {
this.newFeedSlug = sanitized
this.$toast.warning('Slug had to be modified - Run again')
return
}
const payload = {
serverAddress: window.origin,
slug: this.newFeedSlug
}
if (this.$isDev) payload.serverAddress = 'http://localhost:3333'
console.log('Payload', payload)
this.$axios
.$post(`/api/podcasts/${this.libraryItemId}/open-feed`, payload)
.then((data) => {
if (data.success) {
console.log('Opened RSS Feed', data)
this.currentFeedUrl = data.feedUrl
} else {
const errorMsg = data.error || 'Unknown error'
this.$toast.error(errorMsg)
}
})
.catch((error) => {
console.error('Failed to open RSS Feed', error)
this.$toast.error()
})
},
copyToClipboard(str) {
this.$copyToClipboard(str, this)
},
closeFeed() {
this.processing = true
this.$axios
.$post(`/api/podcasts/${this.libraryItem.id}/close-feed`)
.then(() => {
this.$toast.success('RSS Feed Closed')
this.show = false
this.processing = false
})
.catch((error) => {
console.error('Failed to close RSS feed', error)
this.processing = false
this.$toast.error()
})
},
init() {
if (!this.libraryItem) return
this.newFeedSlug = this.libraryItem.id
this.currentFeedUrl = this.feedUrl
}
},
mounted() {}
}
</script>
+3 -2
View File
@@ -8,7 +8,7 @@
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> --> <!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
<div class="flex-grow" /> <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> <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> <ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link> </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' : ''"> <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, type: Array,
default: () => [] default: () => []
}, },
libraryItemId: String libraryItemId: String,
isFile: Boolean
}, },
data() { data() {
return { return {
+10 -3
View File
@@ -45,8 +45,8 @@
</td> </td>
<td class="py-0"> <td class="py-0">
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
<!-- <span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click.stop="editUser(user)">edit</span> --> <!-- Dont show edit for non-root users -->
<div class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)"> <div v-if="user.type !== 'root' || userIsRoot" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)">
<span class="material-icons text-base">edit</span> <span class="material-icons text-base">edit</span>
</div> </div>
<div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)"> <div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)">
@@ -58,7 +58,7 @@
</table> </table>
</div> </div>
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" /> <modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
</div> </div>
</template> </template>
@@ -76,6 +76,9 @@ export default {
currentUserId() { currentUserId() {
return this.$store.state.user.user.id return this.$store.state.user.user.id
}, },
userIsRoot() {
return this.$store.getters['user/getIsRoot']
},
usersOnline() { usersOnline() {
var usermap = {} var usermap = {}
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, session: u.session })) this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, session: u.session }))
@@ -156,6 +159,10 @@ export default {
this.init() this.init()
}, },
beforeDestroy() { beforeDestroy() {
if (this.$refs.accountModal) {
this.$refs.accountModal.close()
}
if (this.$root.socket) { if (this.$root.socket) {
this.$root.socket.off('user_added', this.newUserAdded) this.$root.socket.off('user_added', this.newUserAdded)
this.$root.socket.off('user_updated', this.userUpdated) this.$root.socket.off('user_updated', this.userUpdated)
+1 -1
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="relative w-full" v-click-outside="clickOutsideObj"> <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"> <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="flex items-center">
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span> <span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
+3 -2
View File
@@ -16,7 +16,7 @@
</div> </div>
</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> </div>
</template> </template>
@@ -27,7 +27,8 @@ export default {
media: { media: {
type: Object, type: Object,
default: () => {} default: () => {}
} },
isFile: Boolean
}, },
data() { data() {
return {} return {}
+41 -18
View File
@@ -106,12 +106,6 @@ export default {
} }
} }
if (payload.serverSettings) { 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 // Start scans currently running
@@ -167,8 +161,28 @@ export default {
libraryUpdated(library) { libraryUpdated(library) {
this.$store.commit('libraries/addUpdate', library) this.$store.commit('libraries/addUpdate', library)
}, },
libraryRemoved(library) { async libraryRemoved(library) {
this.$store.commit('libraries/remove', 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) { libraryItemAdded(libraryItem) {
// this.$store.commit('libraries/updateFilterDataWithAudiobook', libraryItem) // this.$store.commit('libraries/updateFilterDataWithAudiobook', libraryItem)
@@ -485,6 +499,25 @@ export default {
}, },
resize() { resize() {
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight }) 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() { beforeMount() {
@@ -503,17 +536,7 @@ export default {
this.$store.commit('setExperimentalFeatures', true) this.$store.commit('setExperimentalFeatures', true)
} }
this.$store this.checkVersionUpdate()
.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)
}
}, },
beforeDestroy() { beforeDestroy() {
window.removeEventListener('resize', this.resize) window.removeEventListener('resize', this.resize)
+1 -1
View File
@@ -54,7 +54,7 @@ export default {
bookCoverAspectRatio: this.bookCoverAspectRatio, bookCoverAspectRatio: this.bookCoverAspectRatio,
bookshelfView: this.bookshelfView bookshelfView: this.bookshelfView
} }
if (this.entityName === 'series-books') props.showSequence = true
if (this.entityName === 'books') { if (this.entityName === 'books') {
props.filterBy = this.filterBy props.filterBy = this.filterBy
props.orderBy = this.orderBy props.orderBy = this.orderBy
+19 -8
View File
@@ -112,11 +112,22 @@ export default {
items: [] items: []
}) })
var newtreemap = currtreemap.items[currtreemap.items.length - 1] var newtreemap = currtreemap.items[currtreemap.items.length - 1]
dirReader.readEntries((entries) => {
let entriesPromises = [] let entriesPromises = []
for (let entr of entries) entriesPromises.push(traverseFileTreePromise(entr, newtreemap)) // readEntries returns 100 items max, continue calling readEntries until empty
resolve(Promise.all(entriesPromises)) function readEntries() {
}) dirReader.readEntries((entries) => {
if (entries.length > 0) {
for (let entr of entries) {
entriesPromises.push(traverseFileTreePromise(entr, newtreemap))
}
readEntries()
} else {
resolve(Promise.all(entriesPromises))
}
})
}
readEntries()
} }
}) })
} }
@@ -174,8 +185,8 @@ export default {
if (mediaType === 'podcast') return this.cleanPodcast(item, index) if (mediaType === 'podcast') return this.cleanPodcast(item, index)
return this.cleanBook(item, index) return this.cleanBook(item, index)
}, },
async getItemsFromDataTransferItems(items, mediaType) { async getItemsFromDataTransferItems(dataTransferItems, mediaType) {
var files = await this.getFilesDropped(items) var files = await this.getFilesDropped(dataTransferItems)
if (!files || !files.length) return { error: 'No files found ' } if (!files || !files.length) return { error: 'No files found ' }
var itemData = this.fileTreeToItems(files, mediaType) var itemData = this.fileTreeToItems(files, mediaType)
if (!itemData.items.length && !itemData.ignoredFiles.length) { if (!itemData.items.length && !itemData.ignoredFiles.length) {
@@ -189,7 +200,7 @@ export default {
if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles) if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles)
} }
return ab.itemFiles.length return ab.itemFiles.length
}).map(ab => this.cleanItem(ab, index++)) }).map(ab => this.cleanItem(ab, mediaType, index++))
return { return {
items, items,
ignoredFiles ignoredFiles
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.0.3", "version": "2.0.10",
"description": "Audiobook manager and player", "description": "Audiobook manager and player",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+5 -2
View File
@@ -15,8 +15,8 @@
<div class="w-full h-px bg-primary my-4" /> <div class="w-full h-px bg-primary my-4" />
<p class="mb-4 text-lg">Change Password</p> <p v-if="!isGuest" class="mb-4 text-lg">Change Password</p>
<form @submit.prevent="submitChangePassword"> <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="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="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" /> <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() { isRoot() {
return this.usertype === 'root' return this.usertype === 'root'
},
isGuest() {
return this.usertype === 'guest'
} }
}, },
methods: { methods: {
+11 -5
View File
@@ -38,7 +38,9 @@
</div> </div>
<draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate"> <draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate">
<transition-group type="transition" :name="!drag ? 'flip-list' : null"> <transition-group type="transition" :name="!drag ? 'flip-list' : null">
<li v-for="(audio, index) in files" :key="audio.ino" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center"> <li v-for="(audio, index) in files" :key="audio.ino" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center relative">
<div v-if="audiofilesEncoding[audio.ino]" class="absolute top-0 left-0 w-full h-full bg-success bg-opacity-25" />
<div class="font-book text-center px-4 py-1 w-12"> <div class="font-book text-center px-4 py-1 w-12">
{{ audio.include ? index - numExcluded + 1 : -1 }} {{ audio.include ? index - numExcluded + 1 : -1 }}
</div> </div>
@@ -71,7 +73,7 @@
<div class="font-sans text-xs font-normal w-56"> <div class="font-sans text-xs font-normal w-56">
{{ audio.error }} {{ audio.error }}
</div> </div>
<div class="font-sans text-xs font-normal w-40 flex justify-center"> <div class="font-sans text-xs font-normal w-40 flex items-center justify-center">
<ui-toggle-switch v-model="audio.include" :off-color="'error'" @input="includeToggled(audio)" /> <ui-toggle-switch v-model="audio.include" :off-color="'error'" @input="includeToggled(audio)" />
</div> </div>
</li> </li>
@@ -107,6 +109,10 @@ export default {
console.error('Invalid media type') console.error('Invalid media type')
return redirect('/') return redirect('/')
} }
if (libraryItem.isFile) {
console.error('No need to edit library item that is 1 file...')
return redirect('/')
}
return { return {
libraryItem, libraryItem,
files: libraryItem.media.audioFiles ? libraryItem.media.audioFiles.map((af) => ({ ...af, include: !af.exclude })) : [] files: libraryItem.media.audioFiles ? libraryItem.media.audioFiles.map((af) => ({ ...af, include: !af.exclude })) : []
@@ -125,6 +131,9 @@ export default {
} }
}, },
computed: { computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
media() { media() {
return this.libraryItem.media || {} return this.libraryItem.media || {}
}, },
@@ -158,9 +167,6 @@ export default {
}, },
streamLibraryItem() { streamLibraryItem() {
return this.$store.state.streamLibraryItem return this.$store.state.streamLibraryItem
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
} }
}, },
methods: { methods: {
+1 -1
View File
@@ -15,7 +15,7 @@
<script> <script>
export default { export default {
asyncData({ store, redirect, route }) { asyncData({ store, redirect, route }) {
if (!store.getters['user/getIsRoot']) { if (!store.getters['user/getIsAdminOrUp']) {
// Non-Root user only has access to the listening stats page // Non-Root user only has access to the listening stats page
if (route.name !== 'config-stats') { if (route.name !== 'config-stats') {
redirect('/config/stats') redirect('/config/stats')
-5
View File
@@ -34,11 +34,6 @@
<script> <script>
export default { export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsRoot']) {
redirect('/?error=unauthorized')
}
},
data() { data() {
return { return {
search: null, search: null,
+1 -1
View File
@@ -14,7 +14,7 @@
<h1 class="text-xl pl-2">{{ username }}</h1> <h1 class="text-xl pl-2">{{ username }}</h1>
</div> </div>
<div class="cursor-pointer text-gray-400 hover:text-white" @click="copyToClipboard(userToken)"> <div class="cursor-pointer text-gray-400 hover:text-white" @click="copyToClipboard(userToken)">
<p class="py-2 text-xs"> <p v-if="userToken" class="py-2 text-xs">
<strong class="text-white">API Token: </strong><br /><span class="text-white">{{ userToken }}</span <strong class="text-white">API Token: </strong><br /><span class="text-white">{{ userToken }}</span
><span class="material-icons pl-2 text-base">content_copy</span> ><span class="material-icons pl-2 text-base">content_copy</span>
</p> </p>
+47 -6
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> <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>
<!-- 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 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"> <div class="flex items-center">
<p class="text-sm py-1">{{ episodeDownloadsQueued.length }} Episode{{ episodeDownloadsQueued.length === 1 ? '' : 's' }} queued for download</p> <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>
</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-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"> <div v-for="episode in episodesDownloading" :key="episode.id" class="flex items-center">
<widgets-loading-spinner /> <widgets-loading-spinner />
@@ -150,9 +152,15 @@
<ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" /> <ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" />
</ui-tooltip> </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-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
</ui-tooltip> </ui-tooltip>
<!-- Experimental RSS feed open -->
<ui-tooltip v-if="showRssFeedBtn" text="Open RSS Feed" direction="top">
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeedUrl ? 'success' : 'primary'" outlined @click="clickRSSFeed" />
</ui-tooltip>
</div> </div>
<div class="my-4 max-w-2xl"> <div class="my-4 max-w-2xl">
@@ -165,7 +173,7 @@
<p v-for="audioFile in invalidAudioFiles" :key="audioFile.id" class="text-xs pl-2">- {{ audioFile.metadata.filename }} ({{ audioFile.error }})</p> <p v-for="audioFile in invalidAudioFiles" :key="audioFile.id" class="text-xs pl-2">- {{ audioFile.metadata.filename }} ({{ audioFile.error }})</p>
</div> </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" /> <tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" />
@@ -175,6 +183,7 @@
</div> </div>
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" /> <modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
<modals-rssfeed-view-modal v-model="showRssFeedModal" :library-item="libraryItem" :feed-url="rssFeedUrl" />
</div> </div>
</template> </template>
@@ -186,7 +195,7 @@ export default {
} }
// Include episode downloads for podcasts // Include episode downloads for podcasts
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=authors,downloads`).catch((error) => { var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=authors,downloads,rssfeed`).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return false return false
}) })
@@ -195,7 +204,8 @@ export default {
return redirect('/') return redirect('/')
} }
return { return {
libraryItem: item libraryItem: item,
rssFeedUrl: item.rssFeedUrl || null
} }
}, },
data() { data() {
@@ -206,10 +216,17 @@ export default {
showPodcastEpisodeFeed: false, showPodcastEpisodeFeed: false,
podcastFeedEpisodes: [], podcastFeedEpisodes: [],
episodesDownloading: [], episodesDownloading: [],
episodeDownloadsQueued: [] episodeDownloadsQueued: [],
showRssFeedModal: false
} }
}, },
computed: { computed: {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
isFile() {
return this.libraryItem.isFile
},
coverAspectRatio() { coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio') return this.$store.getters['getServerSetting']('coverAspectRatio')
}, },
@@ -359,6 +376,11 @@ export default {
}, },
userCanDownload() { userCanDownload() {
return this.$store.getters['user/getUserCanDownload'] return this.$store.getters['user/getUserCanDownload']
},
showRssFeedBtn() {
if (!this.showExperimentalFeatures) return false
// If rss feed is open then show feed url to users otherwise just show to admins
return this.isPodcast && (this.userIsAdminOrUp || this.rssFeedUrl)
} }
}, },
methods: { methods: {
@@ -469,6 +491,9 @@ export default {
this.$store.commit('setSelectedLibraryItem', this.libraryItem) this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShowUserCollectionsModal', true) this.$store.commit('globals/setShowUserCollectionsModal', true)
}, },
clickRSSFeed() {
this.showRssFeedModal = true
},
episodeDownloadQueued(episodeDownload) { episodeDownloadQueued(episodeDownload) {
if (episodeDownload.libraryItemId === this.libraryItemId) { if (episodeDownload.libraryItemId === this.libraryItemId) {
this.episodeDownloadsQueued.push(episodeDownload) this.episodeDownloadsQueued.push(episodeDownload)
@@ -485,6 +510,18 @@ export default {
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id) this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id) this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
} }
},
rssFeedOpen(data) {
if (data.libraryItemId === this.libraryItemId) {
console.log('RSS Feed Opened', data)
this.rssFeedUrl = data.feedUrl
}
},
rssFeedClosed(data) {
if (data.libraryItemId === this.libraryItemId) {
console.log('RSS Feed Closed', data)
this.rssFeedUrl = null
}
} }
}, },
mounted() { mounted() {
@@ -497,12 +534,16 @@ export default {
this.$store.commit('libraries/setCurrentLibrary', this.libraryId) this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
} }
this.$root.socket.on('item_updated', this.libraryItemUpdated) this.$root.socket.on('item_updated', this.libraryItemUpdated)
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued) this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted) this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished) this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
}, },
beforeDestroy() { beforeDestroy() {
this.$root.socket.off('item_updated', this.libraryItemUpdated) this.$root.socket.off('item_updated', this.libraryItemUpdated)
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued) this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted) this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished) this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
+270
View File
@@ -0,0 +1,270 @@
<template>
<div id="page-wrapper" class="bg-bg page p-8 overflow-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex justify-center mb-2">
<div class="w-full max-w-2xl">
<p class="text-xl">Metadata to embed</p>
</div>
<div class="w-full max-w-2xl"></div>
</div>
<div class="flex justify-center flex-wrap">
<div class="w-full max-w-2xl border border-opacity-10 bg-bg mx-2">
<div class="flex py-2 px-4">
<div class="w-1/3 text-xs font-semibold uppercase text-gray-200">Meta Tag</div>
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">Value</div>
</div>
<div class="w-full max-h-72 overflow-auto">
<template v-for="(keyValue, index) in metadataKeyValues">
<div :key="keyValue.key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
<div class="w-1/3 font-semibold">{{ keyValue.key }}</div>
<div class="w-2/3">
{{ keyValue.value }}
</div>
</div>
</template>
</div>
</div>
<div class="w-full max-w-2xl border border-opacity-10 bg-bg mx-2">
<div class="flex py-2 px-4">
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">Chapter Title</div>
<div class="w-24 text-xs font-semibold uppercase text-gray-200">Start</div>
<div class="w-24 text-xs font-semibold uppercase text-gray-200">End</div>
</div>
<div class="w-full max-h-72 overflow-auto">
<template v-for="(chapter, index) in metadataChapters">
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
<div class="flex-grow font-semibold">{{ chapter.title }}</div>
<div class="w-24">
{{ chapter.start.toFixed(2) }}
</div>
<div class="w-24">
{{ chapter.end.toFixed(2) }}
</div>
</div>
</template>
</div>
</div>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-8" />
<div class="w-full max-w-4xl mx-auto">
<div class="w-full flex justify-between items-center mb-4">
<p class="text-warning text-lg font-semibold">Warning: Modifies your audio files</p>
<ui-btn v-if="!embedFinished" color="primary" :loading="updatingMetadata" @click="updateAudioFileMetadata">Embed Metadata</ui-btn>
<p v-else class="text-success text-lg font-semibold">Embed Finished!</p>
</div>
<div class="w-full mx-auto border border-opacity-10 bg-bg">
<div class="flex py-2 px-4">
<div class="w-10 text-xs font-semibold text-gray-200">#</div>
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">Filename</div>
<div class="w-16 text-xs font-semibold uppercase text-gray-200">Size</div>
<div class="w-24"></div>
</div>
<template v-for="file in audioFiles">
<div :key="file.index" class="flex py-2 px-4 text-sm" :class="file.index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
<div class="w-10">{{ file.index }}</div>
<div class="flex-grow">
{{ file.metadata.filename }}
</div>
<div class="w-16 font-mono text-gray-200">
{{ $bytesPretty(file.metadata.size) }}
</div>
<div class="w-24">
<div class="flex justify-center">
<span v-if="audiofilesFinished[file.ino]" class="material-icons text-xl text-success leading-none">check_circle</span>
<div v-else-if="audiofilesEncoding[file.ino]">
<widgets-loading-spinner />
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<script>
export default {
async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`)
}
if (!store.getters['user/getIsAdminOrUp']) {
return redirect('/?error=unauthorized')
}
var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {
console.error('Failed', error)
return false
})
if (!libraryItem) {
console.error('Not found...', params.id)
return redirect('/?error=not found')
}
if (libraryItem.mediaType !== 'book') {
console.error('Invalid media type')
return redirect('/?error=invalid media type')
}
if (!libraryItem.media.audioFiles.length) {
cnosole.error('No audio files')
return redirect('/?error=no audio files')
}
return {
libraryItem
}
},
data() {
return {
audiofilesEncoding: {},
audiofilesFinished: {},
updatingMetadata: false,
embedFinished: false
}
},
computed: {
libraryItemId() {
return this.libraryItem.id
},
media() {
return this.libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
audioFiles() {
return this.media.audioFiles || []
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
metadataKeyValues() {
const keyValues = [
{
key: 'title',
value: this.mediaMetadata.title
},
{
key: 'artist',
value: this.mediaMetadata.authorName
},
{
key: 'album_artist',
value: this.mediaMetadata.authorName
},
{
key: 'date',
value: this.mediaMetadata.publishedYear
},
{
key: 'description',
value: this.mediaMetadata.description
},
{
key: 'genre',
value: this.mediaMetadata.genres.join(';')
},
{
key: 'performer',
value: this.mediaMetadata.narratorName
}
]
if (this.mediaMetadata.subtitle) {
keyValues.push({
key: 'subtitle',
value: this.mediaMetadata.subtitle
})
}
if (this.mediaMetadata.asin) {
keyValues.push({
key: 'asin',
value: this.mediaMetadata.asin
})
}
if (this.mediaMetadata.isbn) {
keyValues.push({
key: 'isbn',
value: this.mediaMetadata.isbn
})
}
if (this.mediaMetadata.language) {
keyValues.push({
key: 'language',
value: this.mediaMetadata.language
})
}
if (this.mediaMetadata.series.length) {
var firstSeries = this.mediaMetadata.series[0]
keyValues.push({
key: 'series',
value: firstSeries.name
})
if (firstSeries.sequence) {
keyValues.push({
key: 'series-part',
value: firstSeries.sequence
})
}
}
return keyValues
},
metadataChapters() {
var chapters = this.media.chapters || []
return chapters.concat(chapters)
}
},
methods: {
updateAudioFileMetadata() {
if (confirm(`Warning!\n\nThis will modify the audio files for this audiobook.\nMake sure your audio files are backed up before using this feature.`)) {
this.updatingMetadata = true
this.$axios
.$get(`/api/items/${this.libraryItemId}/audio-metadata`)
.then(() => {
console.log('Audio metadata encode started')
})
.catch((error) => {
console.error('Audio metadata encode failed', error)
this.updatingMetadata = false
})
}
},
audioMetadataStarted(data) {
console.log('audio metadata started', data)
if (data.libraryItemId !== this.libraryItemId) return
this.audiofilesFinished = {}
this.updatingMetadata = true
},
audioMetadataFinished(data) {
console.log('audio metadata finished', data)
if (data.libraryItemId !== this.libraryItemId) return
this.updatingMetadata = false
this.embedFinished = true
this.audiofilesEncoding = {}
this.$toast.success('Audio file metadata updated')
},
audiofileMetadataStarted(data) {
if (data.libraryItemId !== this.libraryItemId) return
this.$set(this.audiofilesEncoding, data.ino, true)
},
audiofileMetadataFinished(data) {
if (data.libraryItemId !== this.libraryItemId) return
this.$set(this.audiofilesEncoding, data.ino, false)
this.$set(this.audiofilesFinished, data.ino, true)
}
},
mounted() {
this.$root.socket.on('audio_metadata_started', this.audioMetadataStarted)
this.$root.socket.on('audio_metadata_finished', this.audioMetadataFinished)
this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted)
this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished)
},
beforeDestroy() {
this.$root.socket.off('audio_metadata_started', this.audioMetadataStarted)
this.$root.socket.off('audio_metadata_finished', this.audioMetadataFinished)
this.$root.socket.off('audiofile_metadata_started', this.audiofileMetadataStarted)
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
}
}
</script>
+11 -4
View File
@@ -48,8 +48,15 @@ export default {
} }
}, },
methods: { methods: {
setUser(user, defaultLibraryId) { setUser({ user, userDefaultLibraryId, serverSettings }) {
this.$store.commit('libraries/setCurrentLibrary', defaultLibraryId) 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) this.$store.commit('user/setUser', user)
}, },
async submitForm() { async submitForm() {
@@ -69,7 +76,7 @@ export default {
if (authRes && authRes.error) { if (authRes && authRes.error) {
this.error = authRes.error this.error = authRes.error
} else if (authRes) { } else if (authRes) {
this.setUser(authRes.user, authRes.userDefaultLibraryId) this.setUser(authRes)
} }
this.processing = false this.processing = false
}, },
@@ -87,7 +94,7 @@ export default {
} }
}) })
.then((res) => { .then((res) => {
this.setUser(res.user, res.userDefaultLibraryId) this.setUser(res)
this.processing = false this.processing = false
}) })
.catch((error) => { .catch((error) => {
+4 -3
View File
@@ -53,8 +53,8 @@
</widgets-alert> </widgets-alert>
<!-- Item Upload cards --> <!-- Item Upload cards -->
<template v-for="(item, index) in items"> <template v-for="item in items">
<cards-item-upload-card :ref="`itemCard-${item.index}`" :key="index" :media-type="selectedLibraryMediaType" :item="item" :processing="processing" @remove="removeItem(item)" /> <cards-item-upload-card :ref="`itemCard-${item.index}`" :key="item.index" :media-type="selectedLibraryMediaType" :item="item" :processing="processing" @remove="removeItem(item)" />
</template> </template>
<!-- Upload/Reset btns --> <!-- Upload/Reset btns -->
@@ -195,7 +195,8 @@ export default {
e.preventDefault() e.preventDefault()
this.isDragging = false this.isDragging = false
var items = e.dataTransfer.items || [] var items = e.dataTransfer.items || []
var itemResults = await this.uploadHelpers.getItemsFromDrop(items)
var itemResults = await this.uploadHelpers.getItemsFromDrop(items, this.selectedLibraryMediaType)
this.setResults(itemResults) this.setResults(itemResults)
}, },
inputChanged(e) { inputChanged(e) {
+25
View File
@@ -125,6 +125,31 @@ Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
return sanitized return sanitized
} }
// SOURCE: https://gist.github.com/spyesx/561b1d65d4afb595f295
// modified: allowed underscores
Vue.prototype.$sanitizeSlug = (str) => {
if (!str) return ''
str = str.replace(/^\s+|\s+$/g, '') // trim
str = str.toLowerCase()
// remove accents, swap ñ for n, etc
var from = "àáäâèéëêìíïîòóöôùúüûñçěščřžýúůďťň·/,:;"
var to = "aaaaeeeeiiiioooouuuuncescrzyuudtn-----"
for (var i = 0, l = from.length; i < l; i++) {
str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i))
}
str = str.replace('.', '-') // replace a dot by a dash
.replace(/[^a-z0-9 -_]/g, '') // remove invalid chars
.replace(/\s+/g, '-') // collapse whitespace and replace by a dash
.replace(/-+/g, '-') // collapse dashes
.replace(/\//g, '') // collapse all forward-slashes
return str
}
Vue.prototype.$copyToClipboard = (str, ctx) => { Vue.prototype.$copyToClipboard = (str, ctx) => {
return new Promise((resolve) => { return new Promise((resolve) => {
if (!navigator.clipboard) { if (!navigator.clipboard) {
+7 -5
View File
@@ -33,11 +33,12 @@ export async function checkForUpdate() {
return return
} }
var largestVer = null var largestVer = null
await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/tags`).then((res) => { await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/releases`).then((res) => {
var tags = res.data var releases = res.data
if (tags && tags.length) { if (releases && releases.length) {
tags.forEach((tag) => { releases.forEach((release) => {
var verObj = parseSemver(tag.name) var tagName = release.tag_name
var verObj = parseSemver(tagName)
if (verObj) { if (verObj) {
if (!largestVer || largestVer.total < verObj.total) { if (!largestVer || largestVer.total < verObj.total) {
largestVer = verObj largestVer = verObj
@@ -50,6 +51,7 @@ export async function checkForUpdate() {
console.error('No valid version tags to compare with') console.error('No valid version tags to compare with')
return return
} }
return { return {
hasUpdate: largestVer.total > currVerObj.total, hasUpdate: largestVer.total > currVerObj.total,
latestVersion: largestVer.version, latestVersion: largestVer.version,
+13
View File
@@ -29,6 +29,19 @@ export const getters = {
var library = state.libraries.find(l => l.id === libraryId) var library = state.libraries.find(l => l.id === libraryId)
if (!library) return null if (!library) return null
return library.provider 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]
} }
} }
+7
View File
@@ -16,6 +16,7 @@ export const state = () => ({
export const getters = { export const getters = {
getIsRoot: (state) => state.user && state.user.type === 'root', getIsRoot: (state) => state.user && state.user.type === 'root',
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
getToken: (state) => { getToken: (state) => {
return state.user ? state.user.token : null return state.user ? state.user.token : null
}, },
@@ -71,6 +72,9 @@ export const actions = {
if (state.settings.orderBy == 'media.metadata.authorName' || state.settings.orderBy == 'media.metadata.authorNameLF') { if (state.settings.orderBy == 'media.metadata.authorName' || state.settings.orderBy == 'media.metadata.authorNameLF') {
settingsUpdate.orderBy = 'media.metadata.author' settingsUpdate.orderBy = 'media.metadata.author'
} }
if (state.settings.orderBy == 'media.duration') {
settingsUpdate.orderBy = 'media.numTracks'
}
var invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues'] var invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues']
var filterByFirstPart = (state.settings.filterBy || '').split('.').shift() var filterByFirstPart = (state.settings.filterBy || '').split('.').shift()
if (invalidFilters.includes(filterByFirstPart)) { if (invalidFilters.includes(filterByFirstPart)) {
@@ -80,6 +84,9 @@ export const actions = {
if (state.settings.orderBy == 'media.metadata.author') { if (state.settings.orderBy == 'media.metadata.author') {
settingsUpdate.orderBy = 'media.metadata.authorName' settingsUpdate.orderBy = 'media.metadata.authorName'
} }
if (state.settings.orderBy == 'media.numTracks') {
settingsUpdate.orderBy = 'media.duration'
}
} }
if (Object.keys(settingsUpdate).length) { if (Object.keys(settingsUpdate).length) {
dispatch('updateUserSettings', settingsUpdate) dispatch('updateUserSettings', settingsUpdate)
+1
View File
@@ -6,6 +6,7 @@ module.exports = {
safelist: [ safelist: [
'bg-success', 'bg-success',
'bg-red-600', 'bg-red-600',
'text-green-500',
'py-1.5', 'py-1.5',
'bg-info' 'bg-info'
] ]
+2 -2
View File
@@ -3,10 +3,10 @@ version: "3.7"
services: services:
audiobookshelf: audiobookshelf:
image: advplyr/audiobookshelf image: ghcr.io/advplyr/audiobookshelf
ports: ports:
- 13378:80 - 13378:80
volumes: volumes:
- /audiobooks:/audiobooks - /audiobooks:/audiobooks
- /metadata:/metadata - /metadata:/metadata
- /config:/config - /config:/config
+5 -5
View File
@@ -1,7 +1,7 @@
<?xml version="1.0"?> <?xml version="1.0"?>
<Container version="2"> <Container version="2">
<Name>audiobookshelf</Name> <Name>audiobookshelf</Name>
<Repository>advplyr/audiobookshelf</Repository> <Repository>ghcr.io/advplyr/audiobookshelf</Repository>
<Registry>https://hub.docker.com/r/advplyr/audiobookshelf/</Registry> <Registry>https://hub.docker.com/r/advplyr/audiobookshelf/</Registry>
<Network>bridge</Network> <Network>bridge</Network>
<MyIP/> <MyIP/>
@@ -9,8 +9,8 @@
<Privileged>false</Privileged> <Privileged>false</Privileged>
<Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support> <Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support>
<Project>https://github.com/advplyr/audiobookshelf</Project> <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> <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</Category> <Category>MediaApp:Books MediaServer:Books MediaApp:Other MediaServer:Other</Category>
<WebUI>http://[IP]:[PORT:80]</WebUI> <WebUI>http://[IP]:[PORT:80]</WebUI>
<TemplateURL>https://raw.githubusercontent.com/advplyr/docker-templates/master/audiobookshelf.xml</TemplateURL> <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> <Icon>https://github.com/advplyr/audiobookshelf/raw/master/client/static/Logo.png</Icon>
@@ -20,7 +20,7 @@
<DateInstalled>1629238508</DateInstalled> <DateInstalled>1629238508</DateInstalled>
<DonateText/> <DonateText/>
<DonateLink/> <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> <Networking>
<Mode>bridge</Mode> <Mode>bridge</Mode>
<Publish> <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="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="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> <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>
+81 -3
View File
@@ -1,12 +1,11 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "1.7.3", "version": "2.0.8",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "version": "2.0.8",
"version": "1.7.3",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"archiver": "^5.3.0", "archiver": "^5.3.0",
@@ -26,6 +25,7 @@
"node-cron": "^3.0.0", "node-cron": "^3.0.0",
"node-ffprobe": "^3.0.0", "node-ffprobe": "^3.0.0",
"node-stream-zip": "^1.15.0", "node-stream-zip": "^1.15.0",
"podcast": "^2.0.0",
"proper-lockfile": "^4.1.2", "proper-lockfile": "^4.1.2",
"read-chunk": "^3.1.0", "read-chunk": "^3.1.0",
"recursive-readdir-async": "^1.1.8", "recursive-readdir-async": "^1.1.8",
@@ -1477,6 +1477,14 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/podcast": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/podcast/-/podcast-2.0.0.tgz",
"integrity": "sha512-1NZe7cVabfkcMe39yOOhBMJrXC6OROLOIBkfEPayiJL59ncyJmOeO5bxolSCSGroho8jQ0zURMczyICI1U/xbw==",
"dependencies": {
"rss": "^1.2.2"
}
},
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -1675,6 +1683,34 @@
"atomically": "^1.7.0" "atomically": "^1.7.0"
} }
}, },
"node_modules/rss": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/rss/-/rss-1.2.2.tgz",
"integrity": "sha1-UKFpiHYTgTOnT5oF0r3I240nqSE=",
"dependencies": {
"mime-types": "2.1.13",
"xml": "1.0.1"
}
},
"node_modules/rss/node_modules/mime-db": {
"version": "1.25.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.25.0.tgz",
"integrity": "sha1-wY29fHOl2/b0SgJNwNFloeexw5I=",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/rss/node_modules/mime-types": {
"version": "2.1.13",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.13.tgz",
"integrity": "sha1-4HqqnGxrmnyjASxpADrSWjnpKog=",
"dependencies": {
"mime-db": "~1.25.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -2071,6 +2107,11 @@
} }
} }
}, },
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU="
},
"node_modules/xml2js": { "node_modules/xml2js": {
"version": "0.4.23", "version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
@@ -3222,6 +3263,14 @@
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==" "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="
}, },
"podcast": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/podcast/-/podcast-2.0.0.tgz",
"integrity": "sha512-1NZe7cVabfkcMe39yOOhBMJrXC6OROLOIBkfEPayiJL59ncyJmOeO5bxolSCSGroho8jQ0zURMczyICI1U/xbw==",
"requires": {
"rss": "^1.2.2"
}
},
"process-nextick-args": { "process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -3387,6 +3436,30 @@
"atomically": "^1.7.0" "atomically": "^1.7.0"
} }
}, },
"rss": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/rss/-/rss-1.2.2.tgz",
"integrity": "sha1-UKFpiHYTgTOnT5oF0r3I240nqSE=",
"requires": {
"mime-types": "2.1.13",
"xml": "1.0.1"
},
"dependencies": {
"mime-db": {
"version": "1.25.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.25.0.tgz",
"integrity": "sha1-wY29fHOl2/b0SgJNwNFloeexw5I="
},
"mime-types": {
"version": "2.1.13",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.13.tgz",
"integrity": "sha1-4HqqnGxrmnyjASxpADrSWjnpKog=",
"requires": {
"mime-db": "~1.25.0"
}
}
}
},
"safe-buffer": { "safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -3690,6 +3763,11 @@
"integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==", "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==",
"requires": {} "requires": {}
}, },
"xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU="
},
"xml2js": { "xml2js": {
"version": "0.4.23", "version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.0.3", "version": "2.0.10",
"description": "Self-hosted audiobook server for managing and playing audiobooks", "description": "Self-hosted audiobook server for managing and playing audiobooks",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -8,7 +8,7 @@
"start": "node index.js", "start": "node index.js",
"client": "cd client && npm install && npm run generate", "client": "cd client && npm install && npm run generate",
"prod": "npm run client && npm install && node prod.js", "prod": "npm run client && npm install && node prod.js",
"build-win": "pkg -t node12-win-x64 -o ./dist/win/audiobookshelf .", "build-win": "pkg -t node16-win-x64 -o ./dist/win/audiobookshelf .",
"build-linux": "build/linuxpackager", "build-linux": "build/linuxpackager",
"docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push . -t advplyr/audiobookshelf", "docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push . -t advplyr/audiobookshelf",
"deploy": "node dist/autodeploy" "deploy": "node dist/autodeploy"
@@ -44,6 +44,7 @@
"node-cron": "^3.0.0", "node-cron": "^3.0.0",
"node-ffprobe": "^3.0.0", "node-ffprobe": "^3.0.0",
"node-stream-zip": "^1.15.0", "node-stream-zip": "^1.15.0",
"podcast": "^2.0.0",
"proper-lockfile": "^4.1.2", "proper-lockfile": "^4.1.2",
"read-chunk": "^3.1.0", "read-chunk": "^3.1.0",
"recursive-readdir-async": "^1.1.8", "recursive-readdir-async": "^1.1.8",
+55 -23
View File
@@ -71,27 +71,62 @@ docker run -d \
-e AUDIOBOOKSHELF_GID=100 \ -e AUDIOBOOKSHELF_GID=100 \
-p 13378:80 \ -p 13378:80 \
-v </path/to/audiobooks>:/audiobooks \ -v </path/to/audiobooks>:/audiobooks \
-v </path/to/your/podcasts>:/podcasts \
-v </path/to/config>:/config \ -v </path/to/config>:/config \
-v </path/to/metadata>:/metadata \ -v </path/to/metadata>:/metadata \
--name audiobookshelf \ --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 ### Running with Docker Compose
```bash ```yaml
### docker-compose.yml ### ### docker-compose.yml ###
services: services:
audiobookshelf: audiobookshelf:
image: advplyr/audiobookshelf image: ghcr.io/advplyr/audiobookshelf
environment:
- AUDIOBOOKSHELF_UID=99
- AUDIOBOOKSHELF_GID=100
ports: ports:
- 13378:80 - 13378:80
volumes: volumes:
- <path/to/your/audiobooks>:/audiobooks - </path/to/your/audiobooks>:/audiobooks
- <path/to/metadata>:/metadata - </path/to/your/podcasts>:/podcasts
- <path/to/config>:/config - </path/to/config>:/config
- </path/to/metadata>:/metadata
``` ```
### Docker Compose Update
Depending on the version of Docker Compose please run one of the two commands. If not sure on which version you are running you can run the following command and check.
#### Version Check
docker-compose --version or docker compose version
#### v2 Update
```bash
docker compose --file <path/to/config>/docker-compose.yml pull
docker compose --file <path/to/config>/docker-compose.yml up -d
```
#### V1 Update
```bash
docker-compose --file <path/to/config>/docker-compose.yml pull
docker-compose --file <path/to/config>/docker-compose.yml up -d
```
** We recommend updating the the latest version of Docker Compose
### Linux (amd64) Install ### Linux (amd64) Install
@@ -99,29 +134,15 @@ Debian package will use this config file `/etc/default/audiobookshelf` if exists
### Ubuntu Install via PPA ### Ubuntu Install via PPA
A PPA is hosted on [github](https://github.com/advplyr/audiobookshelf-ppa), add and install: A PPA is hosted on [github](https://github.com/advplyr/audiobookshelf-ppa)
```bash See [install docs](https://www.audiobookshelf.org/install/#ubuntu)
curl -s --compressed "https://advplyr.github.io/audiobookshelf-ppa/KEY.gpg" | sudo apt-key add -
sudo curl -s --compressed -o /etc/apt/sources.list.d/audiobookshelf.list "https://advplyr.github.io/audiobookshelf-ppa/audiobookshelf.list"
sudo apt update
sudo apt install audiobookshelf
```
or use a single command
```bash
curl -s --compressed "https://advplyr.github.io/audiobookshelf-ppa/KEY.gpg" | sudo apt-key add - && sudo curl -s --compressed -o /etc/apt/sources.list.d/audiobookshelf.list "https://advplyr.github.io/audiobookshelf-ppa/audiobookshelf.list" && sudo apt update && sudo apt install audiobookshelf
```
### Install via debian package ### Install via debian package
Get the `deb` file from the [github repo](https://github.com/advplyr/audiobookshelf-ppa). Get the `deb` file from the [github repo](https://github.com/advplyr/audiobookshelf-ppa).
See [instructions](https://www.audiobookshelf.org/install#debian) See [install docs](https://www.audiobookshelf.org/install#debian)
#### Linux file locations #### Linux file locations
@@ -227,6 +248,17 @@ For this to work you must enable at least the following mods using `a2enmod`:
[from @silentArtifact](https://github.com/advplyr/audiobookshelf/issues/241#issuecomment-1036732329) [from @silentArtifact](https://github.com/advplyr/audiobookshelf/issues/241#issuecomment-1036732329)
### [Traefik Reverse Proxy](https://doc.traefik.io/traefik/)
Middleware relating to CORS will cause the app to report Unknown Error when logging in. To prevent this don't apply any of the following headers to the router for this site:
<ul>
<li>accessControlAllowMethods</li>
<li>accessControlAllowOriginList</li>
<li>accessControlMaxAge</li>
</ul>
From [@Dondochaka](https://discord.com/channels/942908292873723984/942914154254176257/945074590374318170) and [@BeastleeUK](https://discord.com/channels/942908292873723984/942914154254176257/970366039294611506)
<br /> <br />
# Run from source # Run from source
+10 -5
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) { async login(req, res) {
var username = (req.body.username || '').toLowerCase() var username = (req.body.username || '').toLowerCase()
var password = req.body.password || '' var password = req.body.password || ''
@@ -120,17 +128,14 @@ class Auth {
if (password) { if (password) {
return res.status(401).send('Invalid root password (hint: there is none)') return res.status(401).send('Invalid root password (hint: there is none)')
} else { } else {
return res.json({ user: user.toJSONForBrowser(), userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries) }) return res.json(this.getUserLoginResponsePayload(user))
} }
} }
// Check password match // Check password match
var compare = await bcrypt.compare(password, user.pash) var compare = await bcrypt.compare(password, user.pash)
if (compare) { if (compare) {
res.json({ res.json(this.getUserLoginResponsePayload(user))
user: user.toJSONForBrowser(),
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries)
})
} else { } else {
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`) Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
if (req.rateLimit.remaining <= 2) { if (req.rateLimit.remaining <= 2) {
+3 -1
View File
@@ -411,7 +411,9 @@ class Db {
removeEntity(entityName, entityId) { removeEntity(entityName, entityId) {
var entityDb = this.getEntityDb(entityName) 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}`) Logger.debug(`[DB] Deleted entity ${entityName}: ${results.deleted}`)
var arrayKey = this.getEntityArrayKey(entityName) var arrayKey = this.getEntityArrayKey(entityName)
if (this[arrayKey]) { if (this[arrayKey]) {
-59
View File
@@ -1,59 +0,0 @@
// const Podcast = require('podcast')
const express = require('express')
// const ip = require('ip')
const Logger = require('./Logger')
// Not functional at the moment - just an idea
class RssFeeds {
constructor(Port, db) {
this.Port = Port
this.db = db
this.feeds = {}
this.router = express()
this.init()
}
init() {
this.router.get('/:id', this.getFeed.bind(this))
}
getFeed(req, res) {
Logger.info('Get Feed', req.params.id, this.feeds[req.params.id])
var feed = this.feeds[req.params.id]
if (!feed) return null
var xml = feed.buildXml()
res.set('Content-Type', 'text/xml')
res.send(xml)
}
openFeed(audiobook) {
// Removed Podcast npm package and ip package
return null
// var ipAddress = ip.address('public', 'ipv4')
// var serverAddress = 'http://' + ipAddress + ':' + this.Port
// Logger.info('Open RSS Feed', 'Server address', serverAddress)
// var feedId = (Date.now() + Math.floor(Math.random() * 1000)).toString(36)
// const feed = new Podcast({
// title: audiobook.title,
// description: 'AudioBookshelf RSS Feed',
// feed_url: `${serverAddress}/feeds/${feedId}`,
// image_url: `${serverAddress}/Logo.png`,
// author: 'advplyr',
// language: 'en'
// })
// audiobook.tracks.forEach((track) => {
// feed.addItem({
// title: `Track ${track.index}`,
// description: `AudioBookshelf Audiobook Track #${track.index}`,
// url: `${serverAddress}/feeds/${feedId}?track=${track.index}`,
// author: 'advplyr'
// })
// })
// this.feeds[feedId] = feed
// return feed
}
}
module.exports = RssFeeds
+19 -1
View File
@@ -30,6 +30,8 @@ const LogManager = require('./managers/LogManager')
const BackupManager = require('./managers/BackupManager') const BackupManager = require('./managers/BackupManager')
const PlaybackSessionManager = require('./managers/PlaybackSessionManager') const PlaybackSessionManager = require('./managers/PlaybackSessionManager')
const PodcastManager = require('./managers/PodcastManager') const PodcastManager = require('./managers/PodcastManager')
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
const RssFeedManager = require('./managers/RssFeedManager')
class Server { class Server {
constructor(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) { constructor(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
@@ -72,11 +74,13 @@ class Server {
this.playbackSessionManager = new PlaybackSessionManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this)) this.playbackSessionManager = new PlaybackSessionManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
this.coverManager = new CoverManager(this.db, this.cacheManager) this.coverManager = new CoverManager(this.db, this.cacheManager)
this.podcastManager = new PodcastManager(this.db, this.watcher, this.emitter.bind(this)) this.podcastManager = new PodcastManager(this.db, this.watcher, this.emitter.bind(this))
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
this.rssFeedManager = new RssFeedManager(this.db, this.emitter.bind(this))
this.scanner = new Scanner(this.db, this.coverManager, this.emitter.bind(this)) this.scanner = new Scanner(this.db, this.coverManager, this.emitter.bind(this))
// Routers // Routers
this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.emitter.bind(this), this.clientEmitter.bind(this)) this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.audioMetadataManager, this.rssFeedManager, this.emitter.bind(this), this.clientEmitter.bind(this))
this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager, this.emitter.bind(this)) this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager, this.emitter.bind(this))
this.staticRouter = new StaticRouter(this.db) this.staticRouter = new StaticRouter(this.db)
@@ -196,6 +200,19 @@ class Server {
res.sendFile(fullPath) res.sendFile(fullPath)
}) })
// RSS Feed temp route
app.get('/feed/:id', (req, res) => {
Logger.info(`[Server] requesting rss feed ${req.params.id}`)
this.rssFeedManager.getFeed(req, res)
})
app.get('/feed/:id/cover', (req, res) => {
this.rssFeedManager.getFeedCover(req, res)
})
app.get('/feed/:id/item/*', (req, res) => {
Logger.info(`[Server] requesting rss feed ${req.params.id}`)
this.rssFeedManager.getFeedItem(req, res)
})
// Client dynamic routes // Client dynamic routes
app.get('/item/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) app.get('/item/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
@@ -409,6 +426,7 @@ class Server {
await this.db.updateEntity('user', user) await this.db.updateEntity('user', user)
const initialPayload = { const initialPayload = {
// TODO: this is sent with user auth now, update mobile app to use that then remove this
serverSettings: this.db.serverSettings.toJSON(), serverSettings: this.db.serverSettings.toJSON(),
audiobookPath: global.AudiobookPath, audiobookPath: global.AudiobookPath,
metadataPath: global.MetadataPath, metadataPath: global.MetadataPath,
+8 -8
View File
@@ -4,16 +4,16 @@ class BackupController {
constructor() { } constructor() { }
async create(req, res) { async create(req, res) {
if (!req.user.isRoot) { if (!req.user.isAdminOrUp) {
Logger.error(`[BackupController] Non-Root user attempting to craete backup`, req.user) Logger.error(`[BackupController] Non-admin user attempting to craete backup`, req.user)
return res.sendStatus(403) return res.sendStatus(403)
} }
this.backupManager.requestCreateBackup(res) this.backupManager.requestCreateBackup(res)
} }
async delete(req, res) { async delete(req, res) {
if (!req.user.isRoot) { if (!req.user.isAdminOrUp) {
Logger.error(`[BackupController] Non-Root user attempting to delete backup`, req.user) Logger.error(`[BackupController] Non-admin user attempting to delete backup`, req.user)
return res.sendStatus(403) return res.sendStatus(403)
} }
var backup = this.backupManager.backups.find(b => b.id === req.params.id) var backup = this.backupManager.backups.find(b => b.id === req.params.id)
@@ -25,8 +25,8 @@ class BackupController {
} }
async upload(req, res) { async upload(req, res) {
if (!req.user.isRoot) { if (!req.user.isAdminOrUp) {
Logger.error(`[BackupController] Non-Root user attempting to upload backup`, req.user) Logger.error(`[BackupController] Non-admin user attempting to upload backup`, req.user)
return res.sendStatus(403) return res.sendStatus(403)
} }
if (!req.files.file) { if (!req.files.file) {
@@ -37,8 +37,8 @@ class BackupController {
} }
async apply(req, res) { async apply(req, res) {
if (!req.user.isRoot) { if (!req.user.isAdminOrUp) {
Logger.error(`[BackupController] Non-Root user attempting to apply backup`, req.user) Logger.error(`[BackupController] Non-admin user attempting to apply backup`, req.user)
return res.sendStatus(403) return res.sendStatus(403)
} }
var backup = this.backupManager.backups.find(b => b.id === req.params.id) var backup = this.backupManager.backups.find(b => b.id === req.params.id)
+3 -100
View File
@@ -201,7 +201,6 @@ class LibraryController {
libraryItems = naturalSort(libraryItems).by(sortArray) libraryItems = naturalSort(libraryItems).by(sortArray)
} }
// TODO: Potentially implement collapse series again
if (payload.collapseseries) { if (payload.collapseseries) {
libraryItems = libraryHelpers.collapseBookSeries(libraryItems) libraryItems = libraryHelpers.collapseBookSeries(libraryItems)
payload.total = libraryItems.length payload.total = libraryItems.length
@@ -319,105 +318,9 @@ class LibraryController {
res.json(categories) 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 // PATCH: Change the order of libraries
async reorder(req, res) { async reorder(req, res) {
if (!req.user.isRoot) { if (!req.user.isAdminOrUp) {
Logger.error('[LibraryController] ReorderLibraries invalid user', req.user) Logger.error('[LibraryController] ReorderLibraries invalid user', req.user)
return res.sendStatus(403) return res.sendStatus(403)
} }
@@ -554,7 +457,7 @@ class LibraryController {
} }
async matchAll(req, res) { async matchAll(req, res) {
if (!req.user.isRoot) { if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryController] Non-root user attempted to match library items`, req.user) Logger.error(`[LibraryController] Non-root user attempted to match library items`, req.user)
return res.sendStatus(403) return res.sendStatus(403)
} }
@@ -564,7 +467,7 @@ class LibraryController {
// GET: api/scan (Root) // GET: api/scan (Root)
async scan(req, res) { async scan(req, res) {
if (!req.user.isRoot) { if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryController] Non-root user attempted to scan library`, req.user) Logger.error(`[LibraryController] Non-root user attempted to scan library`, req.user)
return res.sendStatus(403) return res.sendStatus(403)
} }
+38 -5
View File
@@ -11,6 +11,17 @@ class LibraryItemController {
if (req.query.expanded == 1) { if (req.query.expanded == 1) {
var item = req.libraryItem.toJSONExpanded() 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 (includeEntities.includes('rssfeed')) {
var feedData = this.rssFeedManager.findFeedForItem(item.id)
item.rssFeedUrl = feedData ? feedData.feedUrl : null
}
if (item.mediaType == 'book') { if (item.mediaType == 'book') {
if (includeEntities.includes('authors')) { if (includeEntities.includes('authors')) {
item.media.metadata.authors = item.media.metadata.authors.map(au => { item.media.metadata.authors = item.media.metadata.authors.map(au => {
@@ -320,8 +331,8 @@ class LibraryItemController {
// DELETE: api/items/all // DELETE: api/items/all
async deleteAll(req, res) { async deleteAll(req, res) {
if (!req.user.isRoot) { if (!req.user.isAdminOrUp) {
Logger.warn('User other than root attempted to delete all library items', req.user) Logger.warn('User other than admin attempted to delete all library items', req.user)
return res.sendStatus(403) return res.sendStatus(403)
} }
Logger.info('Removing all Library Items') Logger.info('Removing all Library Items')
@@ -330,18 +341,40 @@ class LibraryItemController {
else res.sendStatus(500) else res.sendStatus(500)
} }
// GET: api/items/:id/scan (Root) // GET: api/items/:id/scan (admin)
async scan(req, res) { async scan(req, res) {
if (!req.user.isRoot) { if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-root user attempted to scan library item`, req.user) Logger.error(`[LibraryItemController] Non-admin user attempted to scan library item`, req.user)
return res.sendStatus(403) 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) var result = await this.scanner.scanLibraryItemById(req.libraryItem.id)
res.json({ res.json({
result: Object.keys(ScanResult).find(key => ScanResult[key] == result) result: Object.keys(ScanResult).find(key => ScanResult[key] == result)
}) })
} }
// POST: api/items/:id/audio-metadata
async updateAudioFileMetadata(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user)
return res.sendStatus(403)
}
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
Logger.error(`[LibraryItemController] Invalid library item`)
return res.sendStatus(500)
}
this.audioMetadataManager.updateAudioFileMetadataForItem(req.user, req.libraryItem)
res.sendStatus(200)
}
middleware(req, res, next) { middleware(req, res, next) {
var item = this.db.libraryItems.find(li => li.id === req.params.id) var item = this.db.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404) if (!item || !item.media) return res.sendStatus(404)
+4
View File
@@ -133,6 +133,10 @@ class MeController {
// PATCH: api/me/password // PATCH: api/me/password
updatePassword(req, res) { 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) this.auth.userChangePassword(req, res)
} }
+13 -8
View File
@@ -159,10 +159,10 @@ class MiscController {
res.json(downloads) res.json(downloads)
} }
// PATCH: api/settings (Root) // PATCH: api/settings (admin)
async updateServerSettings(req, res) { async updateServerSettings(req, res) {
if (!req.user.isRoot) { if (!req.user.isAdminOrUp) {
Logger.error('User other than root attempting to update server settings', req.user) Logger.error('User other than admin attempting to update server settings', req.user)
return res.sendStatus(403) return res.sendStatus(403)
} }
var settingsUpdate = req.body var settingsUpdate = req.body
@@ -185,9 +185,9 @@ class MiscController {
}) })
} }
// POST: api/purgecache (Root) // POST: api/purgecache (admin)
async purgeCache(req, res) { async purgeCache(req, res) {
if (!req.user.isRoot) { if (!req.user.isAdminOrUp) {
return res.sendStatus(403) return res.sendStatus(403)
} }
Logger.info(`[ApiRouter] Purging all cache`) Logger.info(`[ApiRouter] Purging all cache`)
@@ -230,12 +230,17 @@ class MiscController {
Logger.error('Invalid user in authorize') Logger.error('Invalid user in authorize')
return res.sendStatus(401) 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) { getAllTags(req, res) {
if (!req.user.isRoot) { if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-root user attempted to getAllTags`) Logger.error(`[MiscController] Non-admin user attempted to getAllTags`)
return res.sendStatus(404) return res.sendStatus(404)
} }
var tags = [] var tags = []
+78 -25
View File
@@ -9,8 +9,8 @@ const filePerms = require('../utils/filePerms')
class PodcastController { class PodcastController {
async create(req, res) { async create(req, res) {
if (!req.user.isRoot) { if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-root user attempted to create podcast`, req.user) Logger.error(`[PodcastController] Non-admin user attempted to create podcast`, req.user)
return res.sendStatus(500) return res.sendStatus(500)
} }
const payload = req.body const payload = req.body
@@ -115,24 +115,26 @@ class PodcastController {
} }
async checkNewEpisodes(req, res) { async checkNewEpisodes(req, res) {
var libraryItem = this.db.getLibraryItem(req.params.id) if (!req.user.isAdminOrUp) {
if (!libraryItem || libraryItem.mediaType !== 'podcast') { Logger.error(`[PodcastController] Non-admin user attempted to check/download episodes`, req.user)
return res.sendStatus(404) return res.sendStatus(500)
} }
var libraryItem = req.libraryItem
if (!libraryItem.media.metadata.feedUrl) { if (!libraryItem.media.metadata.feedUrl) {
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${libraryItem.id}`) Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${libraryItem.id}`)
return res.status(500).send('Podcast has no rss feed url') 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({ res.json({
episodes: newEpisodes || [] episodes: newEpisodes || []
}) })
} }
clearEpisodeDownloadQueue(req, res) { clearEpisodeDownloadQueue(req, res) {
if (!req.user.canUpdate) { if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] User attempting to clear download queue without permission "${req.user.username}"`) Logger.error(`[PodcastController] Non-admin user attempting to clear download queue "${req.user.username}"`)
return res.sendStatus(500) return res.sendStatus(500)
} }
this.podcastManager.clearDownloadQueue(req.params.id) this.podcastManager.clearDownloadQueue(req.params.id)
@@ -140,10 +142,8 @@ class PodcastController {
} }
getEpisodeDownloads(req, res) { getEpisodeDownloads(req, res) {
var libraryItem = this.db.getLibraryItem(req.params.id) var libraryItem = req.libraryItem
if (!libraryItem || libraryItem.mediaType !== 'podcast') {
return res.sendStatus(404)
}
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id) var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
res.json({ res.json({
downloads: downloadsInQueue.map(d => d.toJSONForClient()) downloads: downloadsInQueue.map(d => d.toJSONForClient())
@@ -151,13 +151,11 @@ class PodcastController {
} }
async downloadEpisodes(req, res) { async downloadEpisodes(req, res) {
var libraryItem = this.db.getLibraryItem(req.params.id) if (!req.user.isAdminOrUp) {
if (!libraryItem || libraryItem.mediaType !== 'podcast') { Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user)
return res.sendStatus(404) return res.sendStatus(500)
}
if (!req.user.canUpload || !req.user.checkCanAccessLibrary(libraryItem.libraryId)) {
return res.sendStatus(404)
} }
var libraryItem = req.libraryItem
var episodes = req.body var episodes = req.body
if (!episodes || !episodes.length) { if (!episodes || !episodes.length) {
@@ -168,14 +166,39 @@ class PodcastController {
res.sendStatus(200) res.sendStatus(200)
} }
async openPodcastFeed(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user attempted to open podcast feed`, req.user.username)
return res.sendStatus(500)
}
const feedData = this.rssFeedManager.openPodcastFeed(req.user, req.libraryItem, req.body)
if (feedData.error) {
return res.json({
success: false,
error: feedData.error
})
}
res.json({
success: true,
feedUrl: feedData.feedUrl
})
}
async closePodcastFeed(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user attempted to close podcast feed`, req.user.username)
return res.sendStatus(500)
}
this.rssFeedManager.closePodcastFeedForItem(req.params.id)
res.sendStatus(200)
}
async updateEpisode(req, res) { async updateEpisode(req, res) {
var libraryItem = this.db.getLibraryItem(req.params.id) var libraryItem = req.libraryItem
if (!libraryItem || libraryItem.mediaType !== 'podcast') {
return res.sendStatus(404)
}
if (!req.user.canUpload || !req.user.checkCanAccessLibrary(libraryItem.libraryId)) {
return res.sendStatus(404)
}
var episodeId = req.params.episodeId var episodeId = req.params.episodeId
if (!libraryItem.media.checkHasEpisode(episodeId)) { if (!libraryItem.media.checkHasEpisode(episodeId)) {
@@ -190,5 +213,35 @@ class PodcastController {
res.json(libraryItem.toJSONExpanded()) res.json(libraryItem.toJSONExpanded())
} }
middleware(req, res, next) {
var item = this.db.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404)
if (!item.isPodcast) {
return res.sendStatus(500)
}
// Check user can access this library
if (!req.user.checkCanAccessLibrary(item.libraryId)) {
return res.sendStatus(403)
}
// Check user can access this library item
if (!req.user.checkCanAccessLibraryItemWithTags(item.media.tags)) {
return res.sendStatus(403)
}
if (req.method == 'DELETE' && !req.user.canDelete) {
Logger.warn(`[PodcastController] User attempted to delete without permission`, req.user.username)
return res.sendStatus(403)
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
Logger.warn('[PodcastController] User attempted to update without permission', req.user.username)
return res.sendStatus(403)
}
req.libraryItem = item
next()
}
} }
module.exports = new PodcastController() module.exports = new PodcastController()
+19 -13
View File
@@ -7,14 +7,15 @@ class UserController {
constructor() { } constructor() { }
findAll(req, res) { findAll(req, res) {
if (!req.user.isRoot) return res.sendStatus(403) if (!req.user.isAdminOrUp) return res.sendStatus(403)
var users = this.db.users.map(u => this.userJsonWithItemProgressDetails(u)) const hideRootToken = !req.user.isRoot
var users = this.db.users.map(u => this.userJsonWithItemProgressDetails(u, hideRootToken))
res.json(users) res.json(users)
} }
findOne(req, res) { findOne(req, res) {
if (!req.user.isRoot) { if (!req.user.isAdminOrUp) {
Logger.error('User other than root attempting to get user', req.user) Logger.error('User other than admin attempting to get user', req.user)
return res.sendStatus(403) return res.sendStatus(403)
} }
@@ -23,12 +24,12 @@ class UserController {
return res.sendStatus(404) return res.sendStatus(404)
} }
res.json(this.userJsonWithItemProgressDetails(user)) res.json(this.userJsonWithItemProgressDetails(user, !req.user.isRoot))
} }
async create(req, res) { async create(req, res) {
if (!req.user.isRoot) { if (!req.user.isAdminOrUp) {
Logger.warn('Non-root user attempted to create user', req.user) Logger.warn('Non-admin user attempted to create user', req.user)
return res.sendStatus(403) return res.sendStatus(403)
} }
var account = req.body var account = req.body
@@ -57,8 +58,8 @@ class UserController {
} }
async update(req, res) { async update(req, res) {
if (!req.user.isRoot) { if (!req.user.isAdminOrUp) {
Logger.error('User other than root attempting to update user', req.user) Logger.error('[UserController] User other than admin attempting to update user', req.user)
return res.sendStatus(403) return res.sendStatus(403)
} }
@@ -67,6 +68,11 @@ class UserController {
return res.sendStatus(404) return res.sendStatus(404)
} }
if (user.type === 'root' && !req.user.isRoot) {
Logger.error(`[UserController] Admin user attempted to update root user`, req.user.username)
return res.sendStatus(403)
}
var account = req.body var account = req.body
if (account.username !== undefined && account.username !== user.username) { if (account.username !== undefined && account.username !== user.username) {
@@ -95,8 +101,8 @@ class UserController {
} }
async delete(req, res) { async delete(req, res) {
if (!req.user.isRoot) { if (!req.user.isAdminOrUp) {
Logger.error('User other than root attempting to delete user', req.user) Logger.error('User other than admin attempting to delete user', req.user)
return res.sendStatus(403) return res.sendStatus(403)
} }
if (req.params.id === 'root') { if (req.params.id === 'root') {
@@ -133,7 +139,7 @@ class UserController {
// GET: api/users/:id/listening-sessions // GET: api/users/:id/listening-sessions
async getListeningSessions(req, res) { async getListeningSessions(req, res) {
if (!req.user.isRoot && req.user.id !== req.params.id) { if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
return res.sendStatus(403) return res.sendStatus(403)
} }
var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id) var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id)
@@ -142,7 +148,7 @@ class UserController {
// GET: api/users/:id/listening-stats // GET: api/users/:id/listening-stats
async getListeningStats(req, res) { async getListeningStats(req, res) {
if (!req.user.isRoot && req.user.id !== req.params.id) { if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
return res.sendStatus(403) return res.sendStatus(403)
} }
var listeningStats = await this.getUserListeningStatsHelpers(req.params.id) var listeningStats = await this.getUserListeningStatsHelpers(req.params.id)
+1 -1
View File
@@ -121,7 +121,7 @@ class AbMergeManager {
'-acodec aac', '-acodec aac',
'-ac 2', '-ac 2',
'-b:a 64k', '-b:a 64k',
'-id3v2_version 3' '-movflags use_metadata_tags'
]) ])
} else { } else {
ffmpegOptions.push('-max_muxing_queue_size 1000') ffmpegOptions.push('-max_muxing_queue_size 1000')
+140
View File
@@ -0,0 +1,140 @@
const Path = require('path')
const fs = require('fs-extra')
const workerThreads = require('worker_threads')
const Logger = require('../Logger')
const filePerms = require('../utils/filePerms')
const { secondsToTimestamp } = require('../utils/index')
const { writeMetadataFile } = require('../utils/ffmpegHelpers')
class AudioMetadataMangaer {
constructor(db, emitter, clientEmitter) {
this.db = db
this.emitter = emitter
this.clientEmitter = clientEmitter
}
async updateAudioFileMetadataForItem(user, libraryItem) {
var audioFiles = libraryItem.media.audioFiles
const itemAudioMetadataPayload = {
userId: user.id,
libraryItemId: libraryItem.id,
startedAt: Date.now(),
audioFiles: audioFiles.map(af => ({ index: af.index, ino: af.ino, filename: af.metadata.filename }))
}
this.emitter('audio_metadata_started', itemAudioMetadataPayload)
var downloadsPath = Path.join(global.MetadataPath, 'downloads')
var outputDir = Path.join(downloadsPath, libraryItem.id)
await fs.ensureDir(outputDir)
var metadataFilePath = Path.join(outputDir, 'metadata.txt')
await writeMetadataFile(libraryItem, metadataFilePath)
// TODO: Split into batches
const proms = audioFiles.map(af => {
return this.updateAudioFileMetadata(libraryItem.id, af, outputDir, metadataFilePath)
})
const results = await Promise.all(proms)
Logger.debug(`[AudioMetadataManager] Finished`)
await fs.remove(outputDir)
const elapsed = Date.now() - itemAudioMetadataPayload.startedAt
Logger.debug(`[AudioMetadataManager] Elapsed ${secondsToTimestamp(elapsed)}`)
itemAudioMetadataPayload.results = results
itemAudioMetadataPayload.elapsed = elapsed
itemAudioMetadataPayload.finishedAt = Date.now()
this.emitter('audio_metadata_finished', itemAudioMetadataPayload)
}
updateAudioFileMetadata(libraryItemId, audioFile, outputDir, metadataFilePath) {
return new Promise((resolve) => {
const resultPayload = {
libraryItemId,
index: audioFile.index,
ino: audioFile.ino,
filename: audioFile.metadata.filename
}
this.emitter('audiofile_metadata_started', resultPayload)
Logger.debug(`[AudioFileMetadataManager] Starting audio file metadata encode for "${audioFile.metadata.filename}"`)
var outputPath = Path.join(outputDir, audioFile.metadata.filename)
var inputPath = audioFile.metadata.path
const isM4b = audioFile.metadata.format === 'm4b'
const ffmpegInputs = [
{
input: inputPath,
options: isM4b ? ['-f mp4'] : []
},
{
input: metadataFilePath
}
]
/*
Mp4 doesnt support writing custom tags by default. Supported tags are itunes tags: https://git.videolan.org/?p=ffmpeg.git;a=blob;f=libavformat/movenc.c;h=b6821d447c92183101086cb67099b2f4804293de;hb=HEAD#l2905
Workaround -movflags use_metadata_tags found here: https://superuser.com/a/1208277
Ffmpeg premapped id3 tags: https://wiki.multimedia.cx/index.php/FFmpeg_Metadata
*/
const ffmpegOptions = ['-c copy', '-map_metadata 1', `-metadata track=${audioFile.index}`, '-write_id3v2 1', '-movflags use_metadata_tags']
var workerData = {
inputs: ffmpegInputs,
options: ffmpegOptions,
outputOptions: isM4b ? ['-f mp4'] : [],
output: outputPath,
}
var workerPath = Path.join(global.appRoot, 'server/utils/downloadWorker.js')
var worker = new workerThreads.Worker(workerPath, { workerData })
worker.on('message', async (message) => {
if (message != null && typeof message === 'object') {
if (message.type === 'RESULT') {
Logger.debug(message)
if (message.success) {
Logger.debug(`[AudioFileMetadataManager] Metadata encode SUCCESS for "${audioFile.metadata.filename}"`)
await filePerms.setDefault(outputPath, true)
fs.move(outputPath, inputPath, { overwrite: true }).then(() => {
Logger.debug(`[AudioFileMetadataManager] Audio file replaced successfully "${inputPath}"`)
resultPayload.success = true
this.emitter('audiofile_metadata_finished', resultPayload)
resolve(resultPayload)
}).catch((error) => {
Logger.error(`[AudioFileMetadataManager] Audio file failed to move "${inputPath}"`, error)
resultPayload.success = false
this.emitter('audiofile_metadata_finished', resultPayload)
resolve(resultPayload)
})
} else {
Logger.debug(`[AudioFileMetadataManager] Metadata encode FAILED for "${audioFile.metadata.filename}"`)
resultPayload.success = false
this.emitter('audiofile_metadata_finished', resultPayload)
resolve(resultPayload)
}
} else if (message.type === 'FFMPEG') {
if (message.level === 'debug' && process.env.NODE_ENV === 'production') {
// stderr is not necessary in production
} else if (Logger[message.level]) {
Logger[message.level](message.log)
}
}
} else {
Logger.error('Invalid worker message', message)
}
})
})
}
}
module.exports = AudioMetadataMangaer
+15 -2
View File
@@ -131,8 +131,21 @@ class BackupManager {
var filename = filesInDir[i] var filename = filesInDir[i]
if (filename.endsWith('.audiobookshelf')) { if (filename.endsWith('.audiobookshelf')) {
var fullFilePath = Path.join(this.BackupPath, filename) var fullFilePath = Path.join(this.BackupPath, filename)
const zip = new StreamZip.async({ file: fullFilePath })
const data = await zip.entryData('details') let zip = null
let data = null
try {
zip = new StreamZip.async({ file: fullFilePath })
data = await zip.entryData('details')
} catch (error) {
if (error.message === "Bad archive") {
Logger.warn(`[BackupManager] Backup appears to be corrupted: ${fullFilePath}`)
continue;
} else {
throw error
}
}
var details = data.toString('utf8').split('\n') var details = data.toString('utf8').split('\n')
var backup = new Backup({ details, fullPath: fullFilePath }) var backup = new Backup({ details, fullPath: fullFilePath })
+1 -1
View File
@@ -19,7 +19,7 @@ class CoverManager {
} }
getCoverDirectory(libraryItem) { getCoverDirectory(libraryItem) {
if (this.db.serverSettings.storeCoverWithItem) { if (this.db.serverSettings.storeCoverWithItem && !libraryItem.isFile) {
return libraryItem.path return libraryItem.path
} else { } else {
return Path.posix.join(this.ItemMetadataPath, libraryItem.id) return Path.posix.join(this.ItemMetadataPath, libraryItem.id)
+54 -7
View File
@@ -22,6 +22,7 @@ class PodcastManager {
this.currentDownload = null this.currentDownload = null
this.episodeScheduleTask = null this.episodeScheduleTask = null
this.failedCheckMap = {}
} }
get serverSettings() { get serverSettings() {
@@ -154,7 +155,10 @@ class PodcastManager {
schedulePodcastEpisodeCron() { schedulePodcastEpisodeCron() {
try { try {
Logger.debug(`[PodcastManager] Scheduled podcast episode check cron "${this.serverSettings.podcastEpisodeSchedule}"`) Logger.debug(`[PodcastManager] Scheduled podcast episode check cron "${this.serverSettings.podcastEpisodeSchedule}"`)
this.episodeScheduleTask = cron.schedule(this.serverSettings.podcastEpisodeSchedule, this.checkForNewEpisodes.bind(this)) this.episodeScheduleTask = cron.schedule(this.serverSettings.podcastEpisodeSchedule, () => {
Logger.debug(`[PodcastManager] Running cron`)
this.checkForNewEpisodes()
})
} catch (error) { } catch (error) {
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error) Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
} }
@@ -171,21 +175,35 @@ class PodcastManager {
async checkForNewEpisodes() { async checkForNewEpisodes() {
var podcastsWithAutoDownload = this.db.libraryItems.filter(li => li.mediaType === 'podcast' && li.media.autoDownloadEpisodes) var podcastsWithAutoDownload = this.db.libraryItems.filter(li => li.mediaType === 'podcast' && li.media.autoDownloadEpisodes)
if (!podcastsWithAutoDownload.length) { if (!podcastsWithAutoDownload.length) {
Logger.info(`[PodcastManager] checkForNewEpisodes - No podcasts with auto download set`)
this.cancelCron() this.cancelCron()
return return
} }
Logger.debug(`[PodcastManager] checkForNewEpisodes - Checking ${podcastsWithAutoDownload.length} Podcasts`)
for (const libraryItem of podcastsWithAutoDownload) { for (const libraryItem of podcastsWithAutoDownload) {
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0) const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
Logger.info(`[PodcastManager] checkForNewEpisodes Cron for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`) Logger.info(`[PodcastManager] checkForNewEpisodes Cron for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem) var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem)
Logger.debug(`[PodcastManager] checkForNewEpisodes checked result ${newEpisodes ? newEpisodes.length : 'N/A'}`)
if (!newEpisodes) { // Failed if (!newEpisodes) { // Failed
libraryItem.media.autoDownloadEpisodes = false // Allow up to 3 failed attempts before disabling auto download
if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0
this.failedCheckMap[libraryItem.id]++
if (this.failedCheckMap[libraryItem.id] > 2) {
Logger.error(`[PodcastManager] checkForNewEpisodes 3 failed attempts at checking episodes for "${libraryItem.media.metadata.title}" - disabling auto download`)
libraryItem.media.autoDownloadEpisodes = false
delete this.failedCheckMap[libraryItem.id]
} else {
Logger.warn(`[PodcastManager] checkForNewEpisodes ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}"`)
}
} else if (newEpisodes.length) { } else if (newEpisodes.length) {
delete this.failedCheckMap[libraryItem.id]
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`) Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
this.downloadPodcastEpisodes(libraryItem, newEpisodes) this.downloadPodcastEpisodes(libraryItem, newEpisodes)
} else { } else {
delete this.failedCheckMap[libraryItem.id]
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`) Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`)
} }
@@ -198,27 +216,56 @@ class PodcastManager {
async checkPodcastForNewEpisodes(podcastLibraryItem) { async checkPodcastForNewEpisodes(podcastLibraryItem) {
if (!podcastLibraryItem.media.metadata.feedUrl) { if (!podcastLibraryItem.media.metadata.feedUrl) {
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id}) - disabling auto download`) Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`)
return false return false
} }
var feed = await this.getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl) var feed = await this.getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl)
if (!feed || !feed.episodes) { if (!feed || !feed.episodes) {
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id}) - disabling auto download`) Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`, feed)
return false return false
} }
// Added for testing
Logger.debug(`[PodcastManager] checkPodcastForNewEpisodes: ${feed.episodes.length} episodes in feed for "${podcastLibraryItem.media.metadata.title}"`)
const latestEpisodes = feed.episodes.slice(0, 3)
latestEpisodes.forEach((ep) => {
Logger.debug(`[PodcastManager] checkPodcastForNewEpisodes: Recent episode "${ep.title}", pubDate=${ep.pubDate}, publishedAt=${ep.publishedAt}/${new Date(ep.publishedAt)} for "${podcastLibraryItem.media.metadata.title}"`)
})
// Filter new and not already has // Filter new and not already has
var newEpisodes = feed.episodes.filter(ep => ep.publishedAt > podcastLibraryItem.media.lastEpisodeCheck && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url)) var newEpisodes = feed.episodes.filter(ep => ep.publishedAt > podcastLibraryItem.media.lastEpisodeCheck && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url))
// Max new episodes for safety = 2 // Max new episodes for safety = 3
newEpisodes = newEpisodes.slice(0, 2) 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 return newEpisodes
} }
getPodcastFeed(feedUrl) { getPodcastFeed(feedUrl) {
return axios.get(feedUrl).then(async (data) => { Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}"`)
return axios.get(feedUrl, { timeout: 5000 }).then(async (data) => {
if (!data || !data.data) { if (!data || !data.data) {
Logger.error('Invalid podcast feed request response') Logger.error('Invalid podcast feed request response')
return false return false
} }
Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}" success - parsing xml`)
var payload = await parsePodcastRssFeedXml(data.data) var payload = await parsePodcastRssFeedXml(data.data)
if (!payload) { if (!payload) {
return false return false
+140
View File
@@ -0,0 +1,140 @@
const Path = require('path')
const fs = require('fs-extra')
const { Podcast } = require('podcast')
const { getId } = require('../utils/index')
const Logger = require('../Logger')
// Not functional at the moment
class RssFeedManager {
constructor(db, emitter) {
this.db = db
this.emitter = emitter
this.feeds = {}
}
findFeedForItem(libraryItemId) {
return Object.values(this.feeds).find(feed => feed.libraryItemId === libraryItemId)
}
getFeed(req, res) {
var feedData = this.feeds[req.params.id]
if (!feedData) {
Logger.error(`[RssFeedManager] Feed not found ${req.params.id}`)
res.sendStatus(404)
return
}
var xml = feedData.feed.buildXml()
res.set('Content-Type', 'text/xml')
res.send(xml)
}
getFeedItem(req, res) {
var feedData = this.feeds[req.params.id]
if (!feedData) {
Logger.error(`[RssFeedManager] Feed not found ${req.params.id}`)
res.sendStatus(404)
return
}
var remainingPath = req.params['0']
var fullPath = Path.join(feedData.libraryItemPath, remainingPath)
res.sendFile(fullPath)
}
getFeedCover(req, res) {
var feedData = this.feeds[req.params.id]
if (!feedData) {
Logger.error(`[RssFeedManager] Feed not found ${req.params.id}`)
res.sendStatus(404)
return
}
if (!feedData.mediaCoverPath) {
res.sendStatus(404)
return
}
const extname = Path.extname(feedData.mediaCoverPath).toLowerCase().slice(1)
res.type(`image/${extname}`)
var readStream = fs.createReadStream(feedData.mediaCoverPath)
readStream.pipe(res)
}
openFeed(userId, slug, libraryItem, serverAddress) {
const podcast = libraryItem.media
const feedUrl = `${serverAddress}/feed/${slug}`
// Removed Podcast npm package and ip package
const feed = new Podcast({
title: podcast.metadata.title,
description: podcast.metadata.description,
feedUrl,
siteUrl: serverAddress,
imageUrl: podcast.coverPath ? `${serverAddress}/feed/${slug}/cover` : `${serverAddress}/Logo.png`,
author: podcast.metadata.author || 'advplyr',
language: 'en'
})
podcast.episodes.forEach((episode) => {
var contentUrl = episode.audioTrack.contentUrl.replace(/\\/g, '/')
contentUrl = contentUrl.replace(`/s/item/${libraryItem.id}`, `/feed/${slug}/item`)
feed.addItem({
title: episode.title,
description: episode.description || '',
enclosure: {
url: `${serverAddress}${contentUrl}`,
type: episode.audioTrack.mimeType,
size: episode.size
},
date: episode.pubDate || '',
url: `${serverAddress}${contentUrl}`,
author: podcast.metadata.author || 'advplyr'
})
})
const feedData = {
id: slug,
slug,
userId,
libraryItemId: libraryItem.id,
libraryItemPath: libraryItem.path,
mediaCoverPath: podcast.coverPath,
serverAddress: serverAddress,
feedUrl,
feed
}
this.feeds[slug] = feedData
return feedData
}
openPodcastFeed(user, libraryItem, options) {
const serverAddress = options.serverAddress
const slug = options.slug
if (this.feeds[slug]) {
Logger.error(`[RssFeedManager] Slug already in use`)
return {
error: `Slug "${slug}" already in use`
}
}
const feedData = this.openFeed(user.id, slug, libraryItem, serverAddress)
Logger.debug(`[RssFeedManager] Opened podcast feed ${feedData.feedUrl}`)
this.emitter('rss_feed_open', { libraryItemId: libraryItem.id, feedUrl: feedData.feedUrl })
return feedData
}
closePodcastFeedForItem(libraryItemId) {
var feed = this.findFeedForItem(libraryItemId)
if (!feed) return
this.closeRssFeed(feed.id)
}
closeRssFeed(id) {
if (!this.feeds[id]) return
var feedData = this.feeds[id]
this.emitter('rss_feed_closed', { libraryItemId: feedData.libraryItemId, feedUrl: feedData.feedUrl })
delete this.feeds[id]
Logger.info(`[RssFeedManager] Closed RSS feed "${feedData.feedUrl}"`)
}
}
module.exports = RssFeedManager
+6 -1
View File
@@ -18,6 +18,7 @@ class LibraryItem {
this.path = null this.path = null
this.relPath = null this.relPath = null
this.isFile = false
this.mtimeMs = null this.mtimeMs = null
this.ctimeMs = null this.ctimeMs = null
this.birthtimeMs = null this.birthtimeMs = null
@@ -51,6 +52,7 @@ class LibraryItem {
this.folderId = libraryItem.folderId this.folderId = libraryItem.folderId
this.path = libraryItem.path this.path = libraryItem.path
this.relPath = libraryItem.relPath this.relPath = libraryItem.relPath
this.isFile = !!libraryItem.isFile
this.mtimeMs = libraryItem.mtimeMs || 0 this.mtimeMs = libraryItem.mtimeMs || 0
this.ctimeMs = libraryItem.ctimeMs || 0 this.ctimeMs = libraryItem.ctimeMs || 0
this.birthtimeMs = libraryItem.birthtimeMs || 0 this.birthtimeMs = libraryItem.birthtimeMs || 0
@@ -82,6 +84,7 @@ class LibraryItem {
folderId: this.folderId, folderId: this.folderId,
path: this.path, path: this.path,
relPath: this.relPath, relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtimeMs, mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs, ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs, birthtimeMs: this.birthtimeMs,
@@ -105,6 +108,7 @@ class LibraryItem {
folderId: this.folderId, folderId: this.folderId,
path: this.path, path: this.path,
relPath: this.relPath, relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtimeMs, mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs, ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs, birthtimeMs: this.birthtimeMs,
@@ -128,6 +132,7 @@ class LibraryItem {
folderId: this.folderId, folderId: this.folderId,
path: this.path, path: this.path,
relPath: this.relPath, relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtimeMs, mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs, ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs, birthtimeMs: this.birthtimeMs,
@@ -460,7 +465,7 @@ class LibraryItem {
this.isSavingMetadata = true this.isSavingMetadata = true
var metadataPath = Path.join(global.MetadataPath, 'items', this.id) var metadataPath = Path.join(global.MetadataPath, 'items', this.id)
if (global.ServerSettings.storeMetadataWithItem) { if (global.ServerSettings.storeMetadataWithItem && !this.isFile) {
metadataPath = this.path metadataPath = this.path
} else { } else {
// Make sure metadata book dir exists // Make sure metadata book dir exists
+7 -1
View File
@@ -4,6 +4,8 @@ const Logger = require('../../Logger')
class LibrarySettings { class LibrarySettings {
constructor(settings) { constructor(settings) {
this.disableWatcher = false this.disableWatcher = false
this.skipMatchingMediaWithAsin = false
this.skipMatchingMediaWithIsbn = false
if (settings) { if (settings) {
this.construct(settings) this.construct(settings)
@@ -12,11 +14,15 @@ class LibrarySettings {
construct(settings) { construct(settings) {
this.disableWatcher = !!settings.disableWatcher this.disableWatcher = !!settings.disableWatcher
this.skipMatchingMediaWithAsin = !!settings.skipMatchingMediaWithAsin
this.skipMatchingMediaWithIsbn = !!settings.skipMatchingMediaWithIsbn
} }
toJSON() { toJSON() {
return { return {
disableWatcher: this.disableWatcher disableWatcher: this.disableWatcher,
skipMatchingMediaWithAsin: this.skipMatchingMediaWithAsin,
skipMatchingMediaWithIsbn: this.skipMatchingMediaWithIsbn
} }
} }
+1 -1
View File
@@ -79,7 +79,7 @@ class ServerSettings {
this.backupSchedule = settings.backupSchedule || false this.backupSchedule = settings.backupSchedule || false
this.backupsToKeep = settings.backupsToKeep || 2 this.backupsToKeep = settings.backupsToKeep || 2
this.maxBackupSize = settings.maxBackupSize || 1 this.maxBackupSize = settings.maxBackupSize || 1
this.backupMetadataCovers = settings.backupMetadataCovers !== false this.backupMetadataCovers = settings.backupMetadataCovers !== false
this.loggerDailyLogsToKeep = settings.loggerDailyLogsToKeep || 7 this.loggerDailyLogsToKeep = settings.loggerDailyLogsToKeep || 7
+1 -1
View File
@@ -2,7 +2,7 @@ const Logger = require('../../Logger')
class MediaProgress { class MediaProgress {
constructor(progress) { constructor(progress) {
this.id = null // Same as library item id this.id = null
this.libraryItemId = null this.libraryItemId = null
this.episodeId = null // For podcasts this.episodeId = null // For podcasts
+27 -4
View File
@@ -30,6 +30,15 @@ class User {
get isRoot() { get isRoot() {
return this.type === 'root' 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() { get canDelete() {
return !!this.permissions.delete && this.isActive return !!this.permissions.delete && this.isActive
} }
@@ -57,7 +66,7 @@ class User {
mobileOrderBy: 'recent', mobileOrderBy: 'recent',
mobileOrderDesc: true, mobileOrderDesc: true,
mobileFilterBy: 'all', mobileFilterBy: 'all',
orderBy: 'book.title', orderBy: 'media.metadata.title',
orderDesc: false, orderDesc: false,
filterBy: 'all', filterBy: 'all',
playbackRate: 1, playbackRate: 1,
@@ -186,6 +195,7 @@ class User {
} }
} }
}) })
// And update permissions // And update permissions
if (payload.permissions) { if (payload.permissions) {
for (const key in payload.permissions) { for (const key in payload.permissions) {
@@ -195,8 +205,15 @@ class User {
} }
} }
} }
// Update accessible libraries // 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.length) {
if (payload.librariesAccessible.join(',') !== this.librariesAccessible.join(',')) { if (payload.librariesAccessible.join(',') !== this.librariesAccessible.join(',')) {
hasUpdates = true hasUpdates = true
@@ -208,8 +225,14 @@ class User {
} }
} }
// Update accessible libraries // Update accessible tags
if (payload.itemTagsAccessible !== undefined) { 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.length) {
if (payload.itemTagsAccessible.join(',') !== this.itemTagsAccessible.join(',')) { if (payload.itemTagsAccessible.join(',') !== this.itemTagsAccessible.join(',')) {
hasUpdates = true hasUpdates = true
+15 -8
View File
@@ -25,7 +25,7 @@ const Series = require('../objects/entities/Series')
const FileSystemController = require('../controllers/FileSystemController') const FileSystemController = require('../controllers/FileSystemController')
class ApiRouter { class ApiRouter {
constructor(db, auth, scanner, playbackSessionManager, abMergeManager, coverManager, backupManager, watcher, cacheManager, podcastManager, emitter, clientEmitter) { constructor(db, auth, scanner, playbackSessionManager, abMergeManager, coverManager, backupManager, watcher, cacheManager, podcastManager, audioMetadataManager, rssFeedManager, emitter, clientEmitter) {
this.db = db this.db = db
this.auth = auth this.auth = auth
this.scanner = scanner this.scanner = scanner
@@ -36,6 +36,8 @@ class ApiRouter {
this.watcher = watcher this.watcher = watcher
this.cacheManager = cacheManager this.cacheManager = cacheManager
this.podcastManager = podcastManager this.podcastManager = podcastManager
this.audioMetadataManager = audioMetadataManager
this.rssFeedManager = rssFeedManager
this.emitter = emitter this.emitter = emitter
this.clientEmitter = clientEmitter this.clientEmitter = clientEmitter
@@ -61,7 +63,6 @@ class ApiRouter {
this.router.delete('/libraries/:id/issues', LibraryController.middleware.bind(this), LibraryController.removeLibraryItemsWithIssues.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/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/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/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/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/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
@@ -92,6 +93,7 @@ class ApiRouter {
this.router.patch('/items/:id/episodes', LibraryItemController.middleware.bind(this), LibraryItemController.updateEpisodes.bind(this)) this.router.patch('/items/:id/episodes', LibraryItemController.middleware.bind(this), LibraryItemController.updateEpisodes.bind(this))
this.router.delete('/items/:id/episode/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.removeEpisode.bind(this)) this.router.delete('/items/:id/episode/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.removeEpisode.bind(this))
this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this)) // Root only this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this)) // Root only
this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this)) // Root only
this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this)) this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this)) this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))
@@ -179,11 +181,13 @@ class ApiRouter {
// //
this.router.post('/podcasts', PodcastController.create.bind(this)) this.router.post('/podcasts', PodcastController.create.bind(this))
this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.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/checknew', PodcastController.middleware.bind(this), PodcastController.checkNewEpisodes.bind(this))
this.router.get('/podcasts/:id/downloads', PodcastController.getEpisodeDownloads.bind(this)) this.router.get('/podcasts/:id/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this))
this.router.get('/podcasts/:id/clear-queue', PodcastController.clearEpisodeDownloadQueue.bind(this)) this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this))
this.router.post('/podcasts/:id/download-episodes', PodcastController.downloadEpisodes.bind(this)) this.router.post('/podcasts/:id/download-episodes', PodcastController.middleware.bind(this), PodcastController.downloadEpisodes.bind(this))
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.updateEpisode.bind(this)) this.router.post('/podcasts/:id/open-feed', PodcastController.middleware.bind(this), PodcastController.openPodcastFeed.bind(this))
this.router.post('/podcasts/:id/close-feed', PodcastController.middleware.bind(this), PodcastController.closePodcastFeed.bind(this))
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.updateEpisode.bind(this))
// //
// Misc Routes // Misc Routes
@@ -235,8 +239,11 @@ class ApiRouter {
// //
// Helper Methods // Helper Methods
// //
userJsonWithItemProgressDetails(user) { userJsonWithItemProgressDetails(user, hideRootToken = false) {
var json = user.toJSONForBrowser() var json = user.toJSONForBrowser()
if (json.type === 'root' && hideRootToken) {
json.token = ''
}
json.mediaProgress = json.mediaProgress.map(lip => { json.mediaProgress = json.mediaProgress.map(lip => {
var libraryItem = this.db.libraryItems.find(li => li.id === lip.id) var libraryItem = this.db.libraryItems.find(li => li.id === lip.id)
+16 -1
View File
@@ -235,7 +235,7 @@ class Scanner {
var hasMediaFile = dataFound.libraryFiles.some(lf => lf.isMediaFile) var hasMediaFile = dataFound.libraryFiles.some(lf => lf.isMediaFile)
if (!hasMediaFile) { 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 { } else {
var audioFileSize = 0 var audioFileSize = 0
dataFound.libraryFiles.filter(lf => lf.fileType == 'audio').forEach(lf => audioFileSize += lf.metadata.size) 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++) { for (let i = 0; i < itemsInLibrary.length; i++) {
var libraryItem = itemsInLibrary[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})`) Logger.debug(`[Scanner] matchLibraryItems: Quick matching "${libraryItem.media.metadata.title}" (${i + 1} of ${itemsInLibrary.length})`)
var result = await this.quickMatchLibraryItem(libraryItem, { provider }) var result = await this.quickMatchLibraryItem(libraryItem, { provider })
if (result.warning) { if (result.warning) {
+1 -1
View File
@@ -39,7 +39,7 @@ async function runFfmpeg() {
ffmpegCommand.on('stderr', (stdErrline) => { ffmpegCommand.on('stderr', (stdErrline) => {
parentPort.postMessage({ parentPort.postMessage({
type: 'FFMPEG', type: 'FFMPEG',
level: 'error', level: 'debug',
log: '[DownloadWorker] Ffmpeg Stderr: ' + stdErrline log: '[DownloadWorker] Ffmpeg Stderr: ' + stdErrline
}) })
}) })
+25 -2
View File
@@ -48,10 +48,33 @@ async function writeMetadataFile(libraryItem, outputPath) {
`artist=${libraryItem.media.metadata.authorName}`, `artist=${libraryItem.media.metadata.authorName}`,
`album_artist=${libraryItem.media.metadata.authorName}`, `album_artist=${libraryItem.media.metadata.authorName}`,
`date=${libraryItem.media.metadata.publishedYear || ''}`, `date=${libraryItem.media.metadata.publishedYear || ''}`,
`description=${libraryItem.media.metadata.description}`, `description=${libraryItem.media.metadata.description || ''}`,
`genre=${libraryItem.media.metadata.genres.join(';')}` `genre=${libraryItem.media.metadata.genres.join(';')}`,
`performer=${libraryItem.media.metadata.narratorName || ''}`,
`encoded_by=audiobookshelf:${package.version}`
] ]
if (libraryItem.media.metadata.asin) {
inputstrs.push(`ASIN=${libraryItem.media.metadata.asin}`)
}
if (libraryItem.media.metadata.isbn) {
inputstrs.push(`ISBN=${libraryItem.media.metadata.isbn}`)
}
if (libraryItem.media.metadata.language) {
inputstrs.push(`language=${libraryItem.media.metadata.language}`)
}
if (libraryItem.media.metadata.series.length) {
// Only uses first series
var firstSeries = libraryItem.media.metadata.series[0]
inputstrs.push(`series=${firstSeries.name}`)
if (firstSeries.sequence) {
inputstrs.push(`series-part=${firstSeries.sequence}`)
}
}
if (libraryItem.media.metadata.subtitle) {
inputstrs.push(`subtitle=${libraryItem.media.metadata.subtitle}`)
}
if (libraryItem.media.chapters) { if (libraryItem.media.chapters) {
libraryItem.media.chapters.forEach((chap) => { libraryItem.media.chapters.forEach((chap) => {
const chapterstrs = [ const chapterstrs = [
+35 -11
View File
@@ -1,6 +1,7 @@
const fs = require('fs-extra') const fs = require('fs-extra')
const rra = require('recursive-readdir-async') const rra = require('recursive-readdir-async')
const axios = require('axios') const axios = require('axios')
const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
async function getFileStat(path) { async function getFileStat(path) {
@@ -104,14 +105,27 @@ async function recurseFiles(path, relPathToReplace = null) {
return [] return []
} }
const directoriesToIgnore = []
list = list.filter((item) => { list = list.filter((item) => {
if (item.error) { 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 return false
} }
// Ignore any file if a directory or the filename starts with "." // 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('.')) var pathStartsWithPeriod = relpath.split('/').find(p => p.startsWith('.'))
if (pathStartsWithPeriod) { if (pathStartsWithPeriod) {
Logger.debug(`[fileUtils] Ignoring path has . "${relpath}"`) Logger.debug(`[fileUtils] Ignoring path has . "${relpath}"`)
@@ -119,15 +133,25 @@ async function recurseFiles(path, relPathToReplace = null) {
} }
return true return true
}).map((item) => ({ }).filter(item => {
name: item.name, // Filter out items in ignore directories
path: item.fullname.replace(relPathToReplace, ''), if (directoriesToIgnore.includes(Path.dirname(item.fullname))) {
dirpath: item.path, Logger.debug(`[fileUtils] Ignoring path in dir with .ignore "${item.fullname}"`)
reldirpath: item.path.replace(relPathToReplace, ''), return false
fullpath: item.fullname, }
extension: item.extension, return true
deep: item.deep }).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 // Sort from least deep to most
list.sort((a, b) => a.deep - b.deep) list.sort((a, b) => a.deep - b.deep)
+77 -144
View File
@@ -20,13 +20,13 @@ module.exports = {
if (group === 'genres') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.genres.includes(filter)) 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 === 'tags') filtered = filtered.filter(li => li.media.tags.includes(filter))
else if (group === 'series') { 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 { 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 === 'authors') filtered = filtered.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(filter))
else if (group === 'narrators') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.hasNarrator(filter)) else if (group === 'narrators') filtered = filtered.filter(li => li.mediaType === 'book' && li.media.metadata.hasNarrator(filter))
else if (group === 'progress') { else if (group === 'progress') {
filtered = filtered.filter(li => { filtered = filtered.filter(li => {
var itemProgress = user.getMediaProgress(li.id) var itemProgress = user.getMediaProgress(li.id)
@@ -37,18 +37,22 @@ module.exports = {
}) })
} else if (group == 'missing') { } else if (group == 'missing') {
filtered = filtered.filter(li => { filtered = filtered.filter(li => {
if (filter === 'ASIN' && li.media.metadata.asin === null) return true; if (li.mediaType === 'book') {
if (filter === 'ISBN' && li.media.metadata.isbn === null) return true; if (filter === 'ASIN' && li.media.metadata.asin === null) return true;
if (filter === 'Subtitle' && li.media.metadata.subtitle === null) return true; if (filter === 'ISBN' && li.media.metadata.isbn === null) return true;
if (filter === 'Author' && li.media.metadata.authors.length === 0) return true; if (filter === 'Subtitle' && li.media.metadata.subtitle === null) return true;
if (filter === 'Publish Year' && li.media.metadata.publishedYear === null) return true; if (filter === 'Author' && li.media.metadata.authors.length === 0) return true;
if (filter === 'Series' && li.media.metadata.series.length === 0) return true; if (filter === 'Publish Year' && li.media.metadata.publishedYear === null) return true;
if (filter === 'Description' && li.media.metadata.description === null) return true; if (filter === 'Series' && li.media.metadata.series.length === 0) return true;
if (filter === 'Genres' && li.media.metadata.genres.length === 0) return true; if (filter === 'Description' && li.media.metadata.description === null) return true;
if (filter === 'Tags' && li.media.tags.length === 0) return true; if (filter === 'Genres' && li.media.metadata.genres.length === 0) return true;
if (filter === 'Narrator' && li.media.metadata.narrators.length === 0) return true; if (filter === 'Tags' && li.media.tags.length === 0) return true;
if (filter === 'Publisher' && li.media.metadata.publisher === null) return true; if (filter === 'Narrator' && li.media.metadata.narrators.length === 0) return true;
if (filter === 'Language' && li.media.metadata.language === null) 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') { } else if (group === 'languages') {
filtered = filtered.filter(li => li.media.metadata && li.media.metadata.language === filter) 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) { getBooksNextInSeries(seriesWithUserAb, limit, minified = false) {
var incompleteSeires = seriesWithUserAb.filter((series) => series.books.some((book) => !book.userAudiobook || (!book.userAudiobook.isRead && book.userAudiobook.progress == 0))) var incompleteSeires = seriesWithUserAb.filter((series) => series.books.some((book) => !book.userAudiobook || (!book.userAudiobook.isRead && book.userAudiobook.progress == 0)))
var booksNextInSeries = [] 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) 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) { getGenresWithCount(libraryItems) {
var genresMap = {} var genresMap = {}
libraryItems.forEach((li) => { libraryItems.forEach((li) => {
@@ -350,7 +237,6 @@ module.exports = {
buildPersonalizedShelves(user, libraryItems, mediaType, allSeries, allAuthors, maxEntitiesPerShelf = 10) { buildPersonalizedShelves(user, libraryItems, mediaType, allSeries, allAuthors, maxEntitiesPerShelf = 10) {
const isPodcastLibrary = mediaType === 'podcast' const isPodcastLibrary = mediaType === 'podcast'
const shelves = [ const shelves = [
{ {
id: 'continue-listening', id: 'continue-listening',
@@ -359,6 +245,13 @@ module.exports = {
entities: [], entities: [],
category: 'recentlyListened' category: 'recentlyListened'
}, },
{
id: 'continue-series',
label: 'Continue Series',
type: mediaType,
entities: [],
category: 'continueSeries'
},
{ {
id: 'recently-added', id: 'recently-added',
label: '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 = {} const categoryMap = {}
categories.forEach((cat) => { categories.forEach((cat) => {
categoryMap[cat] = { categoryMap[cat] = {
@@ -512,20 +405,24 @@ module.exports = {
// Newest series // Newest series
if (libraryItem.media.metadata.series.length) { if (libraryItem.media.metadata.series.length) {
for (const librarySeries of libraryItem.media.metadata.series) { for (const librarySeries of libraryItem.media.metadata.series) {
const mediaProgress = allItemProgress.length ? allItemProgress[0] : null
const bookInProgress = mediaProgress && (mediaProgress.inProgress || mediaProgress.isFinished)
const libraryItemJson = libraryItem.toJSONMinified()
libraryItemJson.seriesSequence = librarySeries.sequence
if (!seriesMap[librarySeries.id]) { if (!seriesMap[librarySeries.id]) {
const seriesObj = allSeries.find(se => se.id === librarySeries.id) const seriesObj = allSeries.find(se => se.id === librarySeries.id)
if (seriesObj) { if (seriesObj) {
var series = { var series = {
...seriesObj.toJSON(), ...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) { 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) var indexToPut = categoryMap.newestSeries.items.findIndex(i => series.addedAt > i.addedAt)
if (indexToPut >= 0) { if (indexToPut >= 0) {
categoryMap.newestSeries.items.splice(indexToPut, 0, series) categoryMap.newestSeries.items.splice(indexToPut, 0, series)
@@ -540,15 +437,19 @@ module.exports = {
} }
categoryMap.newestSeries.biggest = categoryMap.newestSeries.items[0].addedAt categoryMap.newestSeries.biggest = categoryMap.newestSeries.items[0].addedAt
seriesMap[librarySeries.id] = series
} }
} }
} else { } else {
// series already in map - add book // series already in map - add book
const libraryItemJson = libraryItem.toJSONMinified()
libraryItemJson.seriesSequence = librarySeries.sequence
seriesMap[librarySeries.id].books.push(libraryItemJson) 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 || (librarySeries.sequence && String(librarySeries.sequence).localeCompare(String(seriesMap[librarySeries.id].sequenceInProgress), undefined, { sensitivity: 'base', numeric: true }) > 0)) {
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 // Sort series books by sequence
if (categoryMap.newestSeries.items.length) { if (categoryMap.newestSeries.items.length) {
for (const seriesItem of categoryMap.newestSeries.items) { for (const seriesItem of categoryMap.newestSeries.items) {
+6 -6
View File
@@ -204,12 +204,12 @@ function parseTags(format, verbose) {
} }
} }
var keysToLookOutFor = ['file_tag_genre1', 'file_tag_genre2', 'file_tag_series', 'file_tag_seriespart', 'file_tag_movement', 'file_tag_movementname', 'file_tag_wwwaudiofile', 'file_tag_contentgroup', 'file_tag_releasetime', 'file_tag_isbn'] // var keysToLookOutFor = ['file_tag_genre1', 'file_tag_genre2', 'file_tag_series', 'file_tag_seriespart', 'file_tag_movement', 'file_tag_movementname', 'file_tag_wwwaudiofile', 'file_tag_contentgroup', 'file_tag_releasetime', 'file_tag_isbn']
keysToLookOutFor.forEach((key) => { // keysToLookOutFor.forEach((key) => {
if (tags[key]) { // if (tags[key]) {
Logger.debug(`Notable! ${key} => ${tags[key]}`) // Logger.debug(`Notable! ${key} => ${tags[key]}`)
} // }
}) // })
return tags return tags
} }
+58 -26
View File
@@ -5,9 +5,9 @@ const { recurseFiles, getFileTimestampsWithIno } = require('./fileUtils')
const globals = require('./globals') const globals = require('./globals')
const LibraryFile = require('../objects/files/LibraryFile') const LibraryFile = require('../objects/files/LibraryFile')
function isMediaFile(mediaType, path) { function isMediaFile(mediaType, ext) {
if (!path) return false // if (!path) return false
var ext = Path.extname(path) // var ext = Path.extname(path)
if (!ext) return false if (!ext) return false
var extclean = ext.slice(1).toLowerCase() var extclean = ext.slice(1).toLowerCase()
if (mediaType === 'podcast') return globals.SupportedAudioTypes.includes(extclean) if (mediaType === 'podcast') return globals.SupportedAudioTypes.includes(extclean)
@@ -62,40 +62,47 @@ module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths
// Input: array of relative file items (see recurseFiles) // Input: array of relative file items (see recurseFiles)
// Output: map of files grouped into potential libarary item dirs // Output: map of files grouped into potential libarary item dirs
function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) { function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
// Step 1: Filter out files in root dir (with depth of 0) // Step 1: Filter out non-media files in root dir (with depth of 0)
var itemsFiltered = fileItems.filter(i => i.deep > 0) var itemsFiltered = fileItems.filter(i => {
return i.deep > 0 || isMediaFile(mediaType, i.extension)
})
// Step 2: Seperate media files and other files // Step 2: Seperate media files and other files
// - Directories without a media file will not be included // - Directories without a media file will not be included
var mediaFileItems = [] var mediaFileItems = []
var otherFileItems = [] var otherFileItems = []
itemsFiltered.forEach(item => { itemsFiltered.forEach(item => {
if (isMediaFile(mediaType, item.fullpath)) mediaFileItems.push(item) if (isMediaFile(mediaType, item.extension)) mediaFileItems.push(item)
else otherFileItems.push(item) else otherFileItems.push(item)
}) })
// Step 3: Group audio files in library items // Step 3: Group audio files in library items
var libraryItemGroup = {} var libraryItemGroup = {}
mediaFileItems.forEach((item) => { mediaFileItems.forEach((item) => {
var dirparts = item.reldirpath.split('/') var dirparts = item.reldirpath.split('/').filter(p => !!p)
var numparts = dirparts.length var numparts = dirparts.length
var _path = '' var _path = ''
// Iterate over directories in path if (!dirparts.length) {
for (let i = 0; i < numparts; i++) { // Media file in root
var dirpart = dirparts.shift() libraryItemGroup[item.name] = item.name
_path = Path.posix.join(_path, dirpart) } 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 if (libraryItemGroup[_path]) { // Directory already has files, add file
var relpath = Path.posix.join(dirparts.join('/'), item.name) var relpath = Path.posix.join(dirparts.join('/'), item.name)
libraryItemGroup[_path].push(relpath) libraryItemGroup[_path].push(relpath)
return return
} else if (!dirparts.length) { // This is the last directory, create group } else if (!dirparts.length) { // This is the last directory, create group
libraryItemGroup[_path] = [item.name] libraryItemGroup[_path] = [item.name]
return 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 } 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)] libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)]
return return
}
} }
} }
}) })
@@ -140,19 +147,44 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) {
} }
var fileItems = await recurseFiles(folderPath) 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) var libraryItemGrouping = groupFileItemsIntoLibraryItemDirs(libraryMediaType, fileItems)
if (!Object.keys(libraryItemGrouping).length) { 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 [] return []
} }
var isFile = false // item is not in a folder
var items = [] var items = []
for (const libraryItemPath in libraryItemGrouping) { 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) var libraryItemFolderStats = await getFileTimestampsWithIno(libraryItemData.path)
items.push({ items.push({
folderId: folder.id, folderId: folder.id,
@@ -163,6 +195,7 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) {
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0, birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
path: libraryItemData.path, path: libraryItemData.path,
relPath: libraryItemData.relPath, relPath: libraryItemData.relPath,
isFile,
media: { media: {
metadata: libraryItemData.mediaMetadata || null 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 can be parsed from the title if user enabled
// Subtitle is everything after " - " // Subtitle is everything after " - "
var subtitle = null var subtitle = null
@@ -290,7 +322,7 @@ function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettin
} }
} }
// Called from Scanner.js
async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, serverSettings = {}) { async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, serverSettings = {}) {
var fileItems = await recurseFiles(libraryItemPath) var fileItems = await recurseFiles(libraryItemPath)