mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-05 22:19:53 -05:00
Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
944a5b3e92 | ||
|
|
9b9de84740 | ||
|
|
2746e61cb3 | ||
|
|
7f1d797fb2 | ||
|
|
2059c9f14a | ||
|
|
0e16a9c8de | ||
|
|
b6a33bf7bb | ||
|
|
ce88ac9f33 | ||
|
|
678dceefed | ||
|
|
8b38dda229 | ||
|
|
7373c7159b | ||
|
|
e34a39dde4 | ||
|
|
d4cd8c6db9 | ||
|
|
9e93a3c7e6 | ||
|
|
4a8bcc90ea | ||
|
|
84c12a6e7e | ||
|
|
2a513ac8b8 | ||
|
|
97687c96cd | ||
|
|
a42c13aec2 | ||
|
|
5f0f8b92d1 | ||
|
|
78ca6aa679 | ||
|
|
22e3d4a150 | ||
|
|
e3fba1fb2b | ||
|
|
4d95250990 | ||
|
|
4776368501 | ||
|
|
8b0ed2bf29 | ||
|
|
54389e3c25 | ||
|
|
bf0da1c6ec | ||
|
|
591a866f8c | ||
|
|
fc8473ed84 | ||
|
|
b19442e440 | ||
|
|
7a51e0693d | ||
|
|
21785c8e72 | ||
|
|
bdf6ccbd2d | ||
|
|
ceb163570f | ||
|
|
049ae73d74 | ||
|
|
729fdd5c9f | ||
|
|
4dac8ac16c | ||
|
|
220bbc3d2d | ||
|
|
c2a4b32192 | ||
|
|
09d0d47549 | ||
|
|
4185807da4 | ||
|
|
8abda14e0f | ||
|
|
619e5c0895 | ||
|
|
3a2594cde9 | ||
|
|
5cca2d0155 | ||
|
|
a467637cb5 | ||
|
|
1a23001955 | ||
|
|
8942dca31d | ||
|
|
2a919012b6 | ||
|
|
40b342498f | ||
|
|
e220b2818a | ||
|
|
620bf7990f | ||
|
|
0df36d2609 | ||
|
|
adfe50a841 | ||
|
|
35925ddc1b | ||
|
|
33dfb764fa | ||
|
|
49bef2c641 | ||
|
|
ac58536501 | ||
|
|
c344555be3 | ||
|
|
645bcc53c6 | ||
|
|
84dd06dfc4 | ||
|
|
0a73dd6437 | ||
|
|
2cc055a1ad | ||
|
|
d8ec3bd218 | ||
|
|
d189ec74c9 | ||
|
|
4291769b93 | ||
|
|
22900a3f67 | ||
|
|
7fa08449de | ||
|
|
4f7203fccb | ||
|
|
0eea766931 | ||
|
|
5c054aef90 | ||
|
|
a1674d5da1 | ||
|
|
91597a5454 | ||
|
|
11354a3e3f | ||
|
|
dcd4f69383 | ||
|
|
e253939c1e | ||
|
|
f25ce1c0e7 | ||
|
|
7717e57c16 | ||
|
|
2e28c9b06d | ||
|
|
4bc7cd2045 | ||
|
|
5389115120 | ||
|
|
6e99cf6570 | ||
|
|
21bdd9f9ec | ||
|
|
e3ae3f7e6a | ||
|
|
74bf917150 | ||
|
|
5666b263f5 | ||
|
|
fc8fec62a0 | ||
|
|
034d858f18 | ||
|
|
ebc9e1a888 | ||
|
|
c5a9c2bf5a | ||
|
|
3dbce8fd71 | ||
|
|
b2d299dba6 | ||
|
|
cb5d9a8287 | ||
|
|
f9530897c0 | ||
|
|
7c7e8285a4 | ||
|
|
7b3f9a1e0c | ||
|
|
399e0ea0bc | ||
|
|
a47b0bce57 | ||
|
|
4b60b4f73e | ||
|
|
d88b20addd | ||
|
|
5d12cc3f23 | ||
|
|
84fb7ce8b3 | ||
|
|
243cc672f7 | ||
|
|
663546dd77 | ||
|
|
1b79b3f42d |
76
.github/workflows/docker-build.yml
vendored
Normal file
76
.github/workflows/docker-build.yml
vendored
Normal 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
|
||||
@@ -54,10 +54,16 @@
|
||||
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
|
||||
</ui-tooltip>
|
||||
<template v-if="userCanUpdate && numLibraryItemsSelected < 50">
|
||||
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
||||
<ui-tooltip text="Edit" direction="bottom">
|
||||
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
||||
</ui-tooltip>
|
||||
</template>
|
||||
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
||||
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||
<ui-tooltip v-if="userCanDelete" text="Delete" direction="bottom">
|
||||
<ui-icon-btn :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
||||
</ui-tooltip>
|
||||
<ui-tooltip text="Deselect All" direction="bottom">
|
||||
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,6 +172,7 @@ export default {
|
||||
isFinished: newIsFinished
|
||||
}
|
||||
})
|
||||
console.log('Progress payloads', updateProgressPayloads)
|
||||
this.$axios
|
||||
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||
.then(() => {
|
||||
@@ -228,4 +235,4 @@ export default {
|
||||
#appbar {
|
||||
box-shadow: 0px 5px 5px #11111155;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -91,7 +91,7 @@ export default {
|
||||
},
|
||||
async fetchCategories() {
|
||||
var categories = await this.$axios
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`)
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/personalized`)
|
||||
.then((data) => {
|
||||
return data
|
||||
})
|
||||
@@ -128,8 +128,7 @@ export default {
|
||||
type: 'series',
|
||||
entities: this.results.series.map((seriesObj) => {
|
||||
return {
|
||||
name: seriesObj.series.name,
|
||||
series: seriesObj.series,
|
||||
...seriesObj.series,
|
||||
books: seriesObj.books,
|
||||
type: 'series'
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editBook" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
||||
<template v-for="(entity, index) in shelf.entities">
|
||||
<cards-lazy-book-card :key="entity.recentEpisode.id" :ref="`shelf-episode-${entity.recentEpisode.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editEpisode" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'series'" class="flex items-center">
|
||||
<template v-for="entity in shelf.entities">
|
||||
<cards-lazy-series-card :key="entity.name" :series-mount="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
|
||||
@@ -70,11 +75,6 @@ export default {
|
||||
selectedAuthor: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
isSelectionMode(newVal) {
|
||||
this.updateSelectionMode(newVal)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverHeight() {
|
||||
return this.bookCoverWidth * this.bookCoverAspectRatio
|
||||
@@ -94,6 +94,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clearSelectedEntities() {
|
||||
this.updateSelectionMode(false)
|
||||
},
|
||||
editAuthor(author) {
|
||||
this.selectedAuthor = author
|
||||
this.showAuthorModal = true
|
||||
@@ -103,9 +106,14 @@ export default {
|
||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||
this.$store.commit('showEditModal', audiobook)
|
||||
},
|
||||
editEpisode({ libraryItem, episode }) {
|
||||
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||
},
|
||||
updateSelectionMode(val) {
|
||||
var selectedLibraryItems = this.$store.state.selectedLibraryItems
|
||||
if (this.shelf.type === 'book') {
|
||||
if (this.shelf.type === 'book' || this.shelf.type === 'podcast') {
|
||||
this.shelf.entities.forEach((ent) => {
|
||||
var component = this.$refs[`shelf-book-${ent.id}`]
|
||||
if (!component || !component.length) return
|
||||
@@ -113,10 +121,24 @@ export default {
|
||||
component.setSelectionMode(val)
|
||||
component.selected = selectedLibraryItems.includes(ent.id)
|
||||
})
|
||||
} else if (this.shelf.type === 'episode') {
|
||||
this.shelf.entities.forEach((ent) => {
|
||||
var component = this.$refs[`shelf-episode-${ent.recentEpisode.id}`]
|
||||
if (!component || !component.length) return
|
||||
component = component[0]
|
||||
component.setSelectionMode(val)
|
||||
component.selected = selectedLibraryItems.includes(ent.id)
|
||||
})
|
||||
}
|
||||
},
|
||||
selectItem(libraryItem) {
|
||||
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
|
||||
this.$nextTick(() => {
|
||||
this.$eventBus.$emit('item-selected', libraryItem)
|
||||
})
|
||||
},
|
||||
itemSelectedEvt() {
|
||||
this.updateSelectionMode(this.isSelectionMode)
|
||||
},
|
||||
scrolled() {
|
||||
clearTimeout(this.scrollTimer)
|
||||
@@ -160,6 +182,14 @@ export default {
|
||||
this.canScrollLeft = false
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -14,16 +14,28 @@
|
||||
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||
<template v-if="page !== 'search' && page !== 'podcast-search' && !isHome">
|
||||
<p v-if="!selectedSeries" class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p>
|
||||
<div v-else class="items-center hidden md:flex">
|
||||
<div v-else class="items-center hidden md:flex w-full">
|
||||
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
<span class="material-icons text-2xl text-white">west</span>
|
||||
</div>
|
||||
<p class="pl-4 font-book text-lg">
|
||||
{{ selectedSeries }}
|
||||
{{ seriesName }}
|
||||
</p>
|
||||
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
|
||||
<span class="font-mono">{{ numShowing }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="primary" small :loading="processingSeries" class="flex items-center" @click="markSeriesFinished">
|
||||
<div class="h-5 w-5">
|
||||
<svg v-if="isSeriesFinished" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
|
||||
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="pl-2"> Mark Series {{ isSeriesFinished ? 'Not Finished' : 'Finished' }}</span></ui-btn
|
||||
>
|
||||
</div>
|
||||
<div class="flex-grow hidden sm:inline-block" />
|
||||
|
||||
@@ -38,6 +50,8 @@
|
||||
<span class="material-icons" style="font-size: 1.4rem">view_list</span>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<ui-btn v-if="isIssuesFilter && userCanDelete" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">Remove All {{ numShowing }} {{ entityName }}</ui-btn>
|
||||
</template>
|
||||
<template v-else-if="page === 'search'">
|
||||
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
@@ -56,7 +70,10 @@ export default {
|
||||
props: {
|
||||
page: String,
|
||||
isHome: Boolean,
|
||||
selectedSeries: String,
|
||||
selectedSeries: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
searchQuery: String,
|
||||
viewMode: String
|
||||
},
|
||||
@@ -66,10 +83,15 @@ export default {
|
||||
hasInit: false,
|
||||
totalEntities: 0,
|
||||
keywordFilter: null,
|
||||
keywordTimeout: null
|
||||
keywordTimeout: null,
|
||||
processingSeries: false,
|
||||
processingIssues: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
isPodcast() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||
},
|
||||
@@ -103,9 +125,68 @@ export default {
|
||||
},
|
||||
showLibrary() {
|
||||
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
||||
},
|
||||
seriesName() {
|
||||
return this.selectedSeries ? this.selectedSeries.name : null
|
||||
},
|
||||
seriesProgress() {
|
||||
return this.selectedSeries ? this.selectedSeries.progress : null
|
||||
},
|
||||
seriesLibraryItemIds() {
|
||||
if (!this.seriesProgress) return []
|
||||
return this.seriesProgress.libraryItemIds || []
|
||||
},
|
||||
isSeriesFinished() {
|
||||
return this.seriesProgress && !!this.seriesProgress.isFinished
|
||||
},
|
||||
filterBy() {
|
||||
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||
},
|
||||
isIssuesFilter() {
|
||||
return this.filterBy === 'issues'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
removeAllIssues() {
|
||||
if (confirm(`Are you sure you want to remove all library items with issues?\n\nNote: This will not delete any files`)) {
|
||||
this.processingIssues = true
|
||||
this.$axios
|
||||
.$delete(`/api/libraries/${this.currentLibraryId}/issues`)
|
||||
.then(() => {
|
||||
this.$toast.success('Removed library items with issues')
|
||||
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
|
||||
this.processingIssues = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove library items with issues', error)
|
||||
this.$toast.error('Failed to remove library items with issues')
|
||||
this.processingIssues = false
|
||||
})
|
||||
}
|
||||
},
|
||||
markSeriesFinished() {
|
||||
var newIsFinished = !this.isSeriesFinished
|
||||
this.processingSeries = true
|
||||
var updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
|
||||
return {
|
||||
id: lid,
|
||||
isFinished: newIsFinished
|
||||
}
|
||||
})
|
||||
console.log('Progress payloads', updateProgressPayloads)
|
||||
this.$axios
|
||||
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||
.then(() => {
|
||||
this.$toast.success('Series update success')
|
||||
this.selectedSeries.progress.isFinished = newIsFinished
|
||||
this.processingSeries = false
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error('Series update failed')
|
||||
console.error('Failed to batch update read/not read', error)
|
||||
this.processingSeries = false
|
||||
})
|
||||
},
|
||||
searchBackArrow() {
|
||||
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
|
||||
},
|
||||
|
||||
@@ -61,7 +61,6 @@ export default {
|
||||
totalShelves: 0,
|
||||
bookshelfMarginLeft: 0,
|
||||
isSelectionMode: false,
|
||||
isSelectAll: false,
|
||||
currentSFQueryString: null,
|
||||
pendingReset: false,
|
||||
keywordFilter: null,
|
||||
@@ -90,9 +89,12 @@ export default {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||
},
|
||||
emptyMessage() {
|
||||
if (this.page === 'series') return `You have no series`
|
||||
if (this.page === 'series') return 'You have no series'
|
||||
if (this.page === 'collections') return "You haven't made any collections yet"
|
||||
if (this.hasFilter) return `No Results for filter "${this.filterName}: ${this.filterValue}"`
|
||||
if (this.hasFilter) {
|
||||
if (this.filterName === 'Issues') return 'No Issues'
|
||||
return `No Results for filter "${this.filterName}: ${this.filterValue}"`
|
||||
}
|
||||
return 'No results'
|
||||
},
|
||||
entityName() {
|
||||
@@ -217,7 +219,6 @@ export default {
|
||||
clearSelectedEntities() {
|
||||
this.updateBookSelectionMode(false)
|
||||
this.isSelectionMode = false
|
||||
this.isSelectAll = false
|
||||
},
|
||||
selectEntity(entity) {
|
||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||
@@ -339,7 +340,6 @@ export default {
|
||||
this.totalEntities = 0
|
||||
this.currentPage = 0
|
||||
this.isSelectionMode = false
|
||||
this.isSelectAll = false
|
||||
this.initialized = false
|
||||
|
||||
this.initSizeData()
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<icons-podcast-svg class="w-6 h-6" />
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 0.9rem">Search</p>
|
||||
@@ -82,6 +82,9 @@ export default {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
paramId() {
|
||||
return this.$route.params ? this.$route.params.id || '' : ''
|
||||
},
|
||||
@@ -112,6 +115,9 @@ export default {
|
||||
showLibrary() {
|
||||
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
||||
},
|
||||
filterBy() {
|
||||
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||
},
|
||||
showingIssues() {
|
||||
if (!this.$route.query) return false
|
||||
return this.libraryBookshelfPage && this.$route.query.filter === 'issues'
|
||||
|
||||
@@ -69,7 +69,8 @@ export default {
|
||||
sleepTimerTime: 0,
|
||||
sleepTimerRemaining: 0,
|
||||
sleepTimer: null,
|
||||
displayTitle: null
|
||||
displayTitle: null,
|
||||
initialPlaybackRate: 1
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -204,6 +205,7 @@ export default {
|
||||
this.playerHandler.setVolume(volume)
|
||||
},
|
||||
setPlaybackRate(playbackRate) {
|
||||
this.initialPlaybackRate = playbackRate
|
||||
this.playerHandler.setPlaybackRate(playbackRate)
|
||||
},
|
||||
seek(time) {
|
||||
@@ -253,7 +255,7 @@ export default {
|
||||
libraryItem: session.libraryItem,
|
||||
episodeId: session.episodeId
|
||||
})
|
||||
this.playerHandler.prepareOpenSession(session)
|
||||
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate)
|
||||
},
|
||||
streamOpen(session) {
|
||||
console.log(`[StreamContainer] Stream session open`, session)
|
||||
@@ -311,7 +313,7 @@ export default {
|
||||
episodeId
|
||||
})
|
||||
|
||||
this.playerHandler.load(libraryItem, episodeId, true)
|
||||
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
|
||||
},
|
||||
pauseItem() {
|
||||
this.playerHandler.pause()
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -65,6 +65,9 @@ export default {
|
||||
},
|
||||
numBooks() {
|
||||
return this._author.numBooks || 0
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No progress shown for collapsed series in library and podcasts -->
|
||||
<div v-if="!booksInSeries && !isPodcast" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
<!-- No progress shown for collapsed series in library and podcasts (unless showing podcast episode) -->
|
||||
<div v-if="!booksInSeries && (!isPodcast || episodeProgress)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<!-- Overlay is not shown if collapsing series in library -->
|
||||
<div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
|
||||
@@ -60,7 +60,7 @@
|
||||
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
||||
</div>
|
||||
|
||||
<div ref="moreIcon" v-show="!isSelectionMode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
||||
<div ref="moreIcon" v-show="!isSelectionMode && !recentEpisode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
||||
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,13 +77,18 @@
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
|
||||
<!-- Volume number -->
|
||||
<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` }">
|
||||
<!-- Series sequence -->
|
||||
<div v-if="seriesSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Episode # -->
|
||||
<div v-if="recentEpisodeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
<div v-if="numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<div v-else-if="numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,7 +110,6 @@ export default {
|
||||
default: 192
|
||||
},
|
||||
bookCoverAspectRatio: Number,
|
||||
showSequence: Boolean,
|
||||
bookshelfView: Number,
|
||||
bookMount: {
|
||||
// Book can be passed as prop or set with setEntity()
|
||||
@@ -145,6 +149,10 @@ export default {
|
||||
_libraryItem() {
|
||||
return this.libraryItem || {}
|
||||
},
|
||||
isFile() {
|
||||
// Library item is not in a folder
|
||||
return this._libraryItem.isFile
|
||||
},
|
||||
media() {
|
||||
return this._libraryItem.media || {}
|
||||
},
|
||||
@@ -167,7 +175,7 @@ export default {
|
||||
return this._libraryItem.id
|
||||
},
|
||||
series() {
|
||||
// Only included when filtering by series or collapse series
|
||||
// Only included when filtering by series or collapse series or Continue Series shelf on home page
|
||||
return this.mediaMetadata.series
|
||||
},
|
||||
seriesSequence() {
|
||||
@@ -190,6 +198,17 @@ export default {
|
||||
processingBatch() {
|
||||
return this.store.state.processingBatch
|
||||
},
|
||||
recentEpisode() {
|
||||
// Only added to item when getting currently listening podcasts
|
||||
return this._libraryItem.recentEpisode
|
||||
},
|
||||
recentEpisodeNumber() {
|
||||
if (!this.recentEpisode) return null
|
||||
if (this.recentEpisode.episode) {
|
||||
return this.recentEpisode.episode.replace(/^#/, '')
|
||||
}
|
||||
return this.recentEpisode.index
|
||||
},
|
||||
collapsedSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this._libraryItem.collapsedSeries
|
||||
@@ -240,7 +259,13 @@ export default {
|
||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||
return null
|
||||
},
|
||||
episodeProgress() {
|
||||
// Only used on home page currently listening podcast shelf
|
||||
if (!this.recentEpisode) return null
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
|
||||
},
|
||||
userProgress() {
|
||||
if (this.episodeProgress) return this.episodeProgress
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
userProgressPercent() {
|
||||
@@ -250,7 +275,8 @@ export default {
|
||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||
},
|
||||
showError() {
|
||||
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
|
||||
if (this.recentEpisode) return false // Dont show podcast error on episode card
|
||||
return this.numInvalidAudioFiles || this.numMissingParts || this.isMissing || this.isInvalid
|
||||
},
|
||||
isStreaming() {
|
||||
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
||||
@@ -259,7 +285,7 @@ export default {
|
||||
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
|
||||
},
|
||||
showPlayButton() {
|
||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
|
||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
||||
},
|
||||
showSmallEBookIcon() {
|
||||
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
|
||||
@@ -270,22 +296,27 @@ export default {
|
||||
isInvalid() {
|
||||
return this._libraryItem.isInvalid
|
||||
},
|
||||
hasMissingParts() {
|
||||
return this._libraryItem.hasMissingParts
|
||||
numMissingParts() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numMissingParts
|
||||
},
|
||||
hasInvalidParts() {
|
||||
return this._libraryItem.hasInvalidParts
|
||||
numInvalidAudioFiles() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numInvalidAudioFiles
|
||||
},
|
||||
errorText() {
|
||||
if (this.isMissing) return 'Item directory is missing!'
|
||||
else if (this.isInvalid) return 'Item has no audio tracks & ebook'
|
||||
var txt = ''
|
||||
if (this.hasMissingParts) {
|
||||
txt = `${this.hasMissingParts} missing parts.`
|
||||
else if (this.isInvalid) {
|
||||
if (this.isPodcast) return 'Podcast has no episodes'
|
||||
return 'Item has no audio tracks & ebook'
|
||||
}
|
||||
if (this.hasInvalidParts) {
|
||||
if (this.hasMissingParts) txt += ' '
|
||||
txt += `${this.hasInvalidParts} invalid parts.`
|
||||
var txt = ''
|
||||
if (this.numMissingParts) {
|
||||
txt += `${this.numMissingParts} missing parts.`
|
||||
}
|
||||
if (this.numInvalidAudioFiles) {
|
||||
if (txt) txt += ' '
|
||||
txt += `${this.numInvalidAudioFiles} invalid audio files.`
|
||||
}
|
||||
return txt || 'Unknown Error'
|
||||
},
|
||||
@@ -337,7 +368,7 @@ export default {
|
||||
text: 'Match'
|
||||
})
|
||||
}
|
||||
if (this.userIsRoot) {
|
||||
if (this.userIsRoot && !this.isFile) {
|
||||
items.push({
|
||||
func: 'rescan',
|
||||
text: 'Re-Scan'
|
||||
@@ -406,6 +437,9 @@ export default {
|
||||
}
|
||||
},
|
||||
editClick() {
|
||||
if (this.recentEpisode) {
|
||||
return this.$emit('edit', { libraryItem: this.libraryItem, episode: this.recentEpisode })
|
||||
}
|
||||
this.$emit('edit', this.libraryItem)
|
||||
},
|
||||
toggleFinished() {
|
||||
@@ -529,7 +563,8 @@ export default {
|
||||
play() {
|
||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItemId
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.recentEpisode ? this.recentEpisode.id : null
|
||||
})
|
||||
},
|
||||
mouseover() {
|
||||
|
||||
@@ -61,6 +61,9 @@ export default {
|
||||
books() {
|
||||
return this.series ? this.series.books || [] : []
|
||||
},
|
||||
addedAt() {
|
||||
return this.series ? this.series.addedAt : 0
|
||||
},
|
||||
seriesBookProgress() {
|
||||
return this.books
|
||||
.map((libraryItem) => {
|
||||
|
||||
@@ -132,6 +132,11 @@ export default {
|
||||
text: 'Tag',
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Issues',
|
||||
value: 'issues',
|
||||
sublist: false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -166,26 +171,26 @@ export default {
|
||||
selectedText() {
|
||||
if (!this.selected) return ''
|
||||
var parts = this.selected.split('.')
|
||||
var filterName = this.selectItems.find((i) => i.value === parts[0]);
|
||||
var filterValue = null;
|
||||
var filterName = this.selectItems.find((i) => i.value === parts[0])
|
||||
var filterValue = null
|
||||
if (parts.length > 1) {
|
||||
var decoded = this.$decode(parts[1])
|
||||
if (decoded.startsWith('aut_')) {
|
||||
var author = this.authors.find((au) => au.id == decoded)
|
||||
if (author) filterValue = author.name;
|
||||
if (author) filterValue = author.name
|
||||
} else if (decoded.startsWith('ser_')) {
|
||||
var series = this.series.find((se) => se.id == decoded)
|
||||
if (series) filterValue = series.name
|
||||
} else {
|
||||
filterValue = decoded;
|
||||
filterValue = decoded
|
||||
}
|
||||
}
|
||||
if (filterName && filterValue) {
|
||||
return `${filterName.text}: ${filterValue}`;
|
||||
return `${filterName.text}: ${filterValue}`
|
||||
} else if (filterName) {
|
||||
return filterName.text;
|
||||
return filterName.text
|
||||
} else if (filterValue) {
|
||||
return filterValue;
|
||||
return filterValue
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
@@ -212,7 +217,7 @@ export default {
|
||||
return ['Finished', 'In Progress', 'Not Started']
|
||||
},
|
||||
missing() {
|
||||
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Volume Number', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language', ]
|
||||
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
|
||||
},
|
||||
sublistItems() {
|
||||
return (this[this.sublist] || []).map((item) => {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
|
||||
<template v-for="item in bookResults">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/item/${item.id}`">
|
||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
@@ -31,7 +31,7 @@
|
||||
<p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Podcasts</p>
|
||||
<template v-for="item in podcastResults">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/item/${item.id}`">
|
||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
|
||||
@@ -131,6 +131,9 @@ export default {
|
||||
this.selectedDesc = !this.selectedDesc
|
||||
} else {
|
||||
this.selected = val
|
||||
if (val == 'media.metadata.title' || val == 'media.metadata.author' || val == 'media.metadata.authorName' || val == 'media.metadata.authorNameLF') {
|
||||
this.selectedDesc = false
|
||||
}
|
||||
}
|
||||
this.showMenu = false
|
||||
this.$nextTick(() => this.$emit('change', val))
|
||||
|
||||
@@ -44,6 +44,14 @@ export default {
|
||||
this.$nextTick(this.init)
|
||||
}
|
||||
}
|
||||
},
|
||||
width: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.isInit = false
|
||||
this.$nextTick(this.init)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
|
||||
<modals-modal ref="modal" v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
@@ -8,20 +8,20 @@
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<div class="w-full p-8">
|
||||
<div class="flex py-2 -mx-2">
|
||||
<div class="flex py-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="newUser.username" label="Username" class="mx-2" />
|
||||
<ui-text-input-with-label v-model="newUser.username" label="Username" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? 'Password' : 'Change Password'" type="password" class="mx-2" />
|
||||
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? 'Password' : 'Change Password'" type="password" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-2">
|
||||
<div class="px-2">
|
||||
<ui-input-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :editable="false" :items="accountTypes" @input="userTypeUpdated" />
|
||||
<div v-show="!isEditingRoot" class="flex py-2">
|
||||
<div class="px-2 w-52">
|
||||
<ui-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :items="accountTypes" @input="userTypeUpdated" />
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div v-show="!isEditingRoot" class="flex items-center pt-4 px-2">
|
||||
<div class="flex items-center pt-4 px-2">
|
||||
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">Is Active</p>
|
||||
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
|
||||
</div>
|
||||
@@ -86,13 +86,13 @@
|
||||
<ui-toggle-switch v-model="newUser.permissions.accessAllTags" @input="accessAllTagsToggled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
||||
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" label="Tags Accessible to User" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex pt-4">
|
||||
<div class="flex pt-4 px-2">
|
||||
<ui-btn v-if="isEditingRoot" to="/account">Change Root Password</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">Submit</ui-btn>
|
||||
</div>
|
||||
@@ -116,7 +116,20 @@ export default {
|
||||
processing: false,
|
||||
newUser: {},
|
||||
isNew: true,
|
||||
accountTypes: ['guest', 'user', 'admin'],
|
||||
accountTypes: [
|
||||
{
|
||||
text: 'Guest',
|
||||
value: 'guest'
|
||||
},
|
||||
{
|
||||
text: 'User',
|
||||
value: 'user'
|
||||
},
|
||||
{
|
||||
text: 'Admin',
|
||||
value: 'admin'
|
||||
}
|
||||
],
|
||||
tags: [],
|
||||
loadingTags: false
|
||||
}
|
||||
@@ -124,6 +137,7 @@ export default {
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
console.log('accoutn modal show change', newVal)
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
@@ -140,7 +154,7 @@ export default {
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.isNew ? 'Add New Account' : `Update Account: ${(this.account || {}).username}`
|
||||
return this.isNew ? 'Add New Account' : `Update ${(this.account || {}).username}`
|
||||
},
|
||||
isEditingRoot() {
|
||||
return this.account && this.account.type === 'root'
|
||||
@@ -161,10 +175,12 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
close() {
|
||||
// Force close when navigating - used in UsersTable
|
||||
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||
},
|
||||
accessAllTagsToggled(val) {
|
||||
if (!val && !this.newUser.itemTagsAccessible.length) {
|
||||
this.newUser.itemTagsAccessible = this.libraries.map((l) => l.id)
|
||||
} else if (val && this.newUser.itemTagsAccessible.length) {
|
||||
if (val && this.newUser.itemTagsAccessible.length) {
|
||||
this.newUser.itemTagsAccessible = []
|
||||
}
|
||||
},
|
||||
@@ -197,6 +213,10 @@ export default {
|
||||
this.$toast.error('Must select at least one library')
|
||||
return
|
||||
}
|
||||
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsAccessible.length) {
|
||||
this.$toast.error('Must select at least one tag')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isNew) {
|
||||
this.submitCreateAccount()
|
||||
|
||||
@@ -62,9 +62,9 @@ export default {
|
||||
component: 'modals-item-tabs-match'
|
||||
},
|
||||
{
|
||||
id: 'merge',
|
||||
title: 'Merge',
|
||||
component: 'modals-item-tabs-merge',
|
||||
id: 'manage',
|
||||
title: 'Manage',
|
||||
component: 'modals-item-tabs-manage',
|
||||
experimental: true
|
||||
}
|
||||
]
|
||||
@@ -123,12 +123,12 @@ export default {
|
||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||
return this.tabs.filter((tab) => {
|
||||
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 == 'book' && tab.id == 'episodes') return false
|
||||
|
||||
if ((tab.id === 'merge' || 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.userCanDownload) return true
|
||||
if (tab.id !== 'manage' && tab.id !== 'files' && this.userCanUpdate) return true
|
||||
if (tab.id === 'match' && this.userCanUpdate) return true
|
||||
return false
|
||||
})
|
||||
@@ -181,15 +181,18 @@ export default {
|
||||
if (this.currentBookshelfIndex - 1 < 0) return
|
||||
var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]
|
||||
this.processing = true
|
||||
var prevBook = await this.$axios.$get(`/api/items/${prevBookId}`).catch((error) => {
|
||||
var prevBook = await this.$axios.$get(`/api/items/${prevBookId}?expanded=1`).catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
|
||||
this.$toast.error(errorMsg)
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
if (prevBook) {
|
||||
this.$store.commit('showEditModalOnTab', { libraryItem: prevBook, tab: this.selectedTab })
|
||||
this.$nextTick(this.init)
|
||||
this.unregisterListeners()
|
||||
this.libraryItem = prevBook
|
||||
this.selectedTab = 'details'
|
||||
this.$store.commit('setSelectedLibraryItem', prevBook)
|
||||
this.$nextTick(this.registerListeners)
|
||||
} else {
|
||||
console.error('Book not found', prevBookId)
|
||||
}
|
||||
@@ -198,15 +201,18 @@ export default {
|
||||
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
|
||||
this.processing = true
|
||||
var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1]
|
||||
var nextBook = await this.$axios.$get(`/api/items/${nextBookId}`).catch((error) => {
|
||||
var nextBook = await this.$axios.$get(`/api/items/${nextBookId}?expanded=1`).catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
|
||||
this.$toast.error(errorMsg)
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
if (nextBook) {
|
||||
this.$store.commit('showEditModalOnTab', { libraryItem: nextBook, tab: this.selectedTab })
|
||||
this.$nextTick(this.init)
|
||||
this.unregisterListeners()
|
||||
this.libraryItem = nextBook
|
||||
this.selectedTab = 'details'
|
||||
this.$store.commit('setSelectedLibraryItem', nextBook)
|
||||
this.$nextTick(this.registerListeners)
|
||||
} else {
|
||||
console.error('Book not found', nextBookId)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
|
||||
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||
<ui-btn v-if="isRootUser && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-btn @click="submitForm">Submit</ui-btn>
|
||||
@@ -49,6 +49,9 @@ export default {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
isFile() {
|
||||
return !!this.libraryItem && this.libraryItem.isFile
|
||||
},
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
@@ -163,7 +166,7 @@ export default {
|
||||
if (updateResult) {
|
||||
if (updateResult.updated) {
|
||||
this.$toast.success('Item details updated')
|
||||
// this.$emit('close')
|
||||
this.$emit('close')
|
||||
} else {
|
||||
this.$toast.info('No updates were necessary')
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div class="w-full mb-4">
|
||||
<!-- <div class="flex items-center mb-4">
|
||||
<p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check for new episodes</ui-btn>
|
||||
</div> -->
|
||||
<div v-if="userIsAdminOrUp" class="flex items-end justify-end mb-4">
|
||||
<!-- <p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p> -->
|
||||
<ui-text-input-with-label ref="lastCheckInput" v-model="lastEpisodeCheckInput" :disabled="checkingNewEpisodes" type="datetime-local" label="Look for new episodes after this date" class="max-w-xs mr-2" />
|
||||
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check & Download New Episodes</ui-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="episodes.length" class="w-full p-4 bg-primary">
|
||||
<p>Podcast Episodes</p>
|
||||
@@ -51,10 +51,23 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
checkingNewEpisodes: false
|
||||
checkingNewEpisodes: false,
|
||||
lastEpisodeCheckInput: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
lastEpisodeCheck: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.setLastEpisodeCheckInput()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
autoDownloadEpisodes() {
|
||||
return !!this.media.autoDownloadEpisodes
|
||||
},
|
||||
@@ -72,8 +85,22 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkForNewEpisodes() {
|
||||
async checkForNewEpisodes() {
|
||||
if (this.$refs.lastCheckInput) {
|
||||
this.$refs.lastCheckInput.blur()
|
||||
}
|
||||
this.checkingNewEpisodes = true
|
||||
const lastEpisodeCheck = new Date(this.lastEpisodeCheckInput).valueOf()
|
||||
|
||||
// If last episode check changed then update it first
|
||||
if (lastEpisodeCheck && lastEpisodeCheck !== this.lastEpisodeCheck) {
|
||||
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, { lastEpisodeCheck }).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
console.log('updateResult', updateResult)
|
||||
}
|
||||
|
||||
this.$axios
|
||||
.$get(`/api/podcasts/${this.libraryItemId}/checknew`)
|
||||
.then((response) => {
|
||||
@@ -91,7 +118,13 @@ export default {
|
||||
this.$toast.error(errorMsg)
|
||||
this.checkingNewEpisodes = false
|
||||
})
|
||||
},
|
||||
setLastEpisodeCheckInput() {
|
||||
this.lastEpisodeCheckInput = this.lastEpisodeCheck ? this.$formatDate(this.lastEpisodeCheck, "yyyy-MM-dd'T'HH:mm") : null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setLastEpisodeCheckInput()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,9 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<template v-for="audiobook in audiobooks">
|
||||
<tables-tracks-table :key="audiobook.id" :title="`Audiobook Tracks (${audiobook.name})`" :audiobook-id="audiobook.id" :tracks="audiobook.tracks" class="mb-4" />
|
||||
</template>
|
||||
|
||||
<tables-library-files-table expanded :files="libraryFiles" :library-item-id="libraryItem.id" :is-missing="isMissing" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -51,12 +47,6 @@ export default {
|
||||
},
|
||||
showDownload() {
|
||||
return this.userCanDownload && !this.isMissing
|
||||
},
|
||||
audiobooks() {
|
||||
return this.media.audiobooks || []
|
||||
},
|
||||
ebooks() {
|
||||
return this.media.ebooks || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<template>
|
||||
<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 class="flex items-center">
|
||||
<div>
|
||||
<p class="text-lg">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="text-lg">Make M4B Audiobook File <span class="text-error">*</span></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 class="flex-grow" />
|
||||
<div>
|
||||
@@ -24,13 +25,55 @@
|
||||
</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
|
||||
> - 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 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="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</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 class="w-80 border border-black-400 bg-bg rounded-xl h-20">
|
||||
@@ -97,9 +140,16 @@ export default {
|
||||
isSingleM4b() {
|
||||
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
showM4bDownload() {
|
||||
if (this.libraryItem.isMissing || !this.mediaTracks.length) return false
|
||||
return !this.isSingleM4b && this.mediaTracks.length > 0
|
||||
if (!this.mediaTracks.length) return false
|
||||
return !this.isSingleM4b
|
||||
},
|
||||
showMp3Split() {
|
||||
if (!this.mediaTracks.length) return false
|
||||
return this.isSingleM4b && this.chapters.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -93,7 +93,9 @@ export default {
|
||||
icon: 'database',
|
||||
mediaType: 'book',
|
||||
settings: {
|
||||
disableWatcher: false
|
||||
disableWatcher: false,
|
||||
skipMatchingMediaWithAsin: false,
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,6 +8,18 @@
|
||||
</div>
|
||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
|
||||
</div>
|
||||
<div class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
|
||||
<p class="pl-4 text-lg">Skip matching books that already have an ASIN</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
|
||||
<p class="pl-4 text-lg">Skip matching books that already have an ISBN</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -23,7 +35,9 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
provider: null,
|
||||
disableWatcher: false
|
||||
disableWatcher: false,
|
||||
skipMatchingMediaWithAsin: false,
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -45,7 +59,9 @@ export default {
|
||||
getLibraryData() {
|
||||
return {
|
||||
settings: {
|
||||
disableWatcher: !!this.disableWatcher
|
||||
disableWatcher: !!this.disableWatcher,
|
||||
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
||||
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -54,6 +70,8 @@ export default {
|
||||
},
|
||||
init() {
|
||||
this.disableWatcher = !!this.librarySettings.disableWatcher
|
||||
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
|
||||
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -35,17 +35,6 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
@@ -72,12 +61,18 @@ export default {
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
return this.$store.state.globals.showEditPodcastEpisode
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', val)
|
||||
}
|
||||
},
|
||||
libraryItem() {
|
||||
return this.$store.state.selectedLibraryItem
|
||||
},
|
||||
episode() {
|
||||
return this.$store.state.globals.selectedEpisode
|
||||
},
|
||||
episodeId() {
|
||||
return this.episode ? this.episode.id : null
|
||||
},
|
||||
|
||||
@@ -124,7 +124,13 @@ export default {
|
||||
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
|
||||
}
|
||||
|
||||
console.log('Podcast payload', episodesToDownload)
|
||||
var payloadSize = JSON.stringify(episodesToDownload).length
|
||||
var sizeInMb = payloadSize / 1024 / 1024
|
||||
var sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
|
||||
console.log('Request size', sizeInMb)
|
||||
if (sizeInMb > 4.99) {
|
||||
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
@@ -144,7 +150,8 @@ export default {
|
||||
init() {
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
var episode = this.episodes[i]
|
||||
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) { // Do not include episodes already downloaded
|
||||
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {
|
||||
// Do not include episodes already downloaded
|
||||
this.$set(this.selectedEpisodes, String(i), false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,42 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="new-podcast-modal" :width="1200" :height="'unset'" :processing="processing">
|
||||
<modals-modal v-model="show" name="new-podcast-modal" :width="1000" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="wrapper" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||
<div class="flex flex-wrap">
|
||||
<div class="w-full md:w-1/2 p-4">
|
||||
<p class="text-lg font-semibold mb-2">Details</p>
|
||||
<div class="flex flex-wrap">
|
||||
<div v-if="podcast.imageUrl" class="p-1 w-full">
|
||||
<img :src="podcast.imageUrl" class="h-16 w-16 object-contain" />
|
||||
</div>
|
||||
<div class="p-1 w-full">
|
||||
<ui-text-input-with-label v-model="podcast.title" label="Title" @input="titleUpdated" />
|
||||
</div>
|
||||
<div class="p-1 w-full">
|
||||
<ui-text-input-with-label v-model="podcast.author" label="Author" />
|
||||
</div>
|
||||
<div class="p-1 w-full">
|
||||
<ui-text-input-with-label v-model="podcast.feedUrl" label="Feed URL" readonly />
|
||||
</div>
|
||||
<div class="p-1 w-full">
|
||||
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" label="Genres" />
|
||||
</div>
|
||||
<div class="p-1 w-full">
|
||||
<ui-textarea-with-label v-model="podcast.description" label="Description" />
|
||||
</div>
|
||||
<div class="p-1 w-full">
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" @input="folderUpdated" />
|
||||
</div>
|
||||
<div class="p-1 w-full">
|
||||
<ui-text-input-with-label v-model="fullPath" label="Podcast Path" readonly />
|
||||
</div>
|
||||
<div class="w-full p-4">
|
||||
<p class="text-lg font-semibold mb-2">Details</p>
|
||||
|
||||
<div v-if="podcast.imageUrl" class="p-1 w-full">
|
||||
<img :src="podcast.imageUrl" class="h-16 w-16 object-contain" />
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-text-input-with-label v-model="podcast.title" label="Title" @input="titleUpdated" />
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-text-input-with-label v-model="podcast.author" label="Author" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 p-4">
|
||||
<p class="text-lg font-semibold mb-2">Episodes</p>
|
||||
<div ref="episodeContainer" id="episodes-scroll" class="w-full overflow-x-hidden overflow-y-auto">
|
||||
<div class="relative">
|
||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||
<ui-checkbox v-model="selectAll" small checkbox-bg="primary" border-color="gray-600" />
|
||||
</div>
|
||||
<div class="px-8 py-2">
|
||||
<p class="font-semibold text-gray-200">Select all episodes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="(episode, index) in episodes" :key="index" class="relative cursor-pointer" :class="selectedEpisodes[String(index)] ? 'bg-success bg-opacity-10' : index % 2 == 0 ? 'bg-primary bg-opacity-25 hover:bg-opacity-40' : 'bg-primary bg-opacity-5 hover:bg-opacity-25'" @click="toggleSelectEpisode(index)">
|
||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||
<ui-checkbox v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
|
||||
</div>
|
||||
<div class="px-8 py-2">
|
||||
<p v-if="episode.episode" class="font-semibold text-gray-200">#{{ episode.episode }}</p>
|
||||
<p class="break-words mb-1">{{ episode.title }}</p>
|
||||
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
|
||||
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-text-input-with-label v-model="podcast.feedUrl" label="Feed URL" readonly />
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" label="Genres" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 w-full">
|
||||
<ui-textarea-with-label v-model="podcast.description" label="Description" :rows="3" />
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" @input="folderUpdated" />
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-text-input-with-label v-model="fullPath" label="Podcast Path" readonly />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,7 +45,7 @@
|
||||
<div class="px-4">
|
||||
<ui-checkbox v-model="podcast.autoDownloadEpisodes" label="Auto Download Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
<ui-btn color="success" :disabled="disableSubmit" @click="submit">{{ buttonText }}</ui-btn>
|
||||
<ui-btn color="success" @click="submit">Add Podcast</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
@@ -104,8 +83,7 @@ export default {
|
||||
itunesId: '',
|
||||
itunesArtistId: '',
|
||||
autoDownloadEpisodes: false
|
||||
},
|
||||
selectedEpisodes: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -127,16 +105,6 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
selectAll: {
|
||||
get() {
|
||||
return this.episodesSelected.length == this.episodes.length
|
||||
},
|
||||
set(val) {
|
||||
for (const key in this.selectedEpisodes) {
|
||||
this.selectedEpisodes[key] = val
|
||||
}
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this._podcastData.title
|
||||
},
|
||||
@@ -166,17 +134,6 @@ export default {
|
||||
if (!this.podcastFeedData) return []
|
||||
return this.podcastFeedData.episodes || []
|
||||
},
|
||||
episodesSelected() {
|
||||
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
||||
},
|
||||
disableSubmit() {
|
||||
return !this.episodesSelected.length && !this.podcast.autoDownloadEpisodes
|
||||
},
|
||||
buttonText() {
|
||||
if (!this.episodesSelected.length) return 'Add Podcast'
|
||||
if (this.episodesSelected.length == 1) return 'Add Podcast & Download 1 Episode'
|
||||
return `Add Podcast & Download ${this.episodesSelected.length} Episodes`
|
||||
},
|
||||
selectedFolder() {
|
||||
return this.folders.find((f) => f.id === this.selectedFolderId)
|
||||
},
|
||||
@@ -196,15 +153,7 @@ export default {
|
||||
}
|
||||
this.fullPath = Path.join(this.selectedFolderPath, this.podcast.title)
|
||||
},
|
||||
toggleSelectEpisode(index) {
|
||||
this.selectedEpisodes[String(index)] = !this.selectedEpisodes[String(index)]
|
||||
},
|
||||
submit() {
|
||||
var episodesToDownload = []
|
||||
if (this.episodesSelected.length) {
|
||||
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
|
||||
}
|
||||
|
||||
const podcastPayload = {
|
||||
path: this.fullPath,
|
||||
folderId: this.selectedFolderId,
|
||||
@@ -224,8 +173,7 @@ export default {
|
||||
language: this.podcast.language
|
||||
},
|
||||
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
|
||||
},
|
||||
episodesToDownload
|
||||
}
|
||||
}
|
||||
console.log('Podcast payload', podcastPayload)
|
||||
|
||||
@@ -260,10 +208,6 @@ export default {
|
||||
this.podcast.language = this._podcastData.language || ''
|
||||
this.podcast.autoDownloadEpisodes = false
|
||||
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
this.$set(this.selectedEpisodes, String(i), false)
|
||||
}
|
||||
|
||||
if (this.folderItems[0]) {
|
||||
this.selectedFolderId = this.folderItems[0].value
|
||||
this.folderUpdated()
|
||||
|
||||
96
client/components/modals/rssfeed/ViewModal.vue
Normal file
96
client/components/modals/rssfeed/ViewModal.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<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 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="feedUrl" 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(feedUrl)">content_copy</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="error" small @click="closeFeed">Close 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
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
title() {
|
||||
return this.mediaMetadata.title
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
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() {}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -8,7 +8,7 @@
|
||||
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
|
||||
<nuxt-link v-if="userCanUpdate && !isFile" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
|
||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
||||
</nuxt-link>
|
||||
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
|
||||
@@ -59,7 +59,8 @@ export default {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
libraryItemId: String
|
||||
libraryItemId: String,
|
||||
isFile: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full my-4" @mousedown.prevent @mouseup.prevent>
|
||||
<div class="w-full my-4">
|
||||
<div class="w-full bg-primary px-6 py-1 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||
<p class="pr-4">{{ title }}</p>
|
||||
<span class="bg-black-400 rounded-xl py-0.5 px-2 text-sm font-mono">{{ files.length }}</span>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
|
||||
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -156,6 +156,10 @@ export default {
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.$refs.accountModal) {
|
||||
this.$refs.accountModal.close()
|
||||
}
|
||||
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.off('user_added', this.newUserAdded)
|
||||
this.$root.socket.off('user_updated', this.userUpdated)
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-80 h-full px-2 flex items-center">
|
||||
<div>
|
||||
<div class="flex-grow max-w-md h-full px-2 flex items-center">
|
||||
<div class="truncate px-1">
|
||||
<nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline">{{ bookTitle }}</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow flex items-center">
|
||||
<div class="w-20 flex items-center">
|
||||
<p class="font-mono text-sm">{{ bookDuration }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
</template>
|
||||
</transition-group>
|
||||
</draggable>
|
||||
|
||||
<modals-podcast-edit-episode v-model="showEditEpisodeModal" :library-item="libraryItem" :episode="selectedEpisode" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -40,8 +38,6 @@ export default {
|
||||
sortDesc: true,
|
||||
drag: false,
|
||||
episodesCopy: [],
|
||||
selectedEpisode: null,
|
||||
showEditEpisodeModal: false,
|
||||
orderChanged: false,
|
||||
savingOrder: false
|
||||
}
|
||||
@@ -97,8 +93,9 @@ export default {
|
||||
return false
|
||||
},
|
||||
editEpisode(episode) {
|
||||
this.selectedEpisode = episode
|
||||
this.showEditEpisodeModal = true
|
||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||
},
|
||||
draggableUpdate() {
|
||||
this.orderChanged = this.checkHasOrderChanged()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
||||
<p class="text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<p class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tables-tracks-table :title="`Audiobook Tracks`" :tracks="media.tracks" :library-item-id="libraryItemId" class="mt-6" />
|
||||
<tables-tracks-table :title="`Audiobook Tracks`" :tracks="media.tracks" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -27,7 +27,8 @@ export default {
|
||||
media: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
isFile: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<modals-user-collections-modal />
|
||||
<modals-edit-collection-modal />
|
||||
<modals-bookshelf-texture-modal />
|
||||
<modals-podcast-edit-episode />
|
||||
<readers-reader />
|
||||
</div>
|
||||
</template>
|
||||
@@ -105,12 +106,6 @@ export default {
|
||||
}
|
||||
}
|
||||
if (payload.serverSettings) {
|
||||
this.$store.commit('setServerSettings', payload.serverSettings)
|
||||
|
||||
if (payload.serverSettings.chromecastEnabled) {
|
||||
console.log('Chromecast enabled import script')
|
||||
require('@/plugins/chromecast.js').default(this)
|
||||
}
|
||||
}
|
||||
|
||||
// Start scans currently running
|
||||
@@ -166,8 +161,28 @@ export default {
|
||||
libraryUpdated(library) {
|
||||
this.$store.commit('libraries/addUpdate', library)
|
||||
},
|
||||
libraryRemoved(library) {
|
||||
async libraryRemoved(library) {
|
||||
this.$store.commit('libraries/remove', library)
|
||||
|
||||
// When removed currently selected library then set next accessible library
|
||||
const currLibraryId = this.$store.state.libraries.currentLibraryId
|
||||
if (currLibraryId === library.id) {
|
||||
var nextLibrary = this.$store.getters['libraries/getNextAccessibleLibrary']
|
||||
if (nextLibrary) {
|
||||
await this.$store.dispatch('libraries/fetch', nextLibrary.id)
|
||||
|
||||
if (this.$route.name.startsWith('config')) {
|
||||
// No need to refresh
|
||||
} else if (this.$route.name.startsWith('library')) {
|
||||
var newRoute = this.$route.path.replace(currLibraryId, nextLibrary.id)
|
||||
this.$router.push(newRoute)
|
||||
} else {
|
||||
this.$router.push(`/library/${nextLibrary.id}`)
|
||||
}
|
||||
} else {
|
||||
console.error('User has no accessible libraries')
|
||||
}
|
||||
}
|
||||
},
|
||||
libraryItemAdded(libraryItem) {
|
||||
// this.$store.commit('libraries/updateFilterDataWithAudiobook', libraryItem)
|
||||
@@ -484,6 +499,25 @@ export default {
|
||||
},
|
||||
resize() {
|
||||
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
|
||||
},
|
||||
checkVersionUpdate() {
|
||||
// Version check is only run if time since last check was 5 minutes
|
||||
const VERSION_CHECK_BUFF = 1000 * 60 * 5 // 5 minutes
|
||||
var lastVerCheck = localStorage.getItem('lastVerCheck') || 0
|
||||
if (Date.now() - Number(lastVerCheck) > VERSION_CHECK_BUFF) {
|
||||
this.$store
|
||||
.dispatch('checkForUpdate')
|
||||
.then((res) => {
|
||||
localStorage.setItem('lastVerCheck', Date.now())
|
||||
if (res && res.hasUpdate) this.showUpdateToast(res)
|
||||
})
|
||||
.catch((err) => console.error(err))
|
||||
|
||||
if (this.$route.query.error) {
|
||||
this.$toast.error(this.$route.query.error)
|
||||
this.$router.replace(this.$route.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
@@ -502,17 +536,7 @@ export default {
|
||||
this.$store.commit('setExperimentalFeatures', true)
|
||||
}
|
||||
|
||||
this.$store
|
||||
.dispatch('checkForUpdate')
|
||||
.then((res) => {
|
||||
if (res && res.hasUpdate) this.showUpdateToast(res)
|
||||
})
|
||||
.catch((err) => console.error(err))
|
||||
|
||||
if (this.$route.query.error) {
|
||||
this.$toast.error(this.$route.query.error)
|
||||
this.$router.replace(this.$route.path)
|
||||
}
|
||||
this.checkVersionUpdate()
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.resize)
|
||||
|
||||
@@ -54,7 +54,7 @@ export default {
|
||||
bookCoverAspectRatio: this.bookCoverAspectRatio,
|
||||
bookshelfView: this.bookshelfView
|
||||
}
|
||||
if (this.entityName === 'series-books') props.showSequence = true
|
||||
|
||||
if (this.entityName === 'books') {
|
||||
props.filterBy = this.filterBy
|
||||
props.orderBy = this.orderBy
|
||||
|
||||
@@ -174,8 +174,8 @@ export default {
|
||||
if (mediaType === 'podcast') return this.cleanPodcast(item, index)
|
||||
return this.cleanBook(item, index)
|
||||
},
|
||||
async getItemsFromDataTransferItems(items, mediaType) {
|
||||
var files = await this.getFilesDropped(items)
|
||||
async getItemsFromDataTransferItems(dataTransferItems, mediaType) {
|
||||
var files = await this.getFilesDropped(dataTransferItems)
|
||||
if (!files || !files.length) return { error: 'No files found ' }
|
||||
var itemData = this.fileTreeToItems(files, mediaType)
|
||||
if (!itemData.items.length && !itemData.ignoredFiles.length) {
|
||||
@@ -189,7 +189,7 @@ export default {
|
||||
if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles)
|
||||
}
|
||||
return ab.itemFiles.length
|
||||
}).map(ab => this.cleanItem(ab, index++))
|
||||
}).map(ab => this.cleanItem(ab, mediaType, index++))
|
||||
return {
|
||||
items,
|
||||
ignoredFiles
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.9",
|
||||
"description": "Audiobook manager and player",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
|
||||
<div class="w-full h-px bg-primary my-4" />
|
||||
|
||||
<p class="mb-4 text-lg">Change Password</p>
|
||||
<form @submit.prevent="submitChangePassword">
|
||||
<p v-if="!isGuest" class="mb-4 text-lg">Change Password</p>
|
||||
<form v-if="!isGuest" @submit.prevent="submitChangePassword">
|
||||
<ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" label="Password" class="my-2" />
|
||||
<ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" label="New Password" class="my-2" />
|
||||
<ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" label="Confirm Password" class="my-2" />
|
||||
@@ -60,6 +60,9 @@ export default {
|
||||
},
|
||||
isRoot() {
|
||||
return this.usertype === 'root'
|
||||
},
|
||||
isGuest() {
|
||||
return this.usertype === 'guest'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
</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">
|
||||
<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">
|
||||
{{ audio.include ? index - numExcluded + 1 : -1 }}
|
||||
</div>
|
||||
@@ -71,7 +73,7 @@
|
||||
<div class="font-sans text-xs font-normal w-56">
|
||||
{{ audio.error }}
|
||||
</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)" />
|
||||
</div>
|
||||
</li>
|
||||
@@ -107,6 +109,10 @@ export default {
|
||||
console.error('Invalid media type')
|
||||
return redirect('/')
|
||||
}
|
||||
if (libraryItem.isFile) {
|
||||
console.error('No need to edit library item that is 1 file...')
|
||||
return redirect('/')
|
||||
}
|
||||
return {
|
||||
libraryItem,
|
||||
files: libraryItem.media.audioFiles ? libraryItem.media.audioFiles.map((af) => ({ ...af, include: !af.exclude })) : []
|
||||
@@ -125,6 +131,12 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
@@ -158,9 +170,6 @@ export default {
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -20,6 +20,14 @@
|
||||
<p class="pl-4 text-lg">Number of backups to keep</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-text-input type="number" v-model="maxBackupSize" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateBackupsSettings" />
|
||||
|
||||
<ui-tooltip :text="maxBackupSizeTooltip">
|
||||
<p class="pl-4 text-lg">Maximum backup size (in GB) <span class="material-icons icon-text">info_outlined</span></p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<tables-backups-table />
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,6 +40,7 @@ export default {
|
||||
updatingServerSettings: false,
|
||||
dailyBackups: true,
|
||||
backupsToKeep: 2,
|
||||
maxBackupSize: 1,
|
||||
newServerSettings: {}
|
||||
}
|
||||
},
|
||||
@@ -47,19 +56,27 @@ export default {
|
||||
dailyBackupsTooltip() {
|
||||
return 'Runs at 1am every day (your server time). Saved in /metadata/backups.'
|
||||
},
|
||||
maxBackupSizeTooltip() {
|
||||
return 'As a safeguard against misconfiguration, backups will fail if they exceed the configured size.'
|
||||
},
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateBackupsSettings() {
|
||||
if (isNaN(this.maxBackupSize) || this.maxBackupSize <= 0) {
|
||||
this.$toast.error('Invalid maximum backup size')
|
||||
return
|
||||
}
|
||||
if (isNaN(this.backupsToKeep) || this.backupsToKeep <= 0 || this.backupsToKeep > 99) {
|
||||
this.$toast.error('Invalid number of backups to keep')
|
||||
return
|
||||
}
|
||||
var updatePayload = {
|
||||
backupSchedule: this.dailyBackups ? '0 1 * * *' : false,
|
||||
backupsToKeep: Number(this.backupsToKeep)
|
||||
backupsToKeep: Number(this.backupsToKeep),
|
||||
maxBackupSize: Number(this.maxBackupSize)
|
||||
}
|
||||
this.updateServerSettings(updatePayload)
|
||||
},
|
||||
@@ -81,6 +98,7 @@ export default {
|
||||
|
||||
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
|
||||
this.dailyBackups = !!this.newServerSettings.backupSchedule
|
||||
this.maxBackupSize = this.newServerSettings.maxBackupSize || 1
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -95,6 +95,23 @@
|
||||
<p class="ml-4">Book has no audio tracks but has valid ebook files. The e-reader is experimental and can be turned on in config.</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast episode downloads queue -->
|
||||
<div v-if="episodeDownloadsQueued.length" class="px-4 py-2 mt-4 bg-info bg-opacity-40 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
|
||||
<div class="flex items-center">
|
||||
<p class="text-sm py-1">{{ episodeDownloadsQueued.length }} Episode{{ episodeDownloadsQueued.length === 1 ? '' : 's' }} queued for download</p>
|
||||
|
||||
<span v-if="userIsAdminOrUp" class="material-icons hover:text-error text-xl ml-3 cursor-pointer" @click="clearDownloadQueue">close</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Podcast episodes currently downloading -->
|
||||
<div v-if="episodesDownloading.length" class="px-4 py-2 mt-4 bg-success bg-opacity-20 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
|
||||
<div v-for="episode in episodesDownloading" :key="episode.id" class="flex items-center">
|
||||
<widgets-loading-spinner />
|
||||
<p class="text-sm py-1 pl-4">Downloading episode "{{ episode.episodeDisplayTitle }}"</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress -->
|
||||
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
|
||||
<p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
@@ -135,16 +152,28 @@
|
||||
<ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="isPodcast" text="Find Episodes" direction="top">
|
||||
<!-- Only admin or root user can download new episodes -->
|
||||
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" text="Find Episodes" direction="top">
|
||||
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<!-- 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 class="my-4 max-w-2xl">
|
||||
<p class="text-base text-gray-100 whitespace-pre-line">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<widgets-audiobook-data v-if="tracks.length" :library-item-id="libraryItemId" :media="media" />
|
||||
<div v-if="invalidAudioFiles.length" class="bg-error border-red-800 shadow-md p-4">
|
||||
<p class="text-sm mb-2">Invalid audio files</p>
|
||||
|
||||
<p v-for="audioFile in invalidAudioFiles" :key="audioFile.id" class="text-xs pl-2">- {{ audioFile.metadata.filename }} ({{ audioFile.error }})</p>
|
||||
</div>
|
||||
|
||||
<widgets-audiobook-data v-if="tracks.length" :library-item-id="libraryItemId" :is-file="isFile" :media="media" />
|
||||
|
||||
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" />
|
||||
|
||||
@@ -154,6 +183,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -163,7 +193,9 @@ export default {
|
||||
if (!store.state.user.user) {
|
||||
return redirect(`/login?redirect=${route.path}`)
|
||||
}
|
||||
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=authors`).catch((error) => {
|
||||
|
||||
// Include episode downloads for podcasts
|
||||
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=authors,downloads,rssfeed`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
@@ -172,7 +204,8 @@ export default {
|
||||
return redirect('/')
|
||||
}
|
||||
return {
|
||||
libraryItem: item
|
||||
libraryItem: item,
|
||||
rssFeedUrl: item.rssFeedUrl || null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -181,10 +214,19 @@ export default {
|
||||
isProcessingReadUpdate: false,
|
||||
fetchingRSSFeed: false,
|
||||
showPodcastEpisodeFeed: false,
|
||||
podcastFeedEpisodes: []
|
||||
podcastFeedEpisodes: [],
|
||||
episodesDownloading: [],
|
||||
episodeDownloadsQueued: [],
|
||||
showRssFeedModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
isFile() {
|
||||
return this.libraryItem.isFile
|
||||
},
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
@@ -209,6 +251,10 @@ export default {
|
||||
isInvalid() {
|
||||
return this.libraryItem.isInvalid
|
||||
},
|
||||
invalidAudioFiles() {
|
||||
if (this.isPodcast) return []
|
||||
return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
|
||||
},
|
||||
showPlayButton() {
|
||||
if (this.isMissing || this.isInvalid) return false
|
||||
if (this.isPodcast) return this.podcastEpisodes.length
|
||||
@@ -330,9 +376,28 @@ export default {
|
||||
},
|
||||
userCanDownload() {
|
||||
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: {
|
||||
clearDownloadQueue() {
|
||||
if (confirm('Are you sure you want to clear episode download queue?')) {
|
||||
this.$axios
|
||||
.$get(`/api/podcasts/${this.libraryItemId}/clear-queue`)
|
||||
.then(() => {
|
||||
this.$toast.success('Episode download queue cleared')
|
||||
this.episodeDownloadQueued = []
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to clear queue', error)
|
||||
this.$toast.error('Failed to clear queue')
|
||||
})
|
||||
}
|
||||
},
|
||||
async findEpisodesClick() {
|
||||
if (!this.mediaMetadata.feedUrl) {
|
||||
return this.$toast.error('Podcast does not have an RSS Feed')
|
||||
@@ -425,17 +490,89 @@ export default {
|
||||
collectionsClick() {
|
||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.$store.commit('globals/setShowUserCollectionsModal', true)
|
||||
},
|
||||
clickRSSFeed() {
|
||||
if (!this.rssFeedUrl) {
|
||||
if (confirm(`Are you sure you want to open an RSS Feed for this podcast?`)) {
|
||||
this.openRSSFeed()
|
||||
}
|
||||
} else {
|
||||
this.showRssFeedModal = true
|
||||
}
|
||||
},
|
||||
openRSSFeed() {
|
||||
const payload = {
|
||||
serverAddress: window.origin
|
||||
}
|
||||
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.rssFeedUrl = data.feedUrl
|
||||
this.showRssFeedModal = true
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to open RSS Feed', error)
|
||||
})
|
||||
},
|
||||
episodeDownloadQueued(episodeDownload) {
|
||||
if (episodeDownload.libraryItemId === this.libraryItemId) {
|
||||
this.episodeDownloadsQueued.push(episodeDownload)
|
||||
}
|
||||
},
|
||||
episodeDownloadStarted(episodeDownload) {
|
||||
if (episodeDownload.libraryItemId === this.libraryItemId) {
|
||||
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
|
||||
this.episodesDownloading.push(episodeDownload)
|
||||
}
|
||||
},
|
||||
episodeDownloadFinished(episodeDownload) {
|
||||
if (episodeDownload.libraryItemId === this.libraryItemId) {
|
||||
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
|
||||
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
|
||||
}
|
||||
},
|
||||
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() {
|
||||
if (this.libraryItem.episodesDownloading) {
|
||||
this.episodeDownloadsQueued = this.libraryItem.episodesDownloading || []
|
||||
}
|
||||
|
||||
// use this items library id as the current
|
||||
if (this.libraryId) {
|
||||
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
|
||||
}
|
||||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||
this.$root.socket.on('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_started', this.episodeDownloadStarted)
|
||||
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||
this.$root.socket.off('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_started', this.episodeDownloadStarted)
|
||||
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
270
client/pages/item/_id/manage.vue
Normal file
270
client/pages/item/_id/manage.vue
Normal 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>
|
||||
@@ -24,7 +24,7 @@ export default {
|
||||
return redirect(`/library/${libraryId}`)
|
||||
}
|
||||
|
||||
var series = await app.$axios.$get(`/api/series/${params.id}`).catch((error) => {
|
||||
var series = await app.$axios.$get(`/api/series/${params.id}?include=progress`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
@@ -33,7 +33,7 @@ export default {
|
||||
}
|
||||
|
||||
return {
|
||||
series: series.name,
|
||||
series,
|
||||
seriesId: params.id
|
||||
}
|
||||
},
|
||||
|
||||
@@ -48,8 +48,15 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setUser(user, defaultLibraryId) {
|
||||
this.$store.commit('libraries/setCurrentLibrary', defaultLibraryId)
|
||||
setUser({ user, userDefaultLibraryId, serverSettings }) {
|
||||
this.$store.commit('setServerSettings', serverSettings)
|
||||
|
||||
if (serverSettings.chromecastEnabled) {
|
||||
console.log('Chromecast enabled import script')
|
||||
require('@/plugins/chromecast.js').default(this)
|
||||
}
|
||||
|
||||
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
|
||||
this.$store.commit('user/setUser', user)
|
||||
},
|
||||
async submitForm() {
|
||||
@@ -69,7 +76,7 @@ export default {
|
||||
if (authRes && authRes.error) {
|
||||
this.error = authRes.error
|
||||
} else if (authRes) {
|
||||
this.setUser(authRes.user, authRes.userDefaultLibraryId)
|
||||
this.setUser(authRes)
|
||||
}
|
||||
this.processing = false
|
||||
},
|
||||
@@ -87,7 +94,7 @@ export default {
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
this.setUser(res.user, res.userDefaultLibraryId)
|
||||
this.setUser(res)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -53,8 +53,8 @@
|
||||
</widgets-alert>
|
||||
|
||||
<!-- Item Upload cards -->
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-item-upload-card :ref="`itemCard-${item.index}`" :key="index" :media-type="selectedLibraryMediaType" :item="item" :processing="processing" @remove="removeItem(item)" />
|
||||
<template v-for="item in items">
|
||||
<cards-item-upload-card :ref="`itemCard-${item.index}`" :key="item.index" :media-type="selectedLibraryMediaType" :item="item" :processing="processing" @remove="removeItem(item)" />
|
||||
</template>
|
||||
|
||||
<!-- Upload/Reset btns -->
|
||||
@@ -195,7 +195,8 @@ export default {
|
||||
e.preventDefault()
|
||||
this.isDragging = false
|
||||
var items = e.dataTransfer.items || []
|
||||
var itemResults = await this.uploadHelpers.getItemsFromDrop(items)
|
||||
|
||||
var itemResults = await this.uploadHelpers.getItemsFromDrop(items, this.selectedLibraryMediaType)
|
||||
this.setResults(itemResults)
|
||||
},
|
||||
inputChanged(e) {
|
||||
|
||||
@@ -10,6 +10,7 @@ export default class PlayerHandler {
|
||||
this.displayTitle = null
|
||||
this.displayAuthor = null
|
||||
this.playWhenReady = false
|
||||
this.initialPlaybackRate = 1
|
||||
this.player = null
|
||||
this.playerState = 'IDLE'
|
||||
this.isHlsTranscode = false
|
||||
@@ -46,12 +47,13 @@ export default class PlayerHandler {
|
||||
return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId)
|
||||
}
|
||||
|
||||
load(libraryItem, episodeId, playWhenReady) {
|
||||
load(libraryItem, episodeId, playWhenReady, playbackRate) {
|
||||
if (!this.player) this.switchPlayer()
|
||||
|
||||
this.libraryItem = libraryItem
|
||||
this.episodeId = episodeId
|
||||
this.playWhenReady = playWhenReady
|
||||
this.initialPlaybackRate = playbackRate
|
||||
this.prepare()
|
||||
}
|
||||
|
||||
@@ -113,6 +115,7 @@ export default class PlayerHandler {
|
||||
console.log('[PlayerHandler] Player state change', state)
|
||||
this.playerState = state
|
||||
if (this.playerState === 'PLAYING') {
|
||||
this.setPlaybackRate(this.initialPlaybackRate)
|
||||
this.startPlayInterval()
|
||||
} else {
|
||||
this.stopPlayInterval()
|
||||
@@ -151,11 +154,12 @@ export default class PlayerHandler {
|
||||
this.prepareSession(session)
|
||||
}
|
||||
|
||||
prepareOpenSession(session) { // Session opened on init socket
|
||||
prepareOpenSession(session, playbackRate) { // Session opened on init socket
|
||||
if (!this.player) this.switchPlayer()
|
||||
|
||||
this.libraryItem = session.libraryItem
|
||||
this.playWhenReady = false
|
||||
this.initialPlaybackRate = playbackRate
|
||||
this.prepareSession(session)
|
||||
}
|
||||
|
||||
@@ -292,6 +296,7 @@ export default class PlayerHandler {
|
||||
}
|
||||
|
||||
setPlaybackRate(playbackRate) {
|
||||
this.initialPlaybackRate = playbackRate // Might be loaded from settings before player is started
|
||||
if (!this.player) return
|
||||
this.player.setPlaybackRate(playbackRate)
|
||||
}
|
||||
|
||||
@@ -33,11 +33,12 @@ export async function checkForUpdate() {
|
||||
return
|
||||
}
|
||||
var largestVer = null
|
||||
await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/tags`).then((res) => {
|
||||
var tags = res.data
|
||||
if (tags && tags.length) {
|
||||
tags.forEach((tag) => {
|
||||
var verObj = parseSemver(tag.name)
|
||||
await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/releases`).then((res) => {
|
||||
var releases = res.data
|
||||
if (releases && releases.length) {
|
||||
releases.forEach((release) => {
|
||||
var tagName = release.tag_name
|
||||
var verObj = parseSemver(tagName)
|
||||
if (verObj) {
|
||||
if (!largestVer || largestVer.total < verObj.total) {
|
||||
largestVer = verObj
|
||||
@@ -50,6 +51,7 @@ export async function checkForUpdate() {
|
||||
console.error('No valid version tags to compare with')
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
hasUpdate: largestVer.total > currVerObj.total,
|
||||
latestVersion: largestVer.version,
|
||||
|
||||
@@ -5,6 +5,8 @@ export const state = () => ({
|
||||
showBatchUserCollectionModal: false,
|
||||
showUserCollectionsModal: false,
|
||||
showEditCollectionModal: false,
|
||||
showEditPodcastEpisode: false,
|
||||
selectedEpisode: null,
|
||||
selectedCollection: null,
|
||||
showBookshelfTextureModal: false,
|
||||
isCasting: false, // Actively casting
|
||||
@@ -46,10 +48,16 @@ export const mutations = {
|
||||
setShowEditCollectionModal(state, val) {
|
||||
state.showEditCollectionModal = val
|
||||
},
|
||||
setShowEditPodcastEpisodeModal(state, val) {
|
||||
state.showEditPodcastEpisode = val
|
||||
},
|
||||
setEditCollection(state, collection) {
|
||||
state.selectedCollection = collection
|
||||
state.showEditCollectionModal = true
|
||||
},
|
||||
setSelectedEpisode(state, episode) {
|
||||
state.selectedEpisode = episode
|
||||
},
|
||||
setShowBookshelfTextureModal(state, val) {
|
||||
state.showBookshelfTextureModal = val
|
||||
},
|
||||
|
||||
@@ -29,6 +29,19 @@ export const getters = {
|
||||
var library = state.libraries.find(l => l.id === libraryId)
|
||||
if (!library) return null
|
||||
return library.provider
|
||||
},
|
||||
getNextAccessibleLibrary: (state, getters, rootState, rootGetters) => {
|
||||
var librariesSorted = getters['getSortedLibraries']()
|
||||
if (!librariesSorted.length) return null
|
||||
|
||||
var canAccessAllLibraries = rootGetters['user/getUserCanAccessAllLibraries']
|
||||
var userAccessibleLibraries = rootGetters['user/getLibrariesAccessible']
|
||||
if (canAccessAllLibraries) return librariesSorted[0]
|
||||
librariesSorted = librariesSorted.filter((lib) => {
|
||||
return userAccessibleLibraries.includes(lib.id)
|
||||
})
|
||||
if (!librariesSorted.length) return null
|
||||
return librariesSorted[0]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export const state = () => ({
|
||||
|
||||
export const getters = {
|
||||
getIsRoot: (state) => state.user && state.user.type === 'root',
|
||||
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
|
||||
getToken: (state) => {
|
||||
return state.user ? state.user.token : null
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ module.exports = {
|
||||
safelist: [
|
||||
'bg-success',
|
||||
'bg-red-600',
|
||||
'text-green-500',
|
||||
'py-1.5',
|
||||
'bg-info'
|
||||
]
|
||||
|
||||
@@ -3,10 +3,10 @@ version: "3.7"
|
||||
|
||||
services:
|
||||
audiobookshelf:
|
||||
image: advplyr/audiobookshelf
|
||||
image: ghcr.io/advplyr/audiobookshelf
|
||||
ports:
|
||||
- 13378:80
|
||||
volumes:
|
||||
- /audiobooks:/audiobooks
|
||||
- /metadata:/metadata
|
||||
- /config:/config
|
||||
- /config:/config
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0"?>
|
||||
<Container version="2">
|
||||
<Name>audiobookshelf</Name>
|
||||
<Repository>advplyr/audiobookshelf</Repository>
|
||||
<Repository>ghcr.io/advplyr/audiobookshelf</Repository>
|
||||
<Registry>https://hub.docker.com/r/advplyr/audiobookshelf/</Registry>
|
||||
<Network>bridge</Network>
|
||||
<MyIP/>
|
||||
@@ -9,8 +9,8 @@
|
||||
<Privileged>false</Privileged>
|
||||
<Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support>
|
||||
<Project>https://github.com/advplyr/audiobookshelf</Project>
|
||||
<Overview>**(Android app is live)** Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free & open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Overview>
|
||||
<Category>MediaApp:Books MediaServer:Books</Category>
|
||||
<Overview>Self-hosted audiobook and podcast server and web app. Supports multi-user w/ permissions and keeps progress in sync across devices. Free & open source mobile apps. Consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Overview>
|
||||
<Category>MediaApp:Books MediaServer:Books MediaApp:Other MediaServer:Other</Category>
|
||||
<WebUI>http://[IP]:[PORT:80]</WebUI>
|
||||
<TemplateURL>https://raw.githubusercontent.com/advplyr/docker-templates/master/audiobookshelf.xml</TemplateURL>
|
||||
<Icon>https://github.com/advplyr/audiobookshelf/raw/master/client/static/Logo.png</Icon>
|
||||
@@ -20,7 +20,7 @@
|
||||
<DateInstalled>1629238508</DateInstalled>
|
||||
<DonateText/>
|
||||
<DonateLink/>
|
||||
<Description>Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free & 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 & open source mobile apps. Consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Description>
|
||||
<Networking>
|
||||
<Mode>bridge</Mode>
|
||||
<Publish>
|
||||
@@ -65,4 +65,4 @@
|
||||
<Config Name="Config" Target="/config" Default="/mnt/user/appdata/audiobookshelf/config/" Mode="rw" Description="Container Path: /config" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/audiobookshelf/config/</Config>
|
||||
<Config Name="Metadata" Target="/metadata" Default="/mnt/user/appdata/audiobookshelf/metadata/" Mode="rw" Description="Container Path: /metadata" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/audiobookshelf/metadata/</Config>
|
||||
<Config Name="Web UI Port" Target="80" Default="13378" Mode="tcp" Description="Container Port: 80" Type="Port" Display="always" Required="false" Mask="false">13378</Config>
|
||||
</Container>
|
||||
</Container>
|
||||
|
||||
@@ -1 +1,49 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 6702.73 1277.37"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:url(#linear-gradient);}.cls-3{font-size:800px;fill:#c9c9c9;font-family:GentiumBookBasic, Gentium Book Basic;}.cls-4{font-size:420px;fill:#474747;font-family:GentiumBasic, Gentium Basic;}</style><linearGradient id="linear-gradient" x1="617.37" y1="20.7" x2="617.37" y2="1216.56" gradientUnits="userSpaceOnUse"><stop offset="0.32" stop-color="#cd9d49"/><stop offset="0.99" stop-color="#875d27"/></linearGradient></defs><title>bgAsset 6</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_2-2" data-name="Layer 2"><g id="Layer_4" data-name="Layer 4"><g id="Layer_5" data-name="Layer 5"><circle class="cls-1" cx="618.63" cy="618.63" r="618.63"/></g><circle class="cls-2" cx="617.37" cy="618.63" r="597.93"/></g><path class="cls-1" d="M1005.57,574.08c-4.84-4-12.37-10-22.58-17v-79.2c0-201.93-163.69-365.63-365.62-365.63h0c-201.93,0-365.63,163.7-365.63,365.63v79.2c-10.21,7-17.74,13-22.58,17A18.15,18.15,0,0,0,222.63,588v94.89a18.15,18.15,0,0,0,6.53,14c11.29,9.4,37.19,29.1,77.52,49.31v9.22c0,24.88,16,45,35.84,45h0c19.79,0,35.84-20.16,35.84-45V527.83c0-24.87-16.05-45-35.84-45h0c-19,0-34.48,18.51-35.75,41.94l-.09,0v-46.9c0-171.59,139.1-310.69,310.69-310.69h0c171.58,0,310.68,139.1,310.68,310.69v46.9l-.08,0c-1.27-23.43-16.79-41.94-35.76-41.94h0c-19.79,0-35.83,20.17-35.83,45V755.4c0,24.88,16,45,35.83,45h0c19.8,0,35.84-20.16,35.84-45v-9.22c40.33-20.21,66.24-39.91,77.52-49.31a18.15,18.15,0,0,0,6.53-14V588A18.15,18.15,0,0,0,1005.57,574.08Z"/><path class="cls-1" d="M489.87,969.71a43.31,43.31,0,0,0,43.3-43.3V441.64a43.3,43.3,0,0,0-43.3-43.29H445.15a43.3,43.3,0,0,0-43.3,43.29V926.41a43.31,43.31,0,0,0,43.3,43.3Zm-71.69-455.1h98.67v10.31H418.18Z"/><path class="cls-1" d="M639.73,969.71A43.3,43.3,0,0,0,683,926.41V441.64a43.29,43.29,0,0,0-43.29-43.29H595a43.29,43.29,0,0,0-43.29,43.29V926.41A43.3,43.3,0,0,0,595,969.71ZM568,514.61H666.7v10.31H568Z"/><path class="cls-1" d="M789.59,969.71a43.3,43.3,0,0,0,43.29-43.3V441.64a43.29,43.29,0,0,0-43.29-43.29H744.86a43.3,43.3,0,0,0-43.3,43.29V926.41a43.31,43.31,0,0,0,43.3,43.3Zm-71.7-455.1h98.67v10.31H717.89Z"/><rect class="cls-1" x="294.5" y="984.69" width="645.74" height="65.25" rx="32.63"/></g><g id="Layer_6" data-name="Layer 6"><text class="cls-3" transform="translate(1492.27 670.42)">audiobookshelf</text><text class="cls-4" transform="translate(1492.27 1128.69)">self-hosted audiobook server</text></g></g></svg>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 24.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 6702.7 1277.4" style="enable-background:new 0 0 6702.7 1277.4;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
.st1{fill:url(#SVGID_1_);}
|
||||
.st2{fill:#C9C9C9;}
|
||||
.st3{font-family:'GentiumBookBasic';}
|
||||
.st4{font-size:800px;}
|
||||
.st5{fill:#474747;}
|
||||
.st6{font-family:'GentiumBasic';}
|
||||
.st7{font-size:305px;}
|
||||
</style>
|
||||
<title>bgAsset 6</title>
|
||||
<g id="Layer_2_1_">
|
||||
<g id="Layer_2-2">
|
||||
<g id="Layer_4">
|
||||
<g id="Layer_5">
|
||||
<circle class="st0" cx="618.6" cy="618.6" r="618.6"/>
|
||||
</g>
|
||||
|
||||
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="617.37" y1="1257.3" x2="617.37" y2="61.4399" gradientTransform="matrix(1 0 0 -1 0 1278)">
|
||||
<stop offset="0.32" style="stop-color:#CD9D49"/>
|
||||
<stop offset="0.99" style="stop-color:#875D27"/>
|
||||
</linearGradient>
|
||||
<circle class="st1" cx="617.4" cy="618.6" r="597.9"/>
|
||||
</g>
|
||||
<path class="st0" d="M1005.6,574.1c-4.8-4-12.4-10-22.6-17v-79.2c0-201.9-163.7-365.6-365.6-365.6l0,0
|
||||
c-201.9,0-365.6,163.7-365.6,365.6v79.2c-10.2,7-17.7,13-22.6,17c-4.1,3.4-6.5,8.5-6.5,13.9v94.9c0,5.4,2.4,10.5,6.5,14
|
||||
c11.3,9.4,37.2,29.1,77.5,49.3v9.2c0,24.9,16,45,35.8,45l0,0c19.8,0,35.8-20.2,35.8-45V527.8c0-24.9-16-45-35.8-45l0,0
|
||||
c-19,0-34.5,18.5-35.8,41.9h-0.1v-46.9c0-171.6,139.1-310.7,310.7-310.7l0,0C789,167.2,928,306.3,928,477.9v46.9H928
|
||||
c-1.3-23.4-16.8-41.9-35.8-41.9l0,0c-19.8,0-35.8,20.2-35.8,45v227.6c0,24.9,16,45,35.8,45l0,0c19.8,0,35.8-20.2,35.8-45v-9.2
|
||||
c40.3-20.2,66.2-39.9,77.5-49.3c4.2-3.5,6.5-8.6,6.5-14V588C1012.1,582.6,1009.7,577.5,1005.6,574.1z"/>
|
||||
<path class="st0" d="M489.9,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3h-44.7
|
||||
c-23.9,0-43.3,19.4-43.3,43.3v484.8c0,23.9,19.4,43.3,43.3,43.3L489.9,969.7z M418.2,514.6h98.7v10.3h-98.7V514.6z"/>
|
||||
<path class="st0" d="M639.7,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3H595c-23.9,0-43.3,19.4-43.3,43.3
|
||||
v484.8c0,23.9,19.4,43.3,43.3,43.3H639.7z M568,514.6h98.7v10.3H568V514.6z"/>
|
||||
<path class="st0" d="M789.6,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3h-44.7
|
||||
c-23.9,0-43.3,19.4-43.3,43.3v484.8c0,23.9,19.4,43.3,43.3,43.3L789.6,969.7z M717.9,514.6h98.7v10.3h-98.7V514.6z"/>
|
||||
<path class="st0" d="M327.1,984.7h580.5c18,0,32.6,14.6,32.6,32.6v0c0,18-14.6,32.6-32.6,32.6H327.1c-18,0-32.6-14.6-32.6-32.6v0
|
||||
C294.5,999.3,309.1,984.7,327.1,984.7z"/>
|
||||
</g>
|
||||
<g id="Layer_6">
|
||||
<text transform="matrix(1 0 0 1 1492.27 735.42)" class="st2 st3 st4">audiobookshelf</text>
|
||||
<text id="self-hosted_audiobook_and_podcast_server" transform="matrix(1 0 0 1 1492.27 1103.6899)" class="st5 st6 st7">self-hosted audiobook and podcast server</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.9 KiB |
84
package-lock.json
generated
84
package-lock.json
generated
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "1.7.3",
|
||||
"version": "2.0.8",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "1.7.3",
|
||||
"version": "2.0.8",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"archiver": "^5.3.0",
|
||||
@@ -26,6 +25,7 @@
|
||||
"node-cron": "^3.0.0",
|
||||
"node-ffprobe": "^3.0.0",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"podcast": "^2.0.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"read-chunk": "^3.1.0",
|
||||
"recursive-readdir-async": "^1.1.8",
|
||||
@@ -1477,6 +1477,14 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
@@ -1675,6 +1683,34 @@
|
||||
"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": {
|
||||
"version": "5.2.1",
|
||||
"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": {
|
||||
"version": "0.4.23",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
@@ -3387,6 +3436,30 @@
|
||||
"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": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -3690,6 +3763,11 @@
|
||||
"integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==",
|
||||
"requires": {}
|
||||
},
|
||||
"xml": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||
"integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU="
|
||||
},
|
||||
"xml2js": {
|
||||
"version": "0.4.23",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.9",
|
||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -44,6 +44,7 @@
|
||||
"node-cron": "^3.0.0",
|
||||
"node-ffprobe": "^3.0.0",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"podcast": "^2.0.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"read-chunk": "^3.1.0",
|
||||
"recursive-readdir-async": "^1.1.8",
|
||||
|
||||
89
readme.md
89
readme.md
@@ -14,20 +14,23 @@
|
||||
|
||||
# About
|
||||
|
||||
Audiobookshelf is a self-hosted audiobook server for managing and playing your audiobooks.
|
||||
Audiobookshelf is a self-hosted audiobook and podcast server.
|
||||
|
||||
### Features
|
||||
|
||||
* Fully **open-source**, including the [android & iOS app](https://github.com/advplyr/audiobookshelf-app) *(in beta)*
|
||||
* Stream all audiobook formats on the fly
|
||||
* Stream all audio formats on the fly
|
||||
* Search and add podcasts to download episodes w/ auto-download
|
||||
* Multi-user support w/ custom permissions
|
||||
* Keeps progress per user and syncs across devices
|
||||
* Auto-detects library updates, no need to re-scan
|
||||
* Upload audiobooks w/ bulk upload drag and drop folders
|
||||
* Upload books and podcasts w/ bulk upload drag and drop folders
|
||||
* Backup your metadata + automated daily backups
|
||||
* Progressive Web App (PWA)
|
||||
* Chromecast support on the web app
|
||||
* Chromecast support on the web app and android app
|
||||
* Fetch metadata and cover art from several sources
|
||||
* Basic ebook support and e-reader *(experimental)*
|
||||
* Merge your audio files into a single m4b w/ metadata and embedded cover *(experimental)*
|
||||
|
||||
Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/audiobookshelf/issues/new/choose)
|
||||
|
||||
@@ -68,27 +71,62 @@ docker run -d \
|
||||
-e AUDIOBOOKSHELF_GID=100 \
|
||||
-p 13378:80 \
|
||||
-v </path/to/audiobooks>:/audiobooks \
|
||||
-v </path/to/your/podcasts>:/podcasts \
|
||||
-v </path/to/config>:/config \
|
||||
-v </path/to/metadata>:/metadata \
|
||||
--name audiobookshelf \
|
||||
--rm advplyr/audiobookshelf
|
||||
ghcr.io/advplyr/audiobookshelf
|
||||
```
|
||||
|
||||
### Docker Update
|
||||
|
||||
```bash
|
||||
docker stop audiobookshelf
|
||||
docker pull ghcr.io/advplyr/audiobookshelf
|
||||
docker start audiobookshelf
|
||||
```
|
||||
|
||||
### Running with Docker Compose
|
||||
|
||||
```bash
|
||||
```yaml
|
||||
### docker-compose.yml ###
|
||||
services:
|
||||
audiobookshelf:
|
||||
image: advplyr/audiobookshelf
|
||||
image: ghcr.io/advplyr/audiobookshelf
|
||||
environment:
|
||||
- AUDIOBOOKSHELF_UID=99
|
||||
- AUDIOBOOKSHELF_GID=100
|
||||
ports:
|
||||
- 13378:80
|
||||
volumes:
|
||||
- <path/to/your/audiobooks>:/audiobooks
|
||||
- <path/to/metadata>:/metadata
|
||||
- <path/to/config>:/config
|
||||
- </path/to/your/audiobooks>:/audiobooks
|
||||
- </path/to/your/podcasts>:/podcasts
|
||||
- </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
|
||||
|
||||
@@ -96,29 +134,15 @@ Debian package will use this config file `/etc/default/audiobookshelf` if exists
|
||||
|
||||
### 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
|
||||
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
|
||||
```
|
||||
See [install docs](https://www.audiobookshelf.org/install/#ubuntu)
|
||||
|
||||
### Install via debian package
|
||||
|
||||
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
|
||||
@@ -224,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)
|
||||
|
||||
### [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 />
|
||||
|
||||
# Run from source
|
||||
|
||||
@@ -100,6 +100,14 @@ class Auth {
|
||||
})
|
||||
}
|
||||
|
||||
getUserLoginResponsePayload(user) {
|
||||
return {
|
||||
user: user.toJSONForBrowser(),
|
||||
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
|
||||
serverSettings: this.db.serverSettings.toJSON()
|
||||
}
|
||||
}
|
||||
|
||||
async login(req, res) {
|
||||
var username = (req.body.username || '').toLowerCase()
|
||||
var password = req.body.password || ''
|
||||
@@ -120,17 +128,14 @@ class Auth {
|
||||
if (password) {
|
||||
return res.status(401).send('Invalid root password (hint: there is none)')
|
||||
} else {
|
||||
return res.json({ user: user.toJSONForBrowser(), userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries) })
|
||||
return res.json(this.getUserLoginResponsePayload(user))
|
||||
}
|
||||
}
|
||||
|
||||
// Check password match
|
||||
var compare = await bcrypt.compare(password, user.pash)
|
||||
if (compare) {
|
||||
res.json({
|
||||
user: user.toJSONForBrowser(),
|
||||
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries)
|
||||
})
|
||||
res.json(this.getUserLoginResponsePayload(user))
|
||||
} else {
|
||||
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
|
||||
if (req.rateLimit.remaining <= 2) {
|
||||
|
||||
37
server/Db.js
37
server/Db.js
@@ -316,6 +316,39 @@ class Db {
|
||||
})
|
||||
}
|
||||
|
||||
async bulkInsertEntities(entityName, entities, batchSize = 500) {
|
||||
// Group entities in batches of size batchSize
|
||||
var entityBatches = []
|
||||
var batch = []
|
||||
var index = 0
|
||||
entities.forEach((ent) => {
|
||||
batch.push(ent)
|
||||
index++
|
||||
if (index >= batchSize) {
|
||||
entityBatches.push(batch)
|
||||
index = 0
|
||||
batch = []
|
||||
}
|
||||
})
|
||||
if (batch.length) entityBatches.push(batch)
|
||||
|
||||
Logger.info(`[Db] bulkInsertEntities: ${entities.length} ${entityName} to ${entityBatches.length} batches of max size ${batchSize}`)
|
||||
|
||||
// Start inserting batches
|
||||
var batchIndex = 1
|
||||
for (const entityBatch of entityBatches) {
|
||||
Logger.info(`[Db] bulkInsertEntities: Start inserting batch ${batchIndex} of ${entityBatch.length} for ${entityName}`)
|
||||
var success = await this.insertEntities(entityName, entityBatch)
|
||||
if (success) {
|
||||
Logger.info(`[Db] bulkInsertEntities: Success inserting batch ${batchIndex} for ${entityName}`)
|
||||
} else {
|
||||
Logger.info(`[Db] bulkInsertEntities: Failed inserting batch ${batchIndex} for ${entityName}`)
|
||||
}
|
||||
batchIndex++
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
updateEntities(entityName, entities) {
|
||||
var entityDb = this.getEntityDb(entityName)
|
||||
|
||||
@@ -378,7 +411,9 @@ class Db {
|
||||
|
||||
removeEntity(entityName, entityId) {
|
||||
var entityDb = this.getEntityDb(entityName)
|
||||
return entityDb.delete((record) => record.id === entityId).then((results) => {
|
||||
return entityDb.delete((record) => {
|
||||
return record.id === entityId
|
||||
}).then((results) => {
|
||||
Logger.debug(`[DB] Deleted entity ${entityName}: ${results.deleted}`)
|
||||
var arrayKey = this.getEntityArrayKey(entityName)
|
||||
if (this[arrayKey]) {
|
||||
|
||||
@@ -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
|
||||
@@ -10,6 +10,7 @@ const { version } = require('../package.json')
|
||||
|
||||
// Utils
|
||||
const dbMigration = require('./utils/dbMigration')
|
||||
const filePerms = require('./utils/filePerms')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
// Classes
|
||||
@@ -29,6 +30,8 @@ const LogManager = require('./managers/LogManager')
|
||||
const BackupManager = require('./managers/BackupManager')
|
||||
const PlaybackSessionManager = require('./managers/PlaybackSessionManager')
|
||||
const PodcastManager = require('./managers/PodcastManager')
|
||||
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
||||
const RssFeedManager = require('./managers/RssFeedManager')
|
||||
|
||||
class Server {
|
||||
constructor(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
||||
@@ -46,9 +49,18 @@ class Server {
|
||||
global.MetadataPath = global.MetadataPath.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
fs.ensureDirSync(global.ConfigPath, 0o774)
|
||||
fs.ensureDirSync(global.MetadataPath, 0o774)
|
||||
fs.ensureDirSync(global.AudiobookPath, 0o774)
|
||||
if (!fs.pathExistsSync(global.ConfigPath)) {
|
||||
fs.mkdirSync(global.ConfigPath)
|
||||
filePerms.setDefaultDirSync(global.ConfigPath, false)
|
||||
}
|
||||
if (!fs.pathExistsSync(global.MetadataPath)) {
|
||||
fs.mkdirSync(global.MetadataPath)
|
||||
filePerms.setDefaultDirSync(global.MetadataPath, false)
|
||||
}
|
||||
if (!fs.pathExistsSync(global.AudiobookPath)) {
|
||||
fs.mkdirSync(global.AudiobookPath)
|
||||
filePerms.setDefaultDirSync(global.AudiobookPath, false)
|
||||
}
|
||||
|
||||
this.db = new Db()
|
||||
this.watcher = new Watcher()
|
||||
@@ -62,11 +74,13 @@ class Server {
|
||||
this.playbackSessionManager = new PlaybackSessionManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.coverManager = new CoverManager(this.db, this.cacheManager)
|
||||
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))
|
||||
|
||||
// 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.staticRouter = new StaticRouter(this.db)
|
||||
|
||||
@@ -153,8 +167,8 @@ class Server {
|
||||
|
||||
app.use(this.auth.cors)
|
||||
app.use(fileUpload())
|
||||
app.use(express.urlencoded({ extended: true, limit: "3mb" }));
|
||||
app.use(express.json({ limit: "3mb" }))
|
||||
app.use(express.urlencoded({ extended: true, limit: "5mb" }));
|
||||
app.use(express.json({ limit: "5mb" }))
|
||||
|
||||
// Static path to generated nuxt
|
||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||
@@ -186,6 +200,19 @@ class Server {
|
||||
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
|
||||
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')))
|
||||
@@ -399,6 +426,7 @@ class Server {
|
||||
await this.db.updateEntity('user', user)
|
||||
|
||||
const initialPayload = {
|
||||
// TODO: this is sent with user auth now, update mobile app to use that then remove this
|
||||
serverSettings: this.db.serverSettings.toJSON(),
|
||||
audiobookPath: global.AudiobookPath,
|
||||
metadataPath: global.MetadataPath,
|
||||
|
||||
@@ -176,15 +176,31 @@ class LibraryController {
|
||||
sortKey += 'IgnorePrefix'
|
||||
}
|
||||
|
||||
// Start sort
|
||||
var direction = payload.sortDesc ? 'desc' : 'asc'
|
||||
libraryItems = naturalSort(libraryItems)[direction]((li) => {
|
||||
var sortArray = [
|
||||
{
|
||||
[direction]: (li) => {
|
||||
// Supports dot notation strings i.e. "media.metadata.title"
|
||||
return sortKey.split('.').reduce((a, b) => a[b], li)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// Supports dot notation strings i.e. "media.metadata.title"
|
||||
return sortKey.split('.').reduce((a, b) => a[b], li)
|
||||
})
|
||||
// Secondary sort when sorting by book author use series sort title
|
||||
if (payload.mediaType === 'book' && payload.sortBy.includes('author')) {
|
||||
sortArray.push({
|
||||
asc: (li) => {
|
||||
if (li.media.metadata.series && li.media.metadata.series.length) {
|
||||
return li.media.metadata.getSeriesSortTitle(li.media.metadata.series[0])
|
||||
}
|
||||
return null
|
||||
}
|
||||
})
|
||||
}
|
||||
libraryItems = naturalSort(libraryItems).by(sortArray)
|
||||
}
|
||||
|
||||
// TODO: Potentially implement collapse series again
|
||||
if (payload.collapseseries) {
|
||||
libraryItems = libraryHelpers.collapseBookSeries(libraryItems)
|
||||
payload.total = libraryItems.length
|
||||
@@ -209,6 +225,22 @@ class LibraryController {
|
||||
res.json(payload)
|
||||
}
|
||||
|
||||
async removeLibraryItemsWithIssues(req, res) {
|
||||
var libraryItemsWithIssues = req.libraryItems.filter(li => li.hasIssues)
|
||||
if (!libraryItemsWithIssues.length) {
|
||||
Logger.warn(`[LibraryController] No library items have issues`)
|
||||
return res.sendStatus(200)
|
||||
}
|
||||
|
||||
Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`)
|
||||
for (const libraryItem of libraryItemsWithIssues) {
|
||||
Logger.info(`[LibraryController] Removing library item "${libraryItem.media.metadata.title}"`)
|
||||
await this.handleDeleteLibraryItem(libraryItem)
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// api/libraries/:id/series
|
||||
async getAllSeriesForLibrary(req, res) {
|
||||
var libraryItems = req.libraryItems
|
||||
@@ -276,137 +308,13 @@ class LibraryController {
|
||||
}
|
||||
|
||||
// api/libraries/:id/personalized
|
||||
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.getItemsWithUserProgress(req.user, libraryItems)
|
||||
var categories = [
|
||||
{
|
||||
id: 'continue-listening',
|
||||
label: 'Continue Listening',
|
||||
type: 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: 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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
res.json(categories)
|
||||
}
|
||||
|
||||
// LEGACY
|
||||
// api/libraries/:id/books/categories
|
||||
async getLibraryCategories(req, res) {
|
||||
var library = req.library
|
||||
var books = this.db.audiobooks.filter(ab => ab.libraryId === library.id)
|
||||
var limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
|
||||
var minified = req.query.minified === '1'
|
||||
|
||||
var booksWithUserAb = libraryHelpers.getItemsWithUserProgress(req.user, books)
|
||||
var series = libraryHelpers.getSeriesFromBooks(books, minified)
|
||||
var seriesWithUserAb = libraryHelpers.getSeriesWithProgressFromBooks(req.user, books)
|
||||
|
||||
var categories = [
|
||||
{
|
||||
id: 'continue-reading',
|
||||
label: 'Continue Reading',
|
||||
type: 'books',
|
||||
entities: libraryHelpers.getBooksMostRecentlyRead(booksWithUserAb, limitPerShelf, minified)
|
||||
},
|
||||
{
|
||||
id: 'continue-series',
|
||||
label: 'Continue Series',
|
||||
type: 'books',
|
||||
entities: libraryHelpers.getBooksNextInSeries(seriesWithUserAb, limitPerShelf, minified)
|
||||
},
|
||||
{
|
||||
id: 'recently-added',
|
||||
label: 'Recently Added',
|
||||
type: 'books',
|
||||
entities: libraryHelpers.getBooksMostRecentlyAdded(books, limitPerShelf, minified)
|
||||
},
|
||||
{
|
||||
id: 'read-again',
|
||||
label: 'Read Again',
|
||||
type: 'books',
|
||||
entities: libraryHelpers.getBooksMostRecentlyFinished(booksWithUserAb, limitPerShelf, minified)
|
||||
},
|
||||
{
|
||||
id: 'recent-series',
|
||||
label: 'Recent Series',
|
||||
type: 'series',
|
||||
entities: libraryHelpers.getSeriesMostRecentlyAdded(series, limitPerShelf)
|
||||
}
|
||||
].filter(cats => { // Remove categories with no items
|
||||
return cats.entities.length
|
||||
})
|
||||
// New and improved personalized call only loops through library items once
|
||||
async getLibraryUserPersonalizedOptimal(req, res) {
|
||||
const mediaType = req.library.mediaType
|
||||
const libraryItems = req.libraryItems
|
||||
const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 10
|
||||
|
||||
const categories = libraryHelpers.buildPersonalizedShelves(req.user, libraryItems, mediaType, this.db.series, this.db.authors, limitPerShelf)
|
||||
res.json(categories)
|
||||
}
|
||||
|
||||
@@ -544,7 +452,8 @@ class LibraryController {
|
||||
})
|
||||
}
|
||||
})
|
||||
res.json(Object.values(authors))
|
||||
|
||||
res.json(naturalSort(Object.values(authors)).asc(au => au.name))
|
||||
}
|
||||
|
||||
async matchAll(req, res) {
|
||||
|
||||
@@ -11,6 +11,17 @@ class LibraryItemController {
|
||||
if (req.query.expanded == 1) {
|
||||
var item = req.libraryItem.toJSONExpanded()
|
||||
|
||||
// Include users media progress
|
||||
if (includeEntities.includes('progress')) {
|
||||
var episodeId = req.query.episode || null
|
||||
item.userMediaProgress = req.user.getMediaProgress(item.id, episodeId)
|
||||
}
|
||||
|
||||
if (includeEntities.includes('rssfeed')) {
|
||||
var feedData = this.rssFeedManager.findFeedForItem(item.id)
|
||||
item.rssFeedUrl = feedData ? feedData.feedUrl : null
|
||||
}
|
||||
|
||||
if (item.mediaType == 'book') {
|
||||
if (includeEntities.includes('authors')) {
|
||||
item.media.metadata.authors = item.media.metadata.authors.map(au => {
|
||||
@@ -21,6 +32,9 @@ class LibraryItemController {
|
||||
}
|
||||
}).filter(au => au)
|
||||
}
|
||||
} else if (includeEntities.includes('downloads')) {
|
||||
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
|
||||
item.episodesDownloading = downloadsInQueue.map(d => d.toJSONForClient())
|
||||
}
|
||||
|
||||
return res.json(item)
|
||||
@@ -333,12 +347,34 @@ class LibraryItemController {
|
||||
Logger.error(`[LibraryItemController] Non-root user attempted to scan library item`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (req.libraryItem.isFile) {
|
||||
Logger.error(`[LibraryItemController] Re-scanning file library items not yet supported`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
var result = await this.scanner.scanLibraryItemById(req.libraryItem.id)
|
||||
res.json({
|
||||
result: Object.keys(ScanResult).find(key => ScanResult[key] == result)
|
||||
})
|
||||
}
|
||||
|
||||
// POST: api/items/:id/audio-metadata
|
||||
async updateAudioFileMetadata(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
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) {
|
||||
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!item || !item.media) return res.sendStatus(404)
|
||||
|
||||
@@ -133,6 +133,10 @@ class MeController {
|
||||
|
||||
// PATCH: api/me/password
|
||||
updatePassword(req, res) {
|
||||
if (req.user.isGuest) {
|
||||
Logger.error(`[MeController] Guest user attempted to change password`, req.user.username)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
this.auth.userChangePassword(req, res)
|
||||
}
|
||||
|
||||
|
||||
@@ -230,7 +230,12 @@ class MiscController {
|
||||
Logger.error('Invalid user in authorize')
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
res.json({ user: req.user, userDefaultLibraryId: req.user.getDefaultLibraryId(this.db.libraries) })
|
||||
const userResponse = {
|
||||
user: req.user,
|
||||
userDefaultLibraryId: req.user.getDefaultLibraryId(this.db.libraries),
|
||||
serverSettings: this.db.serverSettings.toJSON()
|
||||
}
|
||||
res.json(userResponse)
|
||||
}
|
||||
|
||||
getAllTags(req, res) {
|
||||
|
||||
@@ -9,8 +9,8 @@ const filePerms = require('../utils/filePerms')
|
||||
class PodcastController {
|
||||
|
||||
async create(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.error(`[PodcastController] Non-root user attempted to create podcast`, req.user)
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[PodcastController] Non-admin user attempted to create podcast`, req.user)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
const payload = req.body
|
||||
@@ -29,8 +29,8 @@ class PodcastController {
|
||||
|
||||
var podcastPath = payload.path.replace(/\\/g, '/')
|
||||
if (await fs.pathExists(podcastPath)) {
|
||||
Logger.error(`[PodcastController] Attempt to create podcast when folder path already exists "${podcastPath}"`)
|
||||
return res.status(400).send('Path already exists')
|
||||
Logger.error(`[PodcastController] Podcast folder already exists "${podcastPath}"`)
|
||||
return res.status(400).send('Podcast already exists')
|
||||
}
|
||||
|
||||
var success = await fs.ensureDir(podcastPath).then(() => true).catch((error) => {
|
||||
@@ -63,7 +63,8 @@ class PodcastController {
|
||||
// Download and save cover image
|
||||
if (payload.media.metadata.imageUrl) {
|
||||
// TODO: Scan cover image to library files
|
||||
var coverResponse = await this.coverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl)
|
||||
// Podcast cover will always go into library item folder
|
||||
var coverResponse = await this.coverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true)
|
||||
if (coverResponse) {
|
||||
if (coverResponse.error) {
|
||||
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
|
||||
@@ -101,6 +102,7 @@ class PodcastController {
|
||||
Logger.error('Invalid podcast feed request response')
|
||||
return res.status(500).send('Bad response from feed request')
|
||||
}
|
||||
Logger.debug(`[PdocastController] Podcast feed size ${(data.data.length / 1024 / 1024).toFixed(2)}MB`)
|
||||
var payload = await parsePodcastRssFeedXml(data.data, includeRaw)
|
||||
if (!payload) {
|
||||
return res.status(500).send('Invalid podcast RSS feed')
|
||||
@@ -113,29 +115,47 @@ class PodcastController {
|
||||
}
|
||||
|
||||
async checkNewEpisodes(req, res) {
|
||||
var libraryItem = this.db.getLibraryItem(req.params.id)
|
||||
if (!libraryItem || libraryItem.mediaType !== 'podcast') {
|
||||
return res.sendStatus(404)
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[PodcastController] Non-admin user attempted to check/download episodes`, req.user)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
var libraryItem = req.libraryItem
|
||||
if (!libraryItem.media.metadata.feedUrl) {
|
||||
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${libraryItem.id}`)
|
||||
return res.status(500).send('Podcast has no rss feed url')
|
||||
}
|
||||
|
||||
var newEpisodes = await this.podcastManager.checkPodcastForNewEpisodes(libraryItem)
|
||||
var newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(libraryItem)
|
||||
res.json({
|
||||
episodes: newEpisodes || []
|
||||
})
|
||||
}
|
||||
|
||||
clearEpisodeDownloadQueue(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[PodcastController] Non-admin user attempting to clear download queue "${req.user.username}"`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
this.podcastManager.clearDownloadQueue(req.params.id)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
getEpisodeDownloads(req, res) {
|
||||
var libraryItem = req.libraryItem
|
||||
|
||||
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
|
||||
res.json({
|
||||
downloads: downloadsInQueue.map(d => d.toJSONForClient())
|
||||
})
|
||||
}
|
||||
|
||||
async downloadEpisodes(req, res) {
|
||||
var libraryItem = this.db.getLibraryItem(req.params.id)
|
||||
if (!libraryItem || libraryItem.mediaType !== 'podcast') {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
if (!req.user.canUpload || !req.user.checkCanAccessLibrary(libraryItem.libraryId)) {
|
||||
return res.sendStatus(404)
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
var libraryItem = req.libraryItem
|
||||
|
||||
var episodes = req.body
|
||||
if (!episodes || !episodes.length) {
|
||||
@@ -146,14 +166,33 @@ class PodcastController {
|
||||
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)
|
||||
|
||||
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) {
|
||||
var libraryItem = this.db.getLibraryItem(req.params.id)
|
||||
if (!libraryItem || libraryItem.mediaType !== 'podcast') {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
if (!req.user.canUpload || !req.user.checkCanAccessLibrary(libraryItem.libraryId)) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
var libraryItem = req.libraryItem
|
||||
|
||||
var episodeId = req.params.episodeId
|
||||
if (!libraryItem.media.checkHasEpisode(episodeId)) {
|
||||
@@ -168,5 +207,35 @@ class PodcastController {
|
||||
|
||||
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()
|
||||
@@ -4,7 +4,25 @@ class SeriesController {
|
||||
constructor() { }
|
||||
|
||||
async findOne(req, res) {
|
||||
return res.json(req.series)
|
||||
var include = (req.query.include || '').split(',')
|
||||
|
||||
var seriesJson = req.series.toJSON()
|
||||
|
||||
// Add progress map with isFinished flag
|
||||
if (include.includes('progress')) {
|
||||
var libraryItemsInSeries = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(seriesJson.id))
|
||||
var libraryItemsFinished = libraryItemsInSeries.filter(li => {
|
||||
var mediaProgress = req.user.getMediaProgress(li.id)
|
||||
return mediaProgress && mediaProgress.isFinished
|
||||
})
|
||||
seriesJson.progress = {
|
||||
libraryItemIds: libraryItemsInSeries.map(li => li.id),
|
||||
libraryItemIdsFinished: libraryItemsFinished.map(li => li.id),
|
||||
isFinished: libraryItemsFinished.length === libraryItemsInSeries.length
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(seriesJson)
|
||||
}
|
||||
|
||||
async search(req, res) {
|
||||
|
||||
@@ -121,7 +121,7 @@ class AbMergeManager {
|
||||
'-acodec aac',
|
||||
'-ac 2',
|
||||
'-b:a 64k',
|
||||
'-id3v2_version 3'
|
||||
'-movflags use_metadata_tags'
|
||||
])
|
||||
} else {
|
||||
ffmpegOptions.push('-max_muxing_queue_size 1000')
|
||||
|
||||
140
server/managers/AudioMetadataManager.js
Normal file
140
server/managers/AudioMetadataManager.js
Normal 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
|
||||
@@ -23,9 +23,6 @@ class BackupManager {
|
||||
this.scheduleTask = null
|
||||
|
||||
this.backups = []
|
||||
|
||||
// If backup exceeds this value it will be aborted
|
||||
this.MaxBytesBeforeAbort = 1000000000 // ~ 1GB
|
||||
}
|
||||
|
||||
get serverSettings() {
|
||||
@@ -263,7 +260,8 @@ class BackupManager {
|
||||
reject(err)
|
||||
})
|
||||
archive.on('progress', ({ fs: fsobj }) => {
|
||||
if (fsobj.processedBytes > this.MaxBytesBeforeAbort) {
|
||||
const maxBackupSizeInBytes = this.serverSettings.maxBackupSize * 1000 * 1000 * 1000
|
||||
if (fsobj.processedBytes > maxBackupSizeInBytes) {
|
||||
Logger.error(`[BackupManager] Archiver is too large - aborting to prevent endless loop, Bytes Processed: ${fsobj.processedBytes}`)
|
||||
archive.abort()
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -19,7 +19,7 @@ class CoverManager {
|
||||
}
|
||||
|
||||
getCoverDirectory(libraryItem) {
|
||||
if (this.db.serverSettings.storeCoverWithItem) {
|
||||
if (this.db.serverSettings.storeCoverWithItem && !libraryItem.isFile) {
|
||||
return libraryItem.path
|
||||
} else {
|
||||
return Path.posix.join(this.ItemMetadataPath, libraryItem.id)
|
||||
@@ -113,15 +113,17 @@ class CoverManager {
|
||||
|
||||
Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.metadata.title}"`)
|
||||
|
||||
await filePerms.setDefault(coverFullPath)
|
||||
libraryItem.updateMediaCover(coverFullPath)
|
||||
return {
|
||||
cover: coverFullPath
|
||||
}
|
||||
}
|
||||
|
||||
async downloadCoverFromUrl(libraryItem, url) {
|
||||
async downloadCoverFromUrl(libraryItem, url, forceLibraryItemFolder = false) {
|
||||
try {
|
||||
var coverDirPath = this.getCoverDirectory(libraryItem)
|
||||
// Force save cover with library item is used for adding new podcasts
|
||||
var coverDirPath = forceLibraryItemFolder ? libraryItem.path : this.getCoverDirectory(libraryItem)
|
||||
await fs.ensureDir(coverDirPath)
|
||||
|
||||
var temppath = Path.posix.join(coverDirPath, 'cover')
|
||||
@@ -150,6 +152,7 @@ class CoverManager {
|
||||
|
||||
Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}" for "${libraryItem.media.metadata.title}"`)
|
||||
|
||||
await filePerms.setDefault(coverFullPath)
|
||||
libraryItem.updateMediaCover(coverFullPath)
|
||||
return {
|
||||
cover: coverFullPath
|
||||
@@ -249,6 +252,8 @@ class CoverManager {
|
||||
|
||||
var success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath)
|
||||
if (success) {
|
||||
await filePerms.setDefault(coverFilePath)
|
||||
|
||||
libraryItem.updateMediaCover(coverFilePath)
|
||||
return coverFilePath
|
||||
}
|
||||
|
||||
@@ -78,6 +78,13 @@ class PlaybackSessionManager {
|
||||
}
|
||||
|
||||
async startSession(user, libraryItem, episodeId, options) {
|
||||
// Close any sessions already open for user
|
||||
var userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id)
|
||||
for (const session of userSessions) {
|
||||
Logger.info(`[PlaybackSessionManager] startSession: Closing open session "${session.displayTitle}" for user "${user.username}"`)
|
||||
await this.closeSession(user, session, null)
|
||||
}
|
||||
|
||||
var shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options, episodeId))
|
||||
var mediaPlayer = options.mediaPlayer || 'unknown'
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class PodcastManager {
|
||||
this.currentDownload = null
|
||||
|
||||
this.episodeScheduleTask = null
|
||||
this.failedCheckMap = {}
|
||||
}
|
||||
|
||||
get serverSettings() {
|
||||
@@ -35,6 +36,23 @@ class PodcastManager {
|
||||
}
|
||||
}
|
||||
|
||||
getEpisodeDownloadsInQueue(libraryItemId) {
|
||||
return this.downloadQueue.filter(d => d.libraryItemId === libraryItemId)
|
||||
}
|
||||
|
||||
clearDownloadQueue(libraryItemId = null) {
|
||||
if (!this.downloadQueue.length) return
|
||||
|
||||
if (!libraryItemId) {
|
||||
Logger.info(`[PodcastManager] Clearing all downloads in queue (${this.downloadQueue.length})`)
|
||||
this.downloadQueue = []
|
||||
} else {
|
||||
var itemDownloads = this.getEpisodeDownloadsInQueue(libraryItemId)
|
||||
Logger.info(`[PodcastManager] Clearing downloads in queue for item "${libraryItemId}" (${itemDownloads.length})`)
|
||||
this.downloadQueue = this.downloadQueue.filter(d => d.libraryItemId !== libraryItemId)
|
||||
}
|
||||
}
|
||||
|
||||
async downloadPodcastEpisodes(libraryItem, episodesToDownload) {
|
||||
var index = libraryItem.media.episodes.length + 1
|
||||
episodesToDownload.forEach((ep) => {
|
||||
@@ -50,8 +68,11 @@ class PodcastManager {
|
||||
async startPodcastEpisodeDownload(podcastEpisodeDownload) {
|
||||
if (this.currentDownload) {
|
||||
this.downloadQueue.push(podcastEpisodeDownload)
|
||||
this.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient())
|
||||
return
|
||||
}
|
||||
|
||||
this.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient())
|
||||
this.currentDownload = podcastEpisodeDownload
|
||||
|
||||
// Ignores all added files to this dir
|
||||
@@ -65,11 +86,17 @@ class PodcastManager {
|
||||
success = await this.scanAddPodcastEpisodeAudioFile()
|
||||
if (!success) {
|
||||
await fs.remove(this.currentDownload.targetPath)
|
||||
this.currentDownload.setFinished(false)
|
||||
} else {
|
||||
Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.podcastEpisode.title}"`)
|
||||
this.currentDownload.setFinished(true)
|
||||
}
|
||||
} else {
|
||||
this.currentDownload.setFinished(false)
|
||||
}
|
||||
|
||||
this.emitter('episode_download_finished', this.currentDownload.toJSONForClient())
|
||||
|
||||
this.watcher.removeIgnoreDir(this.currentDownload.libraryItem.path)
|
||||
this.currentDownload = null
|
||||
if (this.downloadQueue.length) {
|
||||
@@ -96,6 +123,10 @@ class PodcastManager {
|
||||
var podcastEpisode = this.currentDownload.podcastEpisode
|
||||
podcastEpisode.audioFile = audioFile
|
||||
libraryItem.media.addPodcastEpisode(podcastEpisode)
|
||||
if (libraryItem.isInvalid) {
|
||||
// First episode added to an empty podcast
|
||||
libraryItem.isInvalid = false
|
||||
}
|
||||
libraryItem.libraryFiles.push(libraryFile)
|
||||
libraryItem.updatedAt = Date.now()
|
||||
await this.db.updateLibraryItem(libraryItem)
|
||||
@@ -124,7 +155,10 @@ class PodcastManager {
|
||||
schedulePodcastEpisodeCron() {
|
||||
try {
|
||||
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) {
|
||||
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
|
||||
}
|
||||
@@ -141,21 +175,35 @@ class PodcastManager {
|
||||
async checkForNewEpisodes() {
|
||||
var podcastsWithAutoDownload = this.db.libraryItems.filter(li => li.mediaType === 'podcast' && li.media.autoDownloadEpisodes)
|
||||
if (!podcastsWithAutoDownload.length) {
|
||||
Logger.info(`[PodcastManager] checkForNewEpisodes - No podcasts with auto download set`)
|
||||
this.cancelCron()
|
||||
return
|
||||
}
|
||||
Logger.debug(`[PodcastManager] checkForNewEpisodes - Checking ${podcastsWithAutoDownload.length} Podcasts`)
|
||||
|
||||
for (const libraryItem of podcastsWithAutoDownload) {
|
||||
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
||||
Logger.info(`[PodcastManager] checkForNewEpisodes Cron for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
|
||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem)
|
||||
Logger.debug(`[PodcastManager] checkForNewEpisodes checked result ${newEpisodes ? newEpisodes.length : 'N/A'}`)
|
||||
|
||||
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) {
|
||||
delete this.failedCheckMap[libraryItem.id]
|
||||
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
|
||||
this.downloadPodcastEpisodes(libraryItem, newEpisodes)
|
||||
} else {
|
||||
delete this.failedCheckMap[libraryItem.id]
|
||||
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`)
|
||||
}
|
||||
|
||||
@@ -168,27 +216,56 @@ class PodcastManager {
|
||||
|
||||
async checkPodcastForNewEpisodes(podcastLibraryItem) {
|
||||
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
|
||||
}
|
||||
var feed = await this.getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl)
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
var newEpisodes = feed.episodes.filter(ep => ep.publishedAt > podcastLibraryItem.media.lastEpisodeCheck && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url))
|
||||
// Max new episodes for safety = 2
|
||||
newEpisodes = newEpisodes.slice(0, 2)
|
||||
// Max new episodes for safety = 3
|
||||
newEpisodes = newEpisodes.slice(0, 3)
|
||||
return newEpisodes
|
||||
}
|
||||
|
||||
async checkAndDownloadNewEpisodes(libraryItem) {
|
||||
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
||||
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
|
||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem)
|
||||
if (newEpisodes.length) {
|
||||
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
|
||||
this.downloadPodcastEpisodes(libraryItem, newEpisodes)
|
||||
} else {
|
||||
Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.metadata.title}"`)
|
||||
}
|
||||
|
||||
libraryItem.media.lastEpisodeCheck = Date.now()
|
||||
libraryItem.updatedAt = Date.now()
|
||||
await this.db.updateLibraryItem(libraryItem)
|
||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
|
||||
return newEpisodes
|
||||
}
|
||||
|
||||
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) {
|
||||
Logger.error('Invalid podcast feed request response')
|
||||
return false
|
||||
}
|
||||
Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}" success - parsing xml`)
|
||||
var payload = await parsePodcastRssFeedXml(data.data)
|
||||
if (!payload) {
|
||||
return false
|
||||
|
||||
131
server/managers/RssFeedManager.js
Normal file
131
server/managers/RssFeedManager.js
Normal file
@@ -0,0 +1,131 @@
|
||||
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, feedId, libraryItem, serverAddress) {
|
||||
const podcast = libraryItem.media
|
||||
|
||||
const feedUrl = `${serverAddress}/feed/${feedId}`
|
||||
// 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/${feedId}/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/${feedId}/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: feedId,
|
||||
userId,
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItemPath: libraryItem.path,
|
||||
mediaCoverPath: podcast.coverPath,
|
||||
serverAddress: serverAddress,
|
||||
feedUrl,
|
||||
feed
|
||||
}
|
||||
this.feeds[feedId] = feedData
|
||||
return feedData
|
||||
}
|
||||
|
||||
openPodcastFeed(user, libraryItem, options) {
|
||||
const serverAddress = options.serverAddress
|
||||
const feedId = getId('feed')
|
||||
const feedData = this.openFeed(user.id, feedId, 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
|
||||
@@ -18,6 +18,7 @@ class LibraryItem {
|
||||
|
||||
this.path = null
|
||||
this.relPath = null
|
||||
this.isFile = false
|
||||
this.mtimeMs = null
|
||||
this.ctimeMs = null
|
||||
this.birthtimeMs = null
|
||||
@@ -51,6 +52,7 @@ class LibraryItem {
|
||||
this.folderId = libraryItem.folderId
|
||||
this.path = libraryItem.path
|
||||
this.relPath = libraryItem.relPath
|
||||
this.isFile = !!libraryItem.isFile
|
||||
this.mtimeMs = libraryItem.mtimeMs || 0
|
||||
this.ctimeMs = libraryItem.ctimeMs || 0
|
||||
this.birthtimeMs = libraryItem.birthtimeMs || 0
|
||||
@@ -82,6 +84,7 @@ class LibraryItem {
|
||||
folderId: this.folderId,
|
||||
path: this.path,
|
||||
relPath: this.relPath,
|
||||
isFile: this.isFile,
|
||||
mtimeMs: this.mtimeMs,
|
||||
ctimeMs: this.ctimeMs,
|
||||
birthtimeMs: this.birthtimeMs,
|
||||
@@ -105,6 +108,7 @@ class LibraryItem {
|
||||
folderId: this.folderId,
|
||||
path: this.path,
|
||||
relPath: this.relPath,
|
||||
isFile: this.isFile,
|
||||
mtimeMs: this.mtimeMs,
|
||||
ctimeMs: this.ctimeMs,
|
||||
birthtimeMs: this.birthtimeMs,
|
||||
@@ -128,6 +132,7 @@ class LibraryItem {
|
||||
folderId: this.folderId,
|
||||
path: this.path,
|
||||
relPath: this.relPath,
|
||||
isFile: this.isFile,
|
||||
mtimeMs: this.mtimeMs,
|
||||
ctimeMs: this.ctimeMs,
|
||||
birthtimeMs: this.birthtimeMs,
|
||||
@@ -460,7 +465,7 @@ class LibraryItem {
|
||||
this.isSavingMetadata = true
|
||||
|
||||
var metadataPath = Path.join(global.MetadataPath, 'items', this.id)
|
||||
if (global.ServerSettings.storeMetadataWithItem) {
|
||||
if (global.ServerSettings.storeMetadataWithItem && !this.isFile) {
|
||||
metadataPath = this.path
|
||||
} else {
|
||||
// Make sure metadata book dir exists
|
||||
|
||||
@@ -10,22 +10,42 @@ class PodcastEpisodeDownload {
|
||||
this.libraryItem = null
|
||||
|
||||
this.isDownloading = false
|
||||
this.isFinished = false
|
||||
this.failed = false
|
||||
|
||||
this.startedAt = null
|
||||
this.createdAt = null
|
||||
this.finishedAt = null
|
||||
}
|
||||
|
||||
toJSONForClient() {
|
||||
return {
|
||||
id: this.id,
|
||||
// podcastEpisode: this.podcastEpisode ? this.podcastEpisode.toJSON() : null,
|
||||
episodeDisplayTitle: this.podcastEpisode ? this.podcastEpisode.bestFilename : null,
|
||||
url: this.url,
|
||||
libraryItemId: this.libraryItem ? this.libraryItem.id : null,
|
||||
isDownloading: this.isDownloading,
|
||||
isFinished: this.isFinished,
|
||||
failed: this.failed,
|
||||
startedAt: this.startedAt,
|
||||
createdAt: this.createdAt,
|
||||
finishedAt: this.finishedAt
|
||||
}
|
||||
}
|
||||
|
||||
get targetFilename() {
|
||||
return sanitizeFilename(`${this.podcastEpisode.bestFilename}.mp3`)
|
||||
}
|
||||
|
||||
get targetPath() {
|
||||
return Path.join(this.libraryItem.path, this.targetFilename)
|
||||
}
|
||||
|
||||
get targetRelPath() {
|
||||
return this.targetFilename
|
||||
}
|
||||
get libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
}
|
||||
|
||||
setData(podcastEpisode, libraryItem) {
|
||||
this.id = getId('epdl')
|
||||
@@ -34,5 +54,11 @@ class PodcastEpisodeDownload {
|
||||
this.libraryItem = libraryItem
|
||||
this.createdAt = Date.now()
|
||||
}
|
||||
|
||||
setFinished(success) {
|
||||
this.finishedAt = Date.now()
|
||||
this.isFinished = true
|
||||
this.failed = !success
|
||||
}
|
||||
}
|
||||
module.exports = PodcastEpisodeDownload
|
||||
@@ -1,3 +1,4 @@
|
||||
const Logger = require('../../Logger')
|
||||
const { getId } = require('../../utils/index')
|
||||
|
||||
class Author {
|
||||
@@ -19,7 +20,7 @@ class Author {
|
||||
construct(author) {
|
||||
this.id = author.id
|
||||
this.asin = author.asin
|
||||
this.name = author.name
|
||||
this.name = author.name || ''
|
||||
this.description = author.description || null
|
||||
this.imagePath = author.imagePath
|
||||
this.relImagePath = author.relImagePath
|
||||
@@ -81,6 +82,10 @@ class Author {
|
||||
|
||||
checkNameEquals(name) {
|
||||
if (!name) return false
|
||||
if (this.name === null) {
|
||||
Logger.error(`[Author] Author name is null (${this.id})`)
|
||||
return false
|
||||
}
|
||||
return this.name.toLowerCase() == name.toLowerCase().trim()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ class Book {
|
||||
numAudioFiles: this.audioFiles.length,
|
||||
numChapters: this.chapters.length,
|
||||
numMissingParts: this.missingParts.length,
|
||||
numInvalidAudioFiles: this.invalidAudioFiles.length,
|
||||
duration: this.duration,
|
||||
size: this.size,
|
||||
ebookFormat: this.ebookFile ? this.ebookFile.ebookFormat : null
|
||||
@@ -106,8 +107,11 @@ class Book {
|
||||
get hasEmbeddedCoverArt() {
|
||||
return this.audioFiles.some(af => af.embeddedCoverArt)
|
||||
}
|
||||
get invalidAudioFiles() {
|
||||
return this.audioFiles.filter(af => af.invalid)
|
||||
}
|
||||
get hasIssues() {
|
||||
return this.missingParts.length || this.audioFiles.some(af => af.invalid)
|
||||
return this.missingParts.length || this.invalidAudioFiles.length
|
||||
}
|
||||
get tracks() {
|
||||
var startOffset = 0
|
||||
|
||||
@@ -257,5 +257,9 @@ class Podcast {
|
||||
if (!episode) return 0
|
||||
return episode.duration
|
||||
}
|
||||
|
||||
getEpisode(episodeId) {
|
||||
return this.episodes.find(ep => ep.id == episodeId)
|
||||
}
|
||||
}
|
||||
module.exports = Podcast
|
||||
@@ -162,6 +162,11 @@ class BookMetadata {
|
||||
if (!series) return null
|
||||
return series.sequence || ''
|
||||
}
|
||||
getSeriesSortTitle(series) {
|
||||
if (!series) return ''
|
||||
if (!series.sequence) return series.name
|
||||
return `${series.name} #${series.sequence}`
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
var json = this.toJSON()
|
||||
|
||||
@@ -4,6 +4,8 @@ const Logger = require('../../Logger')
|
||||
class LibrarySettings {
|
||||
constructor(settings) {
|
||||
this.disableWatcher = false
|
||||
this.skipMatchingMediaWithAsin = false
|
||||
this.skipMatchingMediaWithIsbn = false
|
||||
|
||||
if (settings) {
|
||||
this.construct(settings)
|
||||
@@ -12,11 +14,15 @@ class LibrarySettings {
|
||||
|
||||
construct(settings) {
|
||||
this.disableWatcher = !!settings.disableWatcher
|
||||
this.skipMatchingMediaWithAsin = !!settings.skipMatchingMediaWithAsin
|
||||
this.skipMatchingMediaWithIsbn = !!settings.skipMatchingMediaWithIsbn
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
disableWatcher: this.disableWatcher
|
||||
disableWatcher: this.disableWatcher,
|
||||
skipMatchingMediaWithAsin: this.skipMatchingMediaWithAsin,
|
||||
skipMatchingMediaWithIsbn: this.skipMatchingMediaWithIsbn
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class ServerSettings {
|
||||
// this.backupSchedule = '0 1 * * *' // If false then auto-backups are disabled (default every day at 1am)
|
||||
this.backupSchedule = false
|
||||
this.backupsToKeep = 2
|
||||
this.maxBackupSize = 1
|
||||
this.backupMetadataCovers = true
|
||||
|
||||
// Logger
|
||||
@@ -78,6 +79,7 @@ class ServerSettings {
|
||||
|
||||
this.backupSchedule = settings.backupSchedule || false
|
||||
this.backupsToKeep = settings.backupsToKeep || 2
|
||||
this.maxBackupSize = settings.maxBackupSize || 1
|
||||
this.backupMetadataCovers = settings.backupMetadataCovers !== false
|
||||
|
||||
this.loggerDailyLogsToKeep = settings.loggerDailyLogsToKeep || 7
|
||||
@@ -114,6 +116,7 @@ class ServerSettings {
|
||||
rateLimitLoginWindow: this.rateLimitLoginWindow,
|
||||
backupSchedule: this.backupSchedule,
|
||||
backupsToKeep: this.backupsToKeep,
|
||||
maxBackupSize: this.maxBackupSize,
|
||||
backupMetadataCovers: this.backupMetadataCovers,
|
||||
loggerDailyLogsToKeep: this.loggerDailyLogsToKeep,
|
||||
loggerScannerLogsToKeep: this.loggerScannerLogsToKeep,
|
||||
|
||||
@@ -2,7 +2,7 @@ const Logger = require('../../Logger')
|
||||
|
||||
class MediaProgress {
|
||||
constructor(progress) {
|
||||
this.id = null // Same as library item id
|
||||
this.id = null
|
||||
this.libraryItemId = null
|
||||
this.episodeId = null // For podcasts
|
||||
|
||||
|
||||
@@ -30,6 +30,15 @@ class User {
|
||||
get isRoot() {
|
||||
return this.type === 'root'
|
||||
}
|
||||
get isAdmin() {
|
||||
return this.type === 'admin'
|
||||
}
|
||||
get isGuest() {
|
||||
return this.type === 'guest'
|
||||
}
|
||||
get isAdminOrUp() {
|
||||
return this.isAdmin || this.isRoot
|
||||
}
|
||||
get canDelete() {
|
||||
return !!this.permissions.delete && this.isActive
|
||||
}
|
||||
@@ -57,7 +66,7 @@ class User {
|
||||
mobileOrderBy: 'recent',
|
||||
mobileOrderDesc: true,
|
||||
mobileFilterBy: 'all',
|
||||
orderBy: 'book.title',
|
||||
orderBy: 'media.metadata.title',
|
||||
orderDesc: false,
|
||||
filterBy: 'all',
|
||||
playbackRate: 1,
|
||||
@@ -186,6 +195,7 @@ class User {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// And update permissions
|
||||
if (payload.permissions) {
|
||||
for (const key in payload.permissions) {
|
||||
@@ -195,8 +205,15 @@ class User {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update accessible libraries
|
||||
if (payload.librariesAccessible !== undefined) {
|
||||
if (this.permissions.accessAllLibraries) {
|
||||
// Access all libraries
|
||||
if (this.librariesAccessible.length) {
|
||||
this.librariesAccessible = []
|
||||
hasUpdates = true
|
||||
}
|
||||
} else if (payload.librariesAccessible !== undefined) {
|
||||
if (payload.librariesAccessible.length) {
|
||||
if (payload.librariesAccessible.join(',') !== this.librariesAccessible.join(',')) {
|
||||
hasUpdates = true
|
||||
@@ -208,8 +225,14 @@ class User {
|
||||
}
|
||||
}
|
||||
|
||||
// Update accessible libraries
|
||||
if (payload.itemTagsAccessible !== undefined) {
|
||||
// Update accessible tags
|
||||
if (this.permissions.accessAllTags) {
|
||||
// Access all tags
|
||||
if (this.itemTagsAccessible.length) {
|
||||
this.itemTagsAccessible = []
|
||||
hasUpdates = true
|
||||
}
|
||||
} else if (payload.itemTagsAccessible !== undefined) {
|
||||
if (payload.itemTagsAccessible.length) {
|
||||
if (payload.itemTagsAccessible.join(',') !== this.itemTagsAccessible.join(',')) {
|
||||
hasUpdates = true
|
||||
@@ -251,6 +274,11 @@ class User {
|
||||
})
|
||||
}
|
||||
|
||||
getAllMediaProgressForLibraryItem(libraryItemId) {
|
||||
if (!this.mediaProgress) return []
|
||||
return this.mediaProgress.filter(li => li.libraryItemId === libraryItemId)
|
||||
}
|
||||
|
||||
createUpdateMediaProgress(libraryItem, updatePayload, episodeId = null) {
|
||||
var itemProgress = this.mediaProgress.find(li => {
|
||||
if (episodeId && li.episodeId !== episodeId) return false
|
||||
|
||||
@@ -25,7 +25,7 @@ const Series = require('../objects/entities/Series')
|
||||
const FileSystemController = require('../controllers/FileSystemController')
|
||||
|
||||
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.auth = auth
|
||||
this.scanner = scanner
|
||||
@@ -36,6 +36,8 @@ class ApiRouter {
|
||||
this.watcher = watcher
|
||||
this.cacheManager = cacheManager
|
||||
this.podcastManager = podcastManager
|
||||
this.audioMetadataManager = audioMetadataManager
|
||||
this.rssFeedManager = rssFeedManager
|
||||
this.emitter = emitter
|
||||
this.clientEmitter = clientEmitter
|
||||
|
||||
@@ -58,9 +60,10 @@ class ApiRouter {
|
||||
this.router.delete('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.delete.bind(this))
|
||||
|
||||
this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this))
|
||||
this.router.delete('/libraries/:id/issues', LibraryController.middleware.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this))
|
||||
this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
|
||||
this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this))
|
||||
this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalized.bind(this))
|
||||
this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalizedOptimal.bind(this))
|
||||
this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this))
|
||||
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
|
||||
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
|
||||
@@ -90,6 +93,7 @@ class ApiRouter {
|
||||
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.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/update', LibraryItemController.batchUpdate.bind(this))
|
||||
@@ -126,10 +130,10 @@ class ApiRouter {
|
||||
//
|
||||
this.router.get('/me/listening-sessions', MeController.getListeningSessions.bind(this))
|
||||
this.router.get('/me/listening-stats', MeController.getListeningStats.bind(this))
|
||||
this.router.patch('/me/progress/batch/update', MeController.batchUpdateMediaProgress.bind(this))
|
||||
this.router.patch('/me/progress/:id', MeController.createUpdateMediaProgress.bind(this))
|
||||
this.router.delete('/me/progress/:id', MeController.removeMediaProgress.bind(this))
|
||||
this.router.patch('/me/progress/:id/:episodeId', MeController.createUpdateEpisodeMediaProgress.bind(this))
|
||||
this.router.patch('/me/progress/batch/update', MeController.batchUpdateMediaProgress.bind(this))
|
||||
this.router.post('/me/item/:id/bookmark', MeController.createBookmark.bind(this))
|
||||
this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this))
|
||||
this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this))
|
||||
@@ -177,9 +181,13 @@ class ApiRouter {
|
||||
//
|
||||
this.router.post('/podcasts', PodcastController.create.bind(this))
|
||||
this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this))
|
||||
this.router.get('/podcasts/:id/checknew', PodcastController.checkNewEpisodes.bind(this))
|
||||
this.router.post('/podcasts/:id/download-episodes', PodcastController.downloadEpisodes.bind(this))
|
||||
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.updateEpisode.bind(this))
|
||||
this.router.get('/podcasts/:id/checknew', PodcastController.middleware.bind(this), PodcastController.checkNewEpisodes.bind(this))
|
||||
this.router.get('/podcasts/:id/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.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.middleware.bind(this), PodcastController.downloadEpisodes.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
|
||||
|
||||
@@ -235,7 +235,7 @@ class Scanner {
|
||||
|
||||
var hasMediaFile = dataFound.libraryFiles.some(lf => lf.isMediaFile)
|
||||
if (!hasMediaFile) {
|
||||
libraryScan.addLog(LogLevel.WARN, `Directory found "${libraryItemDataFound.path}" has no media files`)
|
||||
libraryScan.addLog(LogLevel.WARN, `Item found "${libraryItemDataFound.path}" has no media files`)
|
||||
} else {
|
||||
var audioFileSize = 0
|
||||
dataFound.libraryFiles.filter(lf => lf.fileType == 'audio').forEach(lf => audioFileSize += lf.metadata.size)
|
||||
@@ -518,7 +518,7 @@ class Scanner {
|
||||
var altDir = `${itemDir}/${firstNest}`
|
||||
|
||||
var fullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), itemDir)
|
||||
var childLibraryItem = this.db.libraryItems.find(li => li.path !== fullPath && li.fullPath.startsWith(fullPath))
|
||||
var childLibraryItem = this.db.libraryItems.find(li => li.path !== fullPath && li.path.startsWith(fullPath))
|
||||
if (!childLibraryItem) {
|
||||
continue;
|
||||
}
|
||||
@@ -642,7 +642,7 @@ class Scanner {
|
||||
}
|
||||
|
||||
// Update media metadata if not set OR overrideDetails flag
|
||||
const detailKeysToUpdate = ['title', 'subtitle', 'narrator', 'publisher', 'publishedYear', 'asin', 'isbn']
|
||||
const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'asin', 'isbn']
|
||||
const updatePayload = {}
|
||||
for (const key in matchData) {
|
||||
if (matchData[key] && detailKeysToUpdate.includes(key)) {
|
||||
@@ -726,6 +726,21 @@ class Scanner {
|
||||
|
||||
for (let i = 0; i < itemsInLibrary.length; i++) {
|
||||
var libraryItem = itemsInLibrary[i]
|
||||
|
||||
if (libraryItem.media.metadata.asin && library.settings.skipMatchingMediaWithAsin) {
|
||||
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${
|
||||
libraryItem.media.metadata.title
|
||||
}" because it already has an ASIN (${i + 1} of ${itemsInLibrary.length})`)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (libraryItem.media.metadata.isbn && library.settings.skipMatchingMediaWithIsbn) {
|
||||
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${
|
||||
libraryItem.media.metadata.title
|
||||
}" because it already has an ISBN (${i + 1} of ${itemsInLibrary.length})`)
|
||||
continue;
|
||||
}
|
||||
|
||||
Logger.debug(`[Scanner] matchLibraryItems: Quick matching "${libraryItem.media.metadata.title}" (${i + 1} of ${itemsInLibrary.length})`)
|
||||
var result = await this.quickMatchLibraryItem(libraryItem, { provider })
|
||||
if (result.warning) {
|
||||
|
||||
@@ -242,10 +242,10 @@ async function migrateLibraryItems(db) {
|
||||
var libraryItems = audiobooks.map((ab) => makeLibraryItemFromOldAb(ab))
|
||||
|
||||
Logger.info(`>>> ${libraryItems.length} Library Items made`)
|
||||
await db.insertEntities('libraryItem', libraryItems)
|
||||
await db.bulkInsertEntities('libraryItem', libraryItems)
|
||||
if (authorsToAdd.length) {
|
||||
Logger.info(`>>> ${authorsToAdd.length} Authors made`)
|
||||
await db.insertEntities('author', authorsToAdd)
|
||||
await db.bulkInsertEntities('author', authorsToAdd)
|
||||
}
|
||||
if (seriesToAdd.length) {
|
||||
Logger.info(`>>> ${seriesToAdd.length} Series made`)
|
||||
|
||||
@@ -39,7 +39,7 @@ async function runFfmpeg() {
|
||||
ffmpegCommand.on('stderr', (stdErrline) => {
|
||||
parentPort.postMessage({
|
||||
type: 'FFMPEG',
|
||||
level: 'error',
|
||||
level: 'debug',
|
||||
log: '[DownloadWorker] Ffmpeg Stderr: ' + stdErrline
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,10 +48,33 @@ async function writeMetadataFile(libraryItem, outputPath) {
|
||||
`artist=${libraryItem.media.metadata.authorName}`,
|
||||
`album_artist=${libraryItem.media.metadata.authorName}`,
|
||||
`date=${libraryItem.media.metadata.publishedYear || ''}`,
|
||||
`description=${libraryItem.media.metadata.description}`,
|
||||
`genre=${libraryItem.media.metadata.genres.join(';')}`
|
||||
`description=${libraryItem.media.metadata.description || ''}`,
|
||||
`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) {
|
||||
libraryItem.media.chapters.forEach((chap) => {
|
||||
const chapterstrs = [
|
||||
|
||||
@@ -94,4 +94,21 @@ module.exports.setDefault = (path, silent = false) => {
|
||||
if (!silent) Logger.debug(`[FilePerms] Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)
|
||||
chmodr(path, mode, uid, gid, resolve)
|
||||
})
|
||||
}
|
||||
|
||||
// Default permissions 0o744 and global Uid/Gid
|
||||
// Used for setting default permission to initial config/metadata directories
|
||||
module.exports.setDefaultDirSync = (path, silent = false) => {
|
||||
const mode = 0o744
|
||||
const uid = global.Uid
|
||||
const gid = global.Gid
|
||||
if (!silent) Logger.debug(`[FilePerms] Setting dir permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)
|
||||
try {
|
||||
fs.chmodSync(path, mode)
|
||||
fs.chownSync(path, uid, gid)
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error(`[FilePerms] Error setting dir permissions for path "${path}"`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
const fs = require('fs-extra')
|
||||
const rra = require('recursive-readdir-async')
|
||||
const axios = require('axios')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
async function getFileStat(path) {
|
||||
@@ -104,14 +105,27 @@ async function recurseFiles(path, relPathToReplace = null) {
|
||||
return []
|
||||
}
|
||||
|
||||
const directoriesToIgnore = []
|
||||
|
||||
list = list.filter((item) => {
|
||||
if (item.error) {
|
||||
Logger.error(`[fileUtils] Recurse files file "${item.fullName}" has error`, item.error)
|
||||
Logger.error(`[fileUtils] Recurse files file "${item.fullname}" has error`, item.error)
|
||||
return false
|
||||
}
|
||||
|
||||
var relpath = item.fullname.replace(relPathToReplace, '')
|
||||
var reldirname = Path.dirname(relpath)
|
||||
if (reldirname === '.') reldirname = ''
|
||||
var dirname = Path.dirname(item.fullname)
|
||||
|
||||
// Directory has a file named ".ignore" flag directory and ignore
|
||||
if (item.name === '.ignore' && reldirname && reldirname !== '.' && !directoriesToIgnore.includes(dirname)) {
|
||||
Logger.debug(`[fileUtils] .ignore found - ignoring directory "${reldirname}"`)
|
||||
directoriesToIgnore.push(dirname)
|
||||
return false
|
||||
}
|
||||
|
||||
// Ignore any file if a directory or the filename starts with "."
|
||||
var relpath = item.fullname.replace(relPathToReplace, '')
|
||||
var pathStartsWithPeriod = relpath.split('/').find(p => p.startsWith('.'))
|
||||
if (pathStartsWithPeriod) {
|
||||
Logger.debug(`[fileUtils] Ignoring path has . "${relpath}"`)
|
||||
@@ -119,15 +133,25 @@ async function recurseFiles(path, relPathToReplace = null) {
|
||||
}
|
||||
|
||||
return true
|
||||
}).map((item) => ({
|
||||
name: item.name,
|
||||
path: item.fullname.replace(relPathToReplace, ''),
|
||||
dirpath: item.path,
|
||||
reldirpath: item.path.replace(relPathToReplace, ''),
|
||||
fullpath: item.fullname,
|
||||
extension: item.extension,
|
||||
deep: item.deep
|
||||
}))
|
||||
}).filter(item => {
|
||||
// Filter out items in ignore directories
|
||||
if (directoriesToIgnore.includes(Path.dirname(item.fullname))) {
|
||||
Logger.debug(`[fileUtils] Ignoring path in dir with .ignore "${item.fullname}"`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}).map((item) => {
|
||||
var isInRoot = (item.path + '/' === relPathToReplace)
|
||||
return {
|
||||
name: item.name,
|
||||
path: item.fullname.replace(relPathToReplace, ''),
|
||||
dirpath: item.path,
|
||||
reldirpath: isInRoot ? '' : item.path.replace(relPathToReplace, ''),
|
||||
fullpath: item.fullname,
|
||||
extension: item.extension,
|
||||
deep: item.deep
|
||||
}
|
||||
})
|
||||
|
||||
// Sort from least deep to most
|
||||
list.sort((a, b) => a.deep - b.deep)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { sort, createNewSortInstance } = require('fast-sort')
|
||||
const Logger = require('../Logger')
|
||||
const naturalSort = createNewSortInstance({
|
||||
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
||||
})
|
||||
@@ -19,13 +20,13 @@ module.exports = {
|
||||
if (group === 'genres') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.genres.includes(filter))
|
||||
else if (group === 'tags') filtered = filtered.filter(li => li.media.tags.includes(filter))
|
||||
else if (group === 'series') {
|
||||
if (filter === 'No Series') filtered = filtered.filter(li => li.media.metadata && !li.media.metadata.series.length)
|
||||
if (filter === 'No Series') filtered = filtered.filter(li => li.mediaType === 'book' && !li.media.metadata.series.length)
|
||||
else {
|
||||
filtered = filtered.filter(li => li.media.metadata && li.media.metadata.hasSeries(filter))
|
||||
filtered = filtered.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(filter))
|
||||
}
|
||||
}
|
||||
else if (group === 'authors') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.hasAuthor(filter))
|
||||
else if (group === 'narrators') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.hasNarrator(filter))
|
||||
else if (group === 'authors') filtered = filtered.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(filter))
|
||||
else if (group === 'narrators') filtered = filtered.filter(li => li.mediaType === 'book' && li.media.metadata.hasNarrator(filter))
|
||||
else if (group === 'progress') {
|
||||
filtered = filtered.filter(li => {
|
||||
var itemProgress = user.getMediaProgress(li.id)
|
||||
@@ -36,29 +37,28 @@ module.exports = {
|
||||
})
|
||||
} else if (group == 'missing') {
|
||||
filtered = filtered.filter(li => {
|
||||
if (filter === 'ASIN' && li.media.metadata.asin === null) return true;
|
||||
if (filter === 'ISBN' && li.media.metadata.isbn === null) return true;
|
||||
if (filter === 'Subtitle' && li.media.metadata.subtitle === null) return true;
|
||||
if (filter === 'Author' && li.media.metadata.authors.length === 0) return true;
|
||||
if (filter === 'Publish Year' && li.media.metadata.publishedYear === null) return true;
|
||||
if (filter === 'Series' && li.media.metadata.series.length === 0) return true;
|
||||
if (filter === 'Volume Number' && (li.media.metadata.series.length === 0 || li.media.metadata.series[0].sequence === null)) return true;
|
||||
if (filter === 'Description' && li.media.metadata.description === null) return true;
|
||||
if (filter === 'Genres' && li.media.metadata.genres.length === 0) return true;
|
||||
if (filter === 'Tags' && li.media.tags.length === 0) return true;
|
||||
if (filter === 'Narrator' && li.media.metadata.narrators.length === 0) return true;
|
||||
if (filter === 'Publisher' && li.media.metadata.publisher === null) return true;
|
||||
if (filter === 'Language' && li.media.metadata.language === null) return true;
|
||||
if (li.mediaType === 'book') {
|
||||
if (filter === 'ASIN' && li.media.metadata.asin === null) return true;
|
||||
if (filter === 'ISBN' && li.media.metadata.isbn === null) return true;
|
||||
if (filter === 'Subtitle' && li.media.metadata.subtitle === null) return true;
|
||||
if (filter === 'Author' && li.media.metadata.authors.length === 0) return true;
|
||||
if (filter === 'Publish Year' && li.media.metadata.publishedYear === null) return true;
|
||||
if (filter === 'Series' && li.media.metadata.series.length === 0) return true;
|
||||
if (filter === 'Description' && li.media.metadata.description === null) return true;
|
||||
if (filter === 'Genres' && li.media.metadata.genres.length === 0) return true;
|
||||
if (filter === 'Tags' && li.media.tags.length === 0) return true;
|
||||
if (filter === 'Narrator' && li.media.metadata.narrators.length === 0) return true;
|
||||
if (filter === 'Publisher' && li.media.metadata.publisher === null) return true;
|
||||
if (filter === 'Language' && li.media.metadata.language === null) return true;
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
})
|
||||
} else if (group === 'languages') {
|
||||
filtered = filtered.filter(li => li.media.metadata && li.media.metadata.language === filter)
|
||||
}
|
||||
} else if (filterBy === 'issues') {
|
||||
filtered = filtered.filter(ab => {
|
||||
// TODO: Update filter for issues
|
||||
return ab.isMissing
|
||||
// return ab.numMissingParts || ab.numInvalidParts || ab.isMissing || ab.isInvalid
|
||||
})
|
||||
filtered = filtered.filter(li => li.hasIssues)
|
||||
}
|
||||
|
||||
return filtered
|
||||
@@ -102,10 +102,10 @@ module.exports = {
|
||||
}
|
||||
if (mediaMetadata.language && !data.languages.includes(mediaMetadata.language)) data.languages.push(mediaMetadata.language)
|
||||
})
|
||||
data.authors = naturalSort(data.authors).asc()
|
||||
data.authors = naturalSort(data.authors).asc(au => au.name)
|
||||
data.genres = naturalSort(data.genres).asc()
|
||||
data.tags = naturalSort(data.tags).asc()
|
||||
data.series = naturalSort(data.series).asc()
|
||||
data.series = naturalSort(data.series).asc(se => se.name)
|
||||
data.narrators = naturalSort(data.narrators).asc()
|
||||
data.languages = naturalSort(data.languages).asc()
|
||||
return data
|
||||
@@ -136,60 +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()
|
||||
})
|
||||
},
|
||||
|
||||
getItemsWithUserProgress(user, libraryItems) {
|
||||
return libraryItems.map(li => {
|
||||
var itemProgress = user.getMediaProgress(li.id)
|
||||
return {
|
||||
userProgress: itemProgress ? itemProgress.toJSON() : null,
|
||||
libraryItem: li
|
||||
}
|
||||
}).filter(b => !!b.userProgress)
|
||||
},
|
||||
|
||||
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 => minified ? b.libraryItem.toJSONMinified() : b.libraryItem.toJSONExpanded()).slice(0, limit)
|
||||
},
|
||||
|
||||
getBooksNextInSeries(seriesWithUserAb, limit, minified = false) {
|
||||
var incompleteSeires = seriesWithUserAb.filter((series) => series.books.some((book) => !book.userAudiobook || (!book.userAudiobook.isRead && book.userAudiobook.progress == 0)))
|
||||
var booksNextInSeries = []
|
||||
@@ -202,27 +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)
|
||||
},
|
||||
|
||||
getItemsMostRecentlyAdded(libraryItems, limit, minified = false) {
|
||||
var itemsSortedByAddedAt = sort(libraryItems).desc(li => li.addedAt)
|
||||
return itemsSortedByAddedAt.map(b => minified ? b.toJSONMinified() : b.toJSONExpanded()).slice(0, limit)
|
||||
},
|
||||
|
||||
getItemsMostRecentlyFinished(itemsWithUserProgress, limit, minified = false) {
|
||||
var itemsFinished = itemsWithUserProgress.filter((data) => data.userProgress && data.userProgress.isFinished)
|
||||
itemsFinished.sort((a, b) => {
|
||||
return b.userProgress.finishedAt - a.userProgress.finishedAt
|
||||
})
|
||||
return itemsFinished.map(i => minified ? i.libraryItem.toJSONMinified() : i.libraryItem.toJSONExpanded()).slice(0, limit)
|
||||
},
|
||||
|
||||
getSeriesMostRecentlyAdded(series, limit) {
|
||||
var seriesSortedByAddedAt = sort(series).desc(_series => {
|
||||
var booksSortedByMostRecent = sort(_series.books).desc(b => b.addedAt)
|
||||
return booksSortedByMostRecent[0].addedAt
|
||||
})
|
||||
return seriesSortedByAddedAt.slice(0, limit)
|
||||
},
|
||||
|
||||
getGenresWithCount(libraryItems) {
|
||||
var genresMap = {}
|
||||
libraryItems.forEach((li) => {
|
||||
@@ -307,5 +232,359 @@ module.exports = {
|
||||
}
|
||||
return libraryItemJson
|
||||
}).filter(li => li)
|
||||
},
|
||||
|
||||
buildPersonalizedShelves(user, libraryItems, mediaType, allSeries, allAuthors, maxEntitiesPerShelf = 10) {
|
||||
const isPodcastLibrary = mediaType === 'podcast'
|
||||
|
||||
const shelves = [
|
||||
{
|
||||
id: 'continue-listening',
|
||||
label: 'Continue Listening',
|
||||
type: isPodcastLibrary ? 'episode' : mediaType,
|
||||
entities: [],
|
||||
category: 'recentlyListened'
|
||||
},
|
||||
{
|
||||
id: 'continue-series',
|
||||
label: 'Continue Series',
|
||||
type: mediaType,
|
||||
entities: [],
|
||||
category: 'continueSeries'
|
||||
},
|
||||
{
|
||||
id: 'recently-added',
|
||||
label: 'Recently Added',
|
||||
type: mediaType,
|
||||
entities: [],
|
||||
category: 'newestItems'
|
||||
},
|
||||
{
|
||||
id: 'listen-again',
|
||||
label: 'Listen Again',
|
||||
type: isPodcastLibrary ? 'episode' : mediaType,
|
||||
entities: [],
|
||||
category: 'recentlyFinished'
|
||||
},
|
||||
{
|
||||
id: 'recent-series',
|
||||
label: 'Recent Series',
|
||||
type: 'series',
|
||||
entities: [],
|
||||
category: 'newestSeries'
|
||||
},
|
||||
{
|
||||
id: 'newest-authors',
|
||||
label: 'Newest Authors',
|
||||
type: 'authors',
|
||||
entities: [],
|
||||
category: 'newestAuthors'
|
||||
},
|
||||
{
|
||||
id: 'episodes-recently-added',
|
||||
label: 'Newest Episodes',
|
||||
type: 'episode',
|
||||
entities: [],
|
||||
category: 'newestEpisodes'
|
||||
}
|
||||
]
|
||||
|
||||
const categories = ['recentlyListened', 'continueSeries', 'newestEpisodes', 'newestItems', 'newestSeries', 'recentlyFinished', 'newestAuthors']
|
||||
const categoryMap = {}
|
||||
categories.forEach((cat) => {
|
||||
categoryMap[cat] = {
|
||||
category: cat,
|
||||
biggest: 0,
|
||||
smallest: 0,
|
||||
items: []
|
||||
}
|
||||
})
|
||||
|
||||
const seriesMap = {}
|
||||
const authorMap = {}
|
||||
|
||||
for (const libraryItem of libraryItems) {
|
||||
if (libraryItem.addedAt > categoryMap.newestItems.smallest) {
|
||||
|
||||
var indexToPut = categoryMap.newestItems.items.findIndex(i => libraryItem.addedAt > i.addedAt)
|
||||
if (indexToPut >= 0) {
|
||||
categoryMap.newestItems.items.splice(indexToPut, 0, libraryItem.toJSONMinified())
|
||||
} else {
|
||||
categoryMap.newestItems.items.push(libraryItem.toJSONMinified())
|
||||
}
|
||||
|
||||
if (categoryMap.newestItems.items.length > maxEntitiesPerShelf) {
|
||||
// Remove last item
|
||||
categoryMap.newestItems.items.pop()
|
||||
categoryMap.newestItems.smallest = categoryMap.newestItems.items[categoryMap.newestItems.items.length - 1].addedAt
|
||||
}
|
||||
categoryMap.newestItems.biggest = categoryMap.newestItems.items[0].addedAt
|
||||
}
|
||||
|
||||
var allItemProgress = user.getAllMediaProgressForLibraryItem(libraryItem.id)
|
||||
if (libraryItem.isPodcast) {
|
||||
// Podcast categories
|
||||
const podcastEpisodes = libraryItem.media.episodes || []
|
||||
for (const episode of podcastEpisodes) {
|
||||
// Newest episodes
|
||||
if (episode.addedAt > categoryMap.newestEpisodes.smallest) {
|
||||
const libraryItemWithEpisode = {
|
||||
...libraryItem.toJSONMinified(),
|
||||
recentEpisode: episode.toJSON()
|
||||
}
|
||||
|
||||
var indexToPut = categoryMap.newestEpisodes.items.findIndex(i => episode.addedAt > i.recentEpisode.addedAt)
|
||||
if (indexToPut >= 0) {
|
||||
categoryMap.newestEpisodes.items.splice(indexToPut, 0, libraryItemWithEpisode)
|
||||
} else {
|
||||
categoryMap.newestEpisodes.items.push(libraryItemWithEpisode)
|
||||
}
|
||||
|
||||
if (categoryMap.newestEpisodes.items.length > maxEntitiesPerShelf) {
|
||||
// Remove last item
|
||||
categoryMap.newestEpisodes.items.pop()
|
||||
categoryMap.newestEpisodes.smallest = categoryMap.newestEpisodes.items[categoryMap.newestEpisodes.items.length - 1].recentEpisode.addedAt
|
||||
}
|
||||
categoryMap.newestEpisodes.biggest = categoryMap.newestEpisodes.items[0].recentEpisode.addedAt
|
||||
}
|
||||
|
||||
// Episode recently listened and finished
|
||||
var mediaProgress = allItemProgress.find(mp => mp.episodeId === episode.id)
|
||||
if (mediaProgress) {
|
||||
if (mediaProgress.isFinished) {
|
||||
if (mediaProgress.finishedAt > categoryMap.recentlyFinished.smallest) { // Item belongs on shelf
|
||||
const libraryItemWithEpisode = {
|
||||
...libraryItem.toJSONMinified(),
|
||||
recentEpisode: episode.toJSON(),
|
||||
finishedAt: mediaProgress.finishedAt
|
||||
}
|
||||
|
||||
var indexToPut = categoryMap.recentlyFinished.items.findIndex(i => mediaProgress.finishedAt > i.finishedAt)
|
||||
if (indexToPut >= 0) {
|
||||
categoryMap.recentlyFinished.items.splice(indexToPut, 0, libraryItemWithEpisode)
|
||||
} else {
|
||||
categoryMap.recentlyFinished.items.push(libraryItemWithEpisode)
|
||||
}
|
||||
|
||||
if (categoryMap.recentlyFinished.items.length > maxEntitiesPerShelf) {
|
||||
// Remove last item
|
||||
categoryMap.recentlyFinished.items.pop()
|
||||
categoryMap.recentlyFinished.smallest = categoryMap.recentlyFinished.items[categoryMap.recentlyFinished.items.length - 1].finishedAt
|
||||
}
|
||||
categoryMap.recentlyFinished.biggest = categoryMap.recentlyFinished.items[0].finishedAt
|
||||
}
|
||||
} else if (mediaProgress.progress > 0) { // Handle most recently listened
|
||||
if (mediaProgress.lastUpdate > categoryMap.recentlyListened.smallest) { // Item belongs on shelf
|
||||
const libraryItemWithEpisode = {
|
||||
...libraryItem.toJSONMinified(),
|
||||
recentEpisode: episode.toJSON(),
|
||||
progressLastUpdate: mediaProgress.lastUpdate
|
||||
}
|
||||
|
||||
var indexToPut = categoryMap.recentlyListened.items.findIndex(i => mediaProgress.lastUpdate > i.progressLastUpdate)
|
||||
if (indexToPut >= 0) {
|
||||
categoryMap.recentlyListened.items.splice(indexToPut, 0, libraryItemWithEpisode)
|
||||
} else {
|
||||
categoryMap.recentlyListened.items.push(libraryItemWithEpisode)
|
||||
}
|
||||
|
||||
if (categoryMap.recentlyListened.items.length > maxEntitiesPerShelf) {
|
||||
// Remove last item
|
||||
categoryMap.recentlyListened.items.pop()
|
||||
categoryMap.recentlyListened.smallest = categoryMap.recentlyListened.items[categoryMap.recentlyListened.items.length - 1].progressLastUpdate
|
||||
}
|
||||
|
||||
categoryMap.recentlyListened.biggest = categoryMap.recentlyListened.items[0].progressLastUpdate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Book categories
|
||||
|
||||
// Newest series
|
||||
if (libraryItem.media.metadata.series.length) {
|
||||
for (const librarySeries of libraryItem.media.metadata.series) {
|
||||
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]) {
|
||||
const seriesObj = allSeries.find(se => se.id === librarySeries.id)
|
||||
if (seriesObj) {
|
||||
var series = {
|
||||
...seriesObj.toJSON(),
|
||||
books: [libraryItemJson],
|
||||
inProgress: bookInProgress,
|
||||
bookInProgressLastUpdate: bookInProgress ? mediaProgress.lastUpdate : null,
|
||||
sequenceInProgress: bookInProgress ? libraryItemJson.seriesSequence : null
|
||||
}
|
||||
seriesMap[librarySeries.id] = series
|
||||
|
||||
if (series.addedAt > categoryMap.newestSeries.smallest) {
|
||||
var indexToPut = categoryMap.newestSeries.items.findIndex(i => series.addedAt > i.addedAt)
|
||||
if (indexToPut >= 0) {
|
||||
categoryMap.newestSeries.items.splice(indexToPut, 0, series)
|
||||
} else {
|
||||
categoryMap.newestSeries.items.push(series)
|
||||
}
|
||||
|
||||
// Max series is 5
|
||||
if (categoryMap.newestSeries.items.length > 5) {
|
||||
categoryMap.newestSeries.items.pop()
|
||||
categoryMap.newestSeries.smallest = categoryMap.newestSeries.items[categoryMap.newestSeries.items.length - 1].addedAt
|
||||
}
|
||||
|
||||
categoryMap.newestSeries.biggest = categoryMap.newestSeries.items[0].addedAt
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// series already in map - add book
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Newest authors
|
||||
if (libraryItem.media.metadata.authors.length) {
|
||||
for (const libraryAuthor of libraryItem.media.metadata.authors) {
|
||||
if (!authorMap[libraryAuthor.id]) {
|
||||
const authorObj = allAuthors.find(au => au.id === libraryAuthor.id)
|
||||
if (authorObj) {
|
||||
var author = {
|
||||
...authorObj.toJSON(),
|
||||
numBooks: 1
|
||||
}
|
||||
|
||||
if (author.addedAt > categoryMap.newestAuthors.smallest) {
|
||||
|
||||
var indexToPut = categoryMap.newestAuthors.items.findIndex(i => author.addedAt > i.addedAt)
|
||||
if (indexToPut >= 0) {
|
||||
categoryMap.newestAuthors.items.splice(indexToPut, 0, author)
|
||||
} else {
|
||||
categoryMap.newestAuthors.items.push(author)
|
||||
}
|
||||
|
||||
// Max authors is 10
|
||||
if (categoryMap.newestAuthors.items.length > 10) {
|
||||
categoryMap.newestAuthors.items.pop()
|
||||
categoryMap.newestAuthors.smallest = categoryMap.newestAuthors.items[categoryMap.newestAuthors.items.length - 1].addedAt
|
||||
}
|
||||
|
||||
categoryMap.newestAuthors.biggest = categoryMap.newestAuthors.items[0].addedAt
|
||||
}
|
||||
|
||||
authorMap[libraryAuthor.id] = author
|
||||
}
|
||||
} else {
|
||||
authorMap[libraryAuthor.id].numBooks++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Book listening and finished
|
||||
var mediaProgress = allItemProgress.length ? allItemProgress[0] : null
|
||||
if (mediaProgress) {
|
||||
// Handle most recently finished
|
||||
if (mediaProgress.isFinished) {
|
||||
if (mediaProgress.finishedAt > categoryMap.recentlyFinished.smallest) { // Item belongs on shelf
|
||||
const libraryItemObj = {
|
||||
...libraryItem.toJSONMinified(),
|
||||
finishedAt: mediaProgress.finishedAt
|
||||
}
|
||||
|
||||
var indexToPut = categoryMap.recentlyFinished.items.findIndex(i => mediaProgress.finishedAt > i.finishedAt)
|
||||
if (indexToPut >= 0) {
|
||||
categoryMap.recentlyFinished.items.splice(indexToPut, 0, libraryItemObj)
|
||||
} else {
|
||||
categoryMap.recentlyFinished.items.push(libraryItemObj)
|
||||
}
|
||||
if (categoryMap.recentlyFinished.items.length > maxEntitiesPerShelf) {
|
||||
// Remove last item
|
||||
categoryMap.recentlyFinished.items.pop()
|
||||
categoryMap.recentlyFinished.smallest = categoryMap.recentlyFinished.items[categoryMap.recentlyFinished.items.length - 1].finishedAt
|
||||
}
|
||||
categoryMap.recentlyFinished.biggest = categoryMap.recentlyFinished.items[0].finishedAt
|
||||
}
|
||||
} else if (mediaProgress.inProgress) { // Handle most recently listened
|
||||
if (mediaProgress.lastUpdate > categoryMap.recentlyListened.smallest) { // Item belongs on shelf
|
||||
const libraryItemObj = {
|
||||
...libraryItem.toJSONMinified(),
|
||||
progressLastUpdate: mediaProgress.lastUpdate
|
||||
}
|
||||
|
||||
var indexToPut = categoryMap.recentlyListened.items.findIndex(i => mediaProgress.lastUpdate > i.progressLastUpdate)
|
||||
if (indexToPut >= 0) {
|
||||
categoryMap.recentlyListened.items.splice(indexToPut, 0, libraryItemObj)
|
||||
} else { // Should only happen when array is < max
|
||||
categoryMap.recentlyListened.items.push(libraryItemObj)
|
||||
}
|
||||
if (categoryMap.recentlyListened.items.length > maxEntitiesPerShelf) {
|
||||
// Remove last item
|
||||
categoryMap.recentlyListened.items.pop()
|
||||
categoryMap.recentlyListened.smallest = categoryMap.recentlyListened.items[categoryMap.recentlyListened.items.length - 1].progressLastUpdate
|
||||
}
|
||||
categoryMap.recentlyListened.biggest = categoryMap.recentlyListened.items[0].progressLastUpdate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For Continue Series - Find next book in series for series that are in progress
|
||||
for (const seriesId in seriesMap) {
|
||||
if (seriesMap[seriesId].inProgress) {
|
||||
seriesMap[seriesId].books = naturalSort(seriesMap[seriesId].books).asc(li => li.seriesSequence)
|
||||
|
||||
const nextBookInSeries = seriesMap[seriesId].books.find(li => {
|
||||
if (!seriesMap[seriesId].sequenceInProgress) return true
|
||||
// True if book series sequence is greater than the current book sequence in progress
|
||||
return String(li.seriesSequence).localeCompare(String(seriesMap[seriesId].sequenceInProgress), undefined, { sensitivity: 'base', numeric: true }) > 0
|
||||
})
|
||||
|
||||
if (nextBookInSeries) {
|
||||
const bookForContinueSeries = {
|
||||
...nextBookInSeries,
|
||||
prevBookInProgressLastUpdate: seriesMap[seriesId].bookInProgressLastUpdate
|
||||
}
|
||||
bookForContinueSeries.media.metadata.series = {
|
||||
id: seriesId,
|
||||
name: seriesMap[seriesId].name,
|
||||
sequence: nextBookInSeries.seriesSequence
|
||||
}
|
||||
|
||||
const indexToPut = categoryMap.continueSeries.items.findIndex(i => i.prevBookInProgressLastUpdate < bookForContinueSeries.prevBookInProgressLastUpdate)
|
||||
if (indexToPut >= 0) {
|
||||
categoryMap.continueSeries.items.splice(indexToPut, 0, bookForContinueSeries)
|
||||
} else if (categoryMap.continueSeries.items.length < 10) { // Max 10 books
|
||||
categoryMap.continueSeries.items.push(bookForContinueSeries)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort series books by sequence
|
||||
if (categoryMap.newestSeries.items.length) {
|
||||
for (const seriesItem of categoryMap.newestSeries.items) {
|
||||
seriesItem.books = naturalSort(seriesItem.books).asc(li => li.seriesSequence)
|
||||
}
|
||||
}
|
||||
|
||||
var categoriesWithItems = Object.values(categoryMap).filter(cat => cat.items.length)
|
||||
|
||||
return categoriesWithItems.map(cat => {
|
||||
var shelf = shelves.find(s => s.category === cat.category)
|
||||
shelf.entities = cat.items
|
||||
return shelf
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,9 @@ module.exports.parse = (nameString) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out names that have no first and last
|
||||
names = names.filter(n => n.first_name || n.last_name)
|
||||
|
||||
var namesArray = names.map(a => a.first_name ? `${a.first_name} ${a.last_name}` : a.last_name)
|
||||
var firstLast = names.length ? namesArray.join(', ') : ''
|
||||
var lastFirst = names.length ? names.map(a => a.first_name ? `${a.last_name}, ${a.first_name}` : a.last_name).join(', ') : ''
|
||||
|
||||
@@ -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']
|
||||
keysToLookOutFor.forEach((key) => {
|
||||
if (tags[key]) {
|
||||
Logger.debug(`Notable! ${key} => ${tags[key]}`)
|
||||
}
|
||||
})
|
||||
// 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) => {
|
||||
// if (tags[key]) {
|
||||
// Logger.debug(`Notable! ${key} => ${tags[key]}`)
|
||||
// }
|
||||
// })
|
||||
return tags
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ const { recurseFiles, getFileTimestampsWithIno } = require('./fileUtils')
|
||||
const globals = require('./globals')
|
||||
const LibraryFile = require('../objects/files/LibraryFile')
|
||||
|
||||
function isMediaFile(mediaType, path) {
|
||||
if (!path) return false
|
||||
var ext = Path.extname(path)
|
||||
function isMediaFile(mediaType, ext) {
|
||||
// if (!path) return false
|
||||
// var ext = Path.extname(path)
|
||||
if (!ext) return false
|
||||
var extclean = ext.slice(1).toLowerCase()
|
||||
if (mediaType === 'podcast') return globals.SupportedAudioTypes.includes(extclean)
|
||||
@@ -62,40 +62,47 @@ module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths
|
||||
// Input: array of relative file items (see recurseFiles)
|
||||
// Output: map of files grouped into potential libarary item dirs
|
||||
function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
|
||||
// Step 1: Filter out files in root dir (with depth of 0)
|
||||
var itemsFiltered = fileItems.filter(i => i.deep > 0)
|
||||
// Step 1: Filter out non-media files in root dir (with depth of 0)
|
||||
var itemsFiltered = fileItems.filter(i => {
|
||||
return i.deep > 0 || isMediaFile(mediaType, i.extension)
|
||||
})
|
||||
|
||||
// Step 2: Seperate media files and other files
|
||||
// - Directories without a media file will not be included
|
||||
var mediaFileItems = []
|
||||
var otherFileItems = []
|
||||
itemsFiltered.forEach(item => {
|
||||
if (isMediaFile(mediaType, item.fullpath)) mediaFileItems.push(item)
|
||||
if (isMediaFile(mediaType, item.extension)) mediaFileItems.push(item)
|
||||
else otherFileItems.push(item)
|
||||
})
|
||||
|
||||
// Step 3: Group audio files in library items
|
||||
var libraryItemGroup = {}
|
||||
mediaFileItems.forEach((item) => {
|
||||
var dirparts = item.reldirpath.split('/')
|
||||
var dirparts = item.reldirpath.split('/').filter(p => !!p)
|
||||
var numparts = dirparts.length
|
||||
var _path = ''
|
||||
|
||||
// Iterate over directories in path
|
||||
for (let i = 0; i < numparts; i++) {
|
||||
var dirpart = dirparts.shift()
|
||||
_path = Path.posix.join(_path, dirpart)
|
||||
if (!dirparts.length) {
|
||||
// Media file in root
|
||||
libraryItemGroup[item.name] = item.name
|
||||
} else {
|
||||
// Iterate over directories in path
|
||||
for (let i = 0; i < numparts; i++) {
|
||||
var dirpart = dirparts.shift()
|
||||
_path = Path.posix.join(_path, dirpart)
|
||||
|
||||
if (libraryItemGroup[_path]) { // Directory already has files, add file
|
||||
var relpath = Path.posix.join(dirparts.join('/'), item.name)
|
||||
libraryItemGroup[_path].push(relpath)
|
||||
return
|
||||
} else if (!dirparts.length) { // This is the last directory, create group
|
||||
libraryItemGroup[_path] = [item.name]
|
||||
return
|
||||
} else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group
|
||||
libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)]
|
||||
return
|
||||
if (libraryItemGroup[_path]) { // Directory already has files, add file
|
||||
var relpath = Path.posix.join(dirparts.join('/'), item.name)
|
||||
libraryItemGroup[_path].push(relpath)
|
||||
return
|
||||
} else if (!dirparts.length) { // This is the last directory, create group
|
||||
libraryItemGroup[_path] = [item.name]
|
||||
return
|
||||
} else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group
|
||||
libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)]
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -140,19 +147,44 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) {
|
||||
}
|
||||
|
||||
var fileItems = await recurseFiles(folderPath)
|
||||
var basePath = folderPath
|
||||
|
||||
const isOpenAudibleFolder = fileItems.find(fi => fi.deep === 0 && fi.name === 'books.json')
|
||||
if (isOpenAudibleFolder) {
|
||||
Logger.info(`[scandir] Detected Open Audible Folder, looking in books folder`)
|
||||
basePath = Path.posix.join(folderPath, 'books')
|
||||
fileItems = await recurseFiles(basePath)
|
||||
Logger.debug(`[scandir] ${fileItems.length} files found in books folder`)
|
||||
}
|
||||
|
||||
var libraryItemGrouping = groupFileItemsIntoLibraryItemDirs(libraryMediaType, fileItems)
|
||||
|
||||
if (!Object.keys(libraryItemGrouping).length) {
|
||||
Logger.error('Root path has no media folders', fileItems.length)
|
||||
Logger.error(`Root path has no media folders: ${folderPath}`)
|
||||
return []
|
||||
}
|
||||
|
||||
var isFile = false // item is not in a folder
|
||||
var items = []
|
||||
for (const libraryItemPath in libraryItemGrouping) {
|
||||
var libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath, serverSettings)
|
||||
var libraryItemData = null
|
||||
var fileObjs = []
|
||||
if (libraryItemPath === libraryItemGrouping[libraryItemPath]) {
|
||||
// Media file in root only get title
|
||||
libraryItemData = {
|
||||
mediaMetadata: {
|
||||
title: Path.basename(libraryItemPath, Path.extname(libraryItemPath))
|
||||
},
|
||||
path: Path.posix.join(basePath, libraryItemPath),
|
||||
relPath: libraryItemPath
|
||||
}
|
||||
fileObjs = await cleanFileObjects(basePath, [libraryItemPath])
|
||||
isFile = true
|
||||
} else {
|
||||
libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath, serverSettings)
|
||||
fileObjs = await cleanFileObjects(libraryItemData.path, libraryItemGrouping[libraryItemPath])
|
||||
}
|
||||
|
||||
var fileObjs = await cleanFileObjects(libraryItemData.path, libraryItemGrouping[libraryItemPath])
|
||||
var libraryItemFolderStats = await getFileTimestampsWithIno(libraryItemData.path)
|
||||
items.push({
|
||||
folderId: folder.id,
|
||||
@@ -163,6 +195,7 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) {
|
||||
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
|
||||
path: libraryItemData.path,
|
||||
relPath: libraryItemData.relPath,
|
||||
isFile,
|
||||
media: {
|
||||
metadata: libraryItemData.mediaMetadata || null
|
||||
},
|
||||
@@ -242,7 +275,6 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Subtitle can be parsed from the title if user enabled
|
||||
// Subtitle is everything after " - "
|
||||
var subtitle = null
|
||||
@@ -290,7 +322,7 @@ function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettin
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Called from Scanner.js
|
||||
async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, serverSettings = {}) {
|
||||
var fileItems = await recurseFiles(libraryItemPath)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user