Compare commits

..

21 Commits

Author SHA1 Message Date
advplyr 2afd0e2acd Update dbMigration for old main library ids 2023-07-16 16:39:59 -05:00
advplyr 0829237166 Fix:Libraries out of order #1911 2023-07-16 15:43:46 -05:00
advplyr 541975f038 Version bump 2.3.1 2023-07-16 15:34:35 -05:00
advplyr 01bf58ab97 Fix createAuthor 2023-07-16 15:29:43 -05:00
advplyr d99b2c25e8 Fixes for db migration & local playback sessions 2023-07-16 15:05:51 -05:00
advplyr 63e5cf2e60 Fix:Accessing series page for some users #787 2023-07-16 08:39:08 -05:00
advplyr 7beca048e7 Version bump v2.3.0 2023-07-15 15:29:25 -05:00
advplyr ec998dc1ac Update:Podcast library item covers show number of episodes incomplete #782 2023-07-15 14:45:08 -05:00
advplyr ddc54c8811 Update:Downloading library item shows log on the server with username #1461 2023-07-15 13:39:12 -05:00
advplyr 72e306935f Update:Support and as separator between multiple authors #1790 2023-07-15 13:28:31 -05:00
advplyr 96a7c7f4d1 Fix:Embedded chapters with invalid IDs, update chapter ids to always be the index #1783 2023-07-15 12:46:51 -05:00
advplyr 9c65d655b8 Fix:Realtime update cover on cover tab in item edit modal 2023-07-15 12:37:33 -05:00
advplyr b108f2241b Add:Library filter for publishers & link to publisher filter on book page #1813 2023-07-15 12:22:13 -05:00
advplyr 9439acf300 Merge pull request #1906 from warnwar/master
stop opf importer from adding duplicate info
2023-07-15 11:44:41 -05:00
advplyr d181e66d83 Update server/utils/parsers/parseOpfMetadata.js 2023-07-15 11:41:44 -05:00
advplyr a87c3f2c77 Update server/utils/parsers/parseOpfMetadata.js 2023-07-15 11:41:40 -05:00
advplyr 2834f6077e Update server/utils/parsers/parseOpfMetadata.js 2023-07-15 11:41:35 -05:00
advplyr 918013ccb3 Add:Option on podcast page to mark all episodes as finished/unfinished #1862 2023-07-15 11:27:06 -05:00
advplyr 4c4672c6c1 Update:Item page UI for details that take up multiple lines 2023-07-15 11:00:07 -05:00
advplyr b3991574c7 Merge pull request #1907 from advplyr/sqlite_2
Migration to use sqlite3
2023-07-14 15:11:23 -05:00
WarWar 47b9ee557e stop opf importer from adding duplicate info 2023-07-14 05:15:29 +00:00
47 changed files with 996 additions and 215 deletions
@@ -168,7 +168,7 @@ export default {
}, },
async fetchCategories() { async fetchCategories() {
const categories = await this.$axios const categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed`) .$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed,numEpisodesIncomplete`)
.then((data) => { .then((data) => {
return data return data
}) })
+2 -2
View File
@@ -315,10 +315,10 @@ export default {
const entityPath = this.entityName === 'series-books' ? 'items' : this.entityName const entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : '' const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed` const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete`
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => { const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
console.error('failed to fetch books', error) console.error('failed to fetch items', error)
return null return null
}) })
+9 -2
View File
@@ -116,9 +116,14 @@
</div> </div>
<!-- Podcast Num Episodes --> <!-- Podcast Num Episodes -->
<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' }"> <div v-else-if="!numEpisodesIncomplete && 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> <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
</div> </div>
<!-- Podcast Num Episodes -->
<div v-else-if="numEpisodesIncomplete && !isHovering && !isSelectionMode" class="absolute rounded-full bg-yellow-400 text-black font-semibold 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' }">{{ numEpisodesIncomplete }}</p>
</div>
</div> </div>
</template> </template>
@@ -227,9 +232,11 @@ export default {
return this.media.numTracks || 0 // toJSONMinified return this.media.numTracks || 0 // toJSONMinified
}, },
numEpisodes() { numEpisodes() {
if (!this.isPodcast) return 0
return this.media.numEpisodes || 0 return this.media.numEpisodes || 0
}, },
numEpisodesIncomplete() {
return this._libraryItem.numEpisodesIncomplete || 0
},
processingBatch() { processingBatch() {
return this.store.state.processingBatch return this.store.state.processingBatch
}, },
@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<div v-if="narrators?.length" class="flex py-0.5 mt-4"> <div v-if="narrators?.length" class="flex py-0.5 mt-4">
<div class="w-32"> <div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNarrators }}</span> <span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNarrators }}</span>
</div> </div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis"> <div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
@@ -12,7 +12,7 @@
</div> </div>
</div> </div>
<div v-if="publishedYear" class="flex py-0.5"> <div v-if="publishedYear" class="flex py-0.5">
<div class="w-32"> <div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublishYear }}</span> <span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublishYear }}</span>
</div> </div>
<div> <div>
@@ -20,15 +20,15 @@
</div> </div>
</div> </div>
<div v-if="publisher" class="flex py-0.5"> <div v-if="publisher" class="flex py-0.5">
<div class="w-32"> <div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublisher }}</span> <span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublisher }}</span>
</div> </div>
<div> <div>
{{ publisher }} <nuxt-link :to="`/library/${libraryId}/bookshelf?filter=publishers.${$encode(publisher)}`" class="hover:underline">{{ publisher }}</nuxt-link>
</div> </div>
</div> </div>
<div v-if="musicAlbum" class="flex py-0.5"> <div v-if="musicAlbum" class="flex py-0.5">
<div class="w-32"> <div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Album</span> <span class="text-white text-opacity-60 uppercase text-sm">Album</span>
</div> </div>
<div> <div>
@@ -36,7 +36,7 @@
</div> </div>
</div> </div>
<div v-if="musicAlbumArtist" class="flex py-0.5"> <div v-if="musicAlbumArtist" class="flex py-0.5">
<div class="w-32"> <div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span> <span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span>
</div> </div>
<div> <div>
@@ -44,7 +44,7 @@
</div> </div>
</div> </div>
<div v-if="musicTrackPretty" class="flex py-0.5"> <div v-if="musicTrackPretty" class="flex py-0.5">
<div class="w-32"> <div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Track</span> <span class="text-white text-opacity-60 uppercase text-sm">Track</span>
</div> </div>
<div> <div>
@@ -52,7 +52,7 @@
</div> </div>
</div> </div>
<div v-if="musicDiscPretty" class="flex py-0.5"> <div v-if="musicDiscPretty" class="flex py-0.5">
<div class="w-32"> <div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Disc</span> <span class="text-white text-opacity-60 uppercase text-sm">Disc</span>
</div> </div>
<div> <div>
@@ -60,7 +60,7 @@
</div> </div>
</div> </div>
<div v-if="podcastType" class="flex py-0.5"> <div v-if="podcastType" class="flex py-0.5">
<div class="w-32"> <div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPodcastType }}</span> <span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPodcastType }}</span>
</div> </div>
<div class="capitalize"> <div class="capitalize">
@@ -68,7 +68,7 @@
</div> </div>
</div> </div>
<div class="flex py-0.5" v-if="genres.length"> <div class="flex py-0.5" v-if="genres.length">
<div class="w-32"> <div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelGenres }}</span> <span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelGenres }}</span>
</div> </div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis"> <div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
@@ -79,7 +79,7 @@
</div> </div>
</div> </div>
<div class="flex py-0.5" v-if="tags.length"> <div class="flex py-0.5" v-if="tags.length">
<div class="w-32"> <div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelTags }}</span> <span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelTags }}</span>
</div> </div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis"> <div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
@@ -90,7 +90,7 @@
</div> </div>
</div> </div>
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5"> <div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
<div class="w-32"> <div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span> <span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
</div> </div>
<div> <div>
@@ -98,7 +98,7 @@
</div> </div>
</div> </div>
<div class="flex py-0.5"> <div class="flex py-0.5">
<div class="w-32"> <div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelSize }}</span> <span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelSize }}</span>
</div> </div>
<div> <div>
@@ -124,6 +124,11 @@ export default {
value: 'narrators', value: 'narrators',
sublist: true sublist: true
}, },
{
text: this.$strings.LabelPublisher,
value: 'publishers',
sublist: true
},
{ {
text: this.$strings.LabelLanguage, text: this.$strings.LabelLanguage,
value: 'languages', value: 'languages',
@@ -167,6 +172,11 @@ export default {
value: 'narrators', value: 'narrators',
sublist: true sublist: true
}, },
{
text: this.$strings.LabelPublisher,
value: 'publishers',
sublist: true
},
{ {
text: this.$strings.LabelLanguage, text: this.$strings.LabelLanguage,
value: 'languages', value: 'languages',
@@ -313,6 +323,9 @@ export default {
languages() { languages() {
return this.filterData.languages || [] return this.filterData.languages || []
}, },
publishers() {
return this.filterData.publishers || []
},
progress() { progress() {
return [ return [
{ {
+9 -6
View File
@@ -1,8 +1,8 @@
<template> <template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative"> <div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
<div class="flex flex-wrap"> <div class="flex flex-wrap mb-4">
<div class="relative"> <div class="relative">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, libraryItemUpdatedAt, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- book cover overlay --> <!-- book cover overlay -->
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100"> <div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
@@ -139,16 +139,19 @@ export default {
return this.$store.getters['libraries/getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
libraryItemId() { libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null return this.libraryItem?.id || null
},
libraryItemUpdatedAt() {
return this.libraryItem?.updatedAt || null
}, },
mediaType() { mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null return this.libraryItem?.mediaType || null
}, },
isPodcast() { isPodcast() {
return this.mediaType == 'podcast' return this.mediaType == 'podcast'
}, },
media() { media() {
return this.libraryItem ? this.libraryItem.media || {} : {} return this.libraryItem?.media || {}
}, },
coverPath() { coverPath() {
return this.media.coverPath return this.media.coverPath
@@ -157,7 +160,7 @@ export default {
return this.media.metadata || {} return this.media.metadata || {}
}, },
libraryFiles() { libraryFiles() {
return this.libraryItem ? this.libraryItem.libraryFiles || [] : [] return this.libraryItem?.libraryFiles || []
}, },
userCanUpload() { userCanUpload() {
return this.$store.getters['user/getUserCanUpload'] return this.$store.getters['user/getUserCanUpload']
@@ -58,7 +58,6 @@ export default {
selectedEpisodes: [], selectedEpisodes: [],
episodesToRemove: [], episodesToRemove: [],
processing: false, processing: false,
quickMatchingEpisodes: false,
search: null, search: null,
searchTimeout: null, searchTimeout: null,
searchText: null searchText: null
@@ -78,6 +77,10 @@ export default {
{ {
text: 'Quick match all episodes', text: 'Quick match all episodes',
action: 'quick-match-episodes' action: 'quick-match-episodes'
},
{
text: this.allEpisodesFinished ? this.$strings.MessageMarkAllEpisodesNotFinished : this.$strings.MessageMarkAllEpisodesFinished,
action: 'batch-mark-as-finished'
} }
] ]
}, },
@@ -169,9 +172,15 @@ export default {
}, },
selectedIsFinished() { selectedIsFinished() {
// Find an item that is not finished, if none then all items finished // Find an item that is not finished, if none then all items finished
return !this.selectedEpisodes.find((episode) => { return !this.selectedEpisodes.some((episode) => {
var itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id) const itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
return !itemProgress || !itemProgress.isFinished return !itemProgress?.isFinished
})
},
allEpisodesFinished() {
return !this.episodesSorted.some((episode) => {
const itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
return !itemProgress?.isFinished
}) })
}, },
dateFormat() { dateFormat() {
@@ -194,17 +203,34 @@ export default {
}, },
contextMenuAction({ action }) { contextMenuAction({ action }) {
if (action === 'quick-match-episodes') { if (action === 'quick-match-episodes') {
if (this.quickMatchingEpisodes) return if (this.processing) return
this.quickMatchAllEpisodes() this.quickMatchAllEpisodes()
} else if (action === 'batch-mark-as-finished') {
if (this.processing) return
this.markAllEpisodesFinished()
} }
}, },
markAllEpisodesFinished() {
const newIsFinished = !this.allEpisodesFinished
const payload = {
message: newIsFinished ? this.$strings.MessageConfirmMarkAllEpisodesFinished : this.$strings.MessageConfirmMarkAllEpisodesNotFinished,
callback: (confirmed) => {
if (confirmed) {
this.batchUpdateEpisodesFinished(this.episodesSorted, newIsFinished)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
quickMatchAllEpisodes() { quickMatchAllEpisodes() {
if (!this.mediaMetadata.feedUrl) { if (!this.mediaMetadata.feedUrl) {
this.$toast.error(this.$strings.MessagePodcastHasNoRSSFeedForMatching) this.$toast.error(this.$strings.MessagePodcastHasNoRSSFeedForMatching)
return return
} }
this.quickMatchingEpisodes = true this.processing = true
const payload = { const payload = {
message: 'Quick matching episodes will overwrite details if a match is found. Only unmatched episodes will be updated. Are you sure?', message: 'Quick matching episodes will overwrite details if a match is found. Only unmatched episodes will be updated. Are you sure?',
@@ -224,7 +250,7 @@ export default {
this.$toast.error('Failed to match episodes') this.$toast.error('Failed to match episodes')
}) })
} }
this.quickMatchingEpisodes = false this.processing = false
}, },
type: 'yesNo' type: 'yesNo'
} }
@@ -248,17 +274,19 @@ export default {
this.$store.commit('addItemToQueue', queueItem) this.$store.commit('addItemToQueue', queueItem)
}, },
toggleBatchFinished() { toggleBatchFinished() {
this.batchUpdateEpisodesFinished(this.selectedEpisodes, !this.selectedIsFinished)
},
batchUpdateEpisodesFinished(episodes, newIsFinished) {
this.processing = true this.processing = true
var newIsFinished = !this.selectedIsFinished
var updateProgressPayloads = this.selectedEpisodes.map((episode) => { const updateProgressPayloads = episodes.map((episode) => {
return { return {
libraryItemId: this.libraryItem.id, libraryItemId: this.libraryItem.id,
episodeId: episode.id, episodeId: episode.id,
isFinished: newIsFinished isFinished: newIsFinished
} }
}) })
return this.$axios
this.$axios
.patch(`/api/me/progress/batch/update`, updateProgressPayloads) .patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => { .then(() => {
this.$toast.success(this.$strings.ToastBatchUpdateSuccess) this.$toast.success(this.$strings.ToastBatchUpdateSuccess)
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.23", "version": "2.3.1",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.23", "version": "2.3.1",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.23", "version": "2.3.1",
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+7 -7
View File
@@ -83,8 +83,8 @@ export const getters = {
getLibraryItemCoverSrc: (state, getters, rootState, rootGetters) => (libraryItem, placeholder = null) => { getLibraryItemCoverSrc: (state, getters, rootState, rootGetters) => (libraryItem, placeholder = null) => {
if (!placeholder) placeholder = `${rootState.routerBasePath}/book_placeholder.jpg` if (!placeholder) placeholder = `${rootState.routerBasePath}/book_placeholder.jpg`
if (!libraryItem) return placeholder if (!libraryItem) return placeholder
var media = libraryItem.media const media = libraryItem.media
if (!media || !media.coverPath || media.coverPath === placeholder) return placeholder if (!media?.coverPath || media.coverPath === placeholder) return placeholder
// Absolute URL covers (should no longer be used) // Absolute URL covers (should no longer be used)
if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath
@@ -99,14 +99,14 @@ export const getters = {
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}` return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}`
}, },
getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => (libraryItemId, placeholder = null, raw = false) => { getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => (libraryItemId, timestamp = null, raw = false) => {
if (!placeholder) placeholder = `${rootState.routerBasePath}/book_placeholder.jpg` const placeholder = `${rootState.routerBasePath}/book_placeholder.jpg`
if (!libraryItemId) return placeholder if (!libraryItemId) return placeholder
var userToken = rootGetters['user/getToken'] const userToken = rootGetters['user/getToken']
if (process.env.NODE_ENV !== 'production') { // Testing if (process.env.NODE_ENV !== 'production') { // Testing
return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}` return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}${timestamp ? `&ts=${timestamp}` : ''}`
} }
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}` return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}${timestamp ? `&ts=${timestamp}` : ''}`
}, },
getIsBatchSelectingMediaItems: (state) => { getIsBatchSelectingMediaItems: (state) => {
return state.selectedMediaItems.length return state.selectedMediaItems.length
+21 -15
View File
@@ -238,21 +238,23 @@ export const mutations = {
if (!libraryItem || !state.filterData) return if (!libraryItem || !state.filterData) return
if (state.currentLibraryId !== libraryItem.libraryId) return if (state.currentLibraryId !== libraryItem.libraryId) return
/* /*
var data = { structure of filterData:
{
authors: [], authors: [],
genres: [], genres: [],
tags: [], tags: [],
series: [], series: [],
narrators: [], narrators: [],
languages: [] languages: [],
publishers: []
} }
*/ */
var mediaMetadata = libraryItem.media.metadata const mediaMetadata = libraryItem.media.metadata
// Add/update book authors // Add/update book authors
if (mediaMetadata.authors && mediaMetadata.authors.length) { if (mediaMetadata.authors?.length) {
mediaMetadata.authors.forEach((author) => { mediaMetadata.authors.forEach((author) => {
var indexOf = state.filterData.authors.findIndex(au => au.id === author.id) const indexOf = state.filterData.authors.findIndex(au => au.id === author.id)
if (indexOf >= 0) { if (indexOf >= 0) {
state.filterData.authors.splice(indexOf, 1, author) state.filterData.authors.splice(indexOf, 1, author)
} else { } else {
@@ -263,9 +265,9 @@ export const mutations = {
} }
// Add/update series // Add/update series
if (mediaMetadata.series && mediaMetadata.series.length) { if (mediaMetadata.series?.length) {
mediaMetadata.series.forEach((series) => { mediaMetadata.series.forEach((series) => {
var indexOf = state.filterData.series.findIndex(se => se.id === series.id) const indexOf = state.filterData.series.findIndex(se => se.id === series.id)
if (indexOf >= 0) { if (indexOf >= 0) {
state.filterData.series.splice(indexOf, 1, { id: series.id, name: series.name }) state.filterData.series.splice(indexOf, 1, { id: series.id, name: series.name })
} else { } else {
@@ -276,7 +278,7 @@ export const mutations = {
} }
// Add genres // Add genres
if (mediaMetadata.genres && mediaMetadata.genres.length) { if (mediaMetadata.genres?.length) {
mediaMetadata.genres.forEach((genre) => { mediaMetadata.genres.forEach((genre) => {
if (!state.filterData.genres.includes(genre)) { if (!state.filterData.genres.includes(genre)) {
state.filterData.genres.push(genre) state.filterData.genres.push(genre)
@@ -286,7 +288,7 @@ export const mutations = {
} }
// Add tags // Add tags
if (libraryItem.media.tags && libraryItem.media.tags.length) { if (libraryItem.media.tags?.length) {
libraryItem.media.tags.forEach((tag) => { libraryItem.media.tags.forEach((tag) => {
if (!state.filterData.tags.includes(tag)) { if (!state.filterData.tags.includes(tag)) {
state.filterData.tags.push(tag) state.filterData.tags.push(tag)
@@ -296,7 +298,7 @@ export const mutations = {
} }
// Add narrators // Add narrators
if (mediaMetadata.narrators && mediaMetadata.narrators.length) { if (mediaMetadata.narrators?.length) {
mediaMetadata.narrators.forEach((narrator) => { mediaMetadata.narrators.forEach((narrator) => {
if (!state.filterData.narrators.includes(narrator)) { if (!state.filterData.narrators.includes(narrator)) {
state.filterData.narrators.push(narrator) state.filterData.narrators.push(narrator)
@@ -305,12 +307,16 @@ export const mutations = {
}) })
} }
// Add publishers
if (mediaMetadata.publisher && !state.filterData.publishers.includes(mediaMetadata.publisher)) {
state.filterData.publishers.push(mediaMetadata.publisher)
state.filterData.publishers.sort((a, b) => a.localeCompare(b))
}
// Add language // Add language
if (mediaMetadata.language) { if (mediaMetadata.language && !state.filterData.languages.includes(mediaMetadata.language)) {
if (!state.filterData.languages.includes(mediaMetadata.language)) { state.filterData.languages.push(mediaMetadata.language)
state.filterData.languages.push(mediaMetadata.language) state.filterData.languages.sort((a, b) => a.localeCompare(b))
state.filterData.languages.sort((a, b) => a.localeCompare(b))
}
} }
}, },
setCollections(state, collections) { setCollections(state, collections) {
+1 -1
View File
@@ -80,7 +80,7 @@ export const actions = {
if (state.settings.orderBy == 'media.metadata.publishedYear') { if (state.settings.orderBy == 'media.metadata.publishedYear') {
settingsUpdate.orderBy = 'media.metadata.title' settingsUpdate.orderBy = 'media.metadata.title'
} }
const invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues', 'ebooks', 'abridged'] const invalidFilters = ['series', 'authors', 'narrators', 'publishers', 'languages', 'progress', 'issues', 'ebooks', 'abridged']
const filterByFirstPart = (state.settings.filterBy || '').split('.').shift() const filterByFirstPart = (state.settings.filterBy || '').split('.').shift()
if (invalidFilters.includes(filterByFirstPart)) { if (invalidFilters.includes(filterByFirstPart)) {
settingsUpdate.filterBy = 'all' settingsUpdate.filterBy = 'all'
+4
View File
@@ -519,6 +519,8 @@
"MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?", "MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?",
"MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?", "MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?",
"MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?", "MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?",
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
"MessageConfirmMarkSeriesFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als abgeschlossen markieren wollen?", "MessageConfirmMarkSeriesFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als abgeschlossen markieren wollen?",
"MessageConfirmMarkSeriesNotFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als nicht abgeschlossen markieren wollen?", "MessageConfirmMarkSeriesNotFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als nicht abgeschlossen markieren wollen?",
"MessageConfirmRemoveAllChapters": "Sind Sie sicher, dass Sie alle Kapitel entfernen möchten?", "MessageConfirmRemoveAllChapters": "Sind Sie sicher, dass Sie alle Kapitel entfernen möchten?",
@@ -552,6 +554,8 @@
"MessageM4BFailed": "M4B fehlgeschlagen!", "MessageM4BFailed": "M4B fehlgeschlagen!",
"MessageM4BFinished": "M4B beendet!", "MessageM4BFinished": "M4B beendet!",
"MessageMapChapterTitles": "Zuordnen von Kapiteltiteln zu Ihren vorhandenen Medienkapiteln ohne Anpassung der Zeitangaben", "MessageMapChapterTitles": "Zuordnen von Kapiteltiteln zu Ihren vorhandenen Medienkapiteln ohne Anpassung der Zeitangaben",
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
"MessageMarkAsFinished": "Als beendet markieren", "MessageMarkAsFinished": "Als beendet markieren",
"MessageMarkAsNotFinished": "Als nicht abgeschlossen markieren", "MessageMarkAsNotFinished": "Als nicht abgeschlossen markieren",
"MessageMatchBooksDescription": "versucht, Bücher in der Bibliothek mit einem Buch des ausgewählten Suchanbieters abzugleichen und leere Details und das Titelbild auszufüllen. Details werden nicht überschrieben.", "MessageMatchBooksDescription": "versucht, Bücher in der Bibliothek mit einem Buch des ausgewählten Suchanbieters abzugleichen und leere Details und das Titelbild auszufüllen. Details werden nicht überschrieben.",
+4
View File
@@ -519,6 +519,8 @@
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?", "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?", "MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?", "MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?", "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?", "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
@@ -552,6 +554,8 @@
"MessageM4BFailed": "M4B Failed!", "MessageM4BFailed": "M4B Failed!",
"MessageM4BFinished": "M4B Finished!", "MessageM4BFinished": "M4B Finished!",
"MessageMapChapterTitles": "Map chapter titles to your existing audiobook chapters without adjusting timestamps", "MessageMapChapterTitles": "Map chapter titles to your existing audiobook chapters without adjusting timestamps",
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
"MessageMarkAsFinished": "Mark as Finished", "MessageMarkAsFinished": "Mark as Finished",
"MessageMarkAsNotFinished": "Mark as Not Finished", "MessageMarkAsNotFinished": "Mark as Not Finished",
"MessageMatchBooksDescription": "will attempt to match books in the library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.", "MessageMatchBooksDescription": "will attempt to match books in the library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.",
+4
View File
@@ -519,6 +519,8 @@
"MessageConfirmDeleteLibrary": "Esta seguro que desea eliminar permanentemente la biblioteca \"{0}\"?", "MessageConfirmDeleteLibrary": "Esta seguro que desea eliminar permanentemente la biblioteca \"{0}\"?",
"MessageConfirmDeleteSession": "Esta seguro que desea eliminar esta session?", "MessageConfirmDeleteSession": "Esta seguro que desea eliminar esta session?",
"MessageConfirmForceReScan": "Esta seguro que desea forzar re-escanear?", "MessageConfirmForceReScan": "Esta seguro que desea forzar re-escanear?",
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
"MessageConfirmMarkSeriesFinished": "Esta seguro que desea marcar todos los libros en esta serie como terminados?", "MessageConfirmMarkSeriesFinished": "Esta seguro que desea marcar todos los libros en esta serie como terminados?",
"MessageConfirmMarkSeriesNotFinished": "Esta seguro que desea marcar todos los libros en esta serie como no terminados?", "MessageConfirmMarkSeriesNotFinished": "Esta seguro que desea marcar todos los libros en esta serie como no terminados?",
"MessageConfirmRemoveAllChapters": "Esta seguro que desea remover todos los capitulos?", "MessageConfirmRemoveAllChapters": "Esta seguro que desea remover todos los capitulos?",
@@ -552,6 +554,8 @@
"MessageM4BFailed": "M4B Fallo!", "MessageM4BFailed": "M4B Fallo!",
"MessageM4BFinished": "M4B Terminado!", "MessageM4BFinished": "M4B Terminado!",
"MessageMapChapterTitles": "Map chapter titles to your existing audiobook chapters without adjusting timestamps", "MessageMapChapterTitles": "Map chapter titles to your existing audiobook chapters without adjusting timestamps",
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
"MessageMarkAsFinished": "Marcar como Terminado", "MessageMarkAsFinished": "Marcar como Terminado",
"MessageMarkAsNotFinished": "Marcar como No Terminado", "MessageMarkAsNotFinished": "Marcar como No Terminado",
"MessageMatchBooksDescription": "intentará hacer coincidir los libros de la biblioteca con un libro del proveedor de búsqueda seleccionado y rellenará los detalles vacíos y la portada. No sobrescribe los detalles.", "MessageMatchBooksDescription": "intentará hacer coincidir los libros de la biblioteca con un libro del proveedor de búsqueda seleccionado y rellenará los detalles vacíos y la portada. No sobrescribe los detalles.",
+4
View File
@@ -519,6 +519,8 @@
"MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?", "MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?",
"MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?", "MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?",
"MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une Analyse Forcée ?", "MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une Analyse Forcée ?",
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
"MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer comme terminé tous les livres de cette série ?", "MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer comme terminé tous les livres de cette série ?",
"MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer comme non terminé tous les livres de cette série ?", "MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer comme non terminé tous les livres de cette série ?",
"MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?", "MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?",
@@ -552,6 +554,8 @@
"MessageM4BFailed": "M4B en échec !", "MessageM4BFailed": "M4B en échec !",
"MessageM4BFinished": "M4B terminé !", "MessageM4BFinished": "M4B terminé !",
"MessageMapChapterTitles": "Faire correspondre les titres des chapitres aux chapitres existants de votre livre audio sans ajuster lhorodatage.", "MessageMapChapterTitles": "Faire correspondre les titres des chapitres aux chapitres existants de votre livre audio sans ajuster lhorodatage.",
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
"MessageMarkAsFinished": "Marquer comme terminé", "MessageMarkAsFinished": "Marquer comme terminé",
"MessageMarkAsNotFinished": "Marquer comme non Terminé", "MessageMarkAsNotFinished": "Marquer comme non Terminé",
"MessageMatchBooksDescription": "tentera de faire correspondre les livres de la bibliothèque avec les livres du fournisseur sélectionné pour combler les détails et couverture manquants. N’écrase pas les données existantes.", "MessageMatchBooksDescription": "tentera de faire correspondre les livres de la bibliothèque avec les livres du fournisseur sélectionné pour combler les détails et couverture manquants. N’écrase pas les données existantes.",
+4
View File
@@ -519,6 +519,8 @@
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?", "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?", "MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?", "MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?", "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?", "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
@@ -552,6 +554,8 @@
"MessageM4BFailed": "M4B Failed!", "MessageM4BFailed": "M4B Failed!",
"MessageM4BFinished": "M4B Finished!", "MessageM4BFinished": "M4B Finished!",
"MessageMapChapterTitles": "Map chapter titles to your existing audiobook chapters without adjusting timestamps", "MessageMapChapterTitles": "Map chapter titles to your existing audiobook chapters without adjusting timestamps",
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
"MessageMarkAsFinished": "Mark as Finished", "MessageMarkAsFinished": "Mark as Finished",
"MessageMarkAsNotFinished": "Mark as Not Finished", "MessageMarkAsNotFinished": "Mark as Not Finished",
"MessageMatchBooksDescription": "will attempt to match books in the library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.", "MessageMatchBooksDescription": "will attempt to match books in the library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.",
+4
View File
@@ -519,6 +519,8 @@
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?", "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?", "MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?", "MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?", "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?", "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
@@ -552,6 +554,8 @@
"MessageM4BFailed": "M4B Failed!", "MessageM4BFailed": "M4B Failed!",
"MessageM4BFinished": "M4B Finished!", "MessageM4BFinished": "M4B Finished!",
"MessageMapChapterTitles": "Map chapter titles to your existing audiobook chapters without adjusting timestamps", "MessageMapChapterTitles": "Map chapter titles to your existing audiobook chapters without adjusting timestamps",
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
"MessageMarkAsFinished": "Mark as Finished", "MessageMarkAsFinished": "Mark as Finished",
"MessageMarkAsNotFinished": "Mark as Not Finished", "MessageMarkAsNotFinished": "Mark as Not Finished",
"MessageMatchBooksDescription": "will attempt to match books in the library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.", "MessageMatchBooksDescription": "will attempt to match books in the library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.",
+4
View File
@@ -519,6 +519,8 @@
"MessageConfirmDeleteLibrary": "Jeste li sigurni da želite trajno obrisati biblioteku \"{0}\"?", "MessageConfirmDeleteLibrary": "Jeste li sigurni da želite trajno obrisati biblioteku \"{0}\"?",
"MessageConfirmDeleteSession": "Jeste li sigurni da želite obrisati ovu sesiju?", "MessageConfirmDeleteSession": "Jeste li sigurni da želite obrisati ovu sesiju?",
"MessageConfirmForceReScan": "Jeste li sigurni da želite ponovno skenirati?", "MessageConfirmForceReScan": "Jeste li sigurni da želite ponovno skenirati?",
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?", "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?", "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
@@ -552,6 +554,8 @@
"MessageM4BFailed": "M4B neuspješan!", "MessageM4BFailed": "M4B neuspješan!",
"MessageM4BFinished": "M4B završio!", "MessageM4BFinished": "M4B završio!",
"MessageMapChapterTitles": "Mapiraj imena poglavlja u postoječa poglavlja bez izmijene timestampova.", "MessageMapChapterTitles": "Mapiraj imena poglavlja u postoječa poglavlja bez izmijene timestampova.",
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
"MessageMarkAsFinished": "Označi kao završeno", "MessageMarkAsFinished": "Označi kao završeno",
"MessageMarkAsNotFinished": "Označi kao nezavršeno", "MessageMarkAsNotFinished": "Označi kao nezavršeno",
"MessageMatchBooksDescription": "će probati matchati knjige iz biblioteke sa knjigom od odabranog poslužitelja i popuniti prazne detalje i cover. Ne briše postojeće detalje.", "MessageMatchBooksDescription": "će probati matchati knjige iz biblioteke sa knjigom od odabranog poslužitelja i popuniti prazne detalje i cover. Ne briše postojeće detalje.",
+4
View File
@@ -519,6 +519,8 @@
"MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?", "MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?",
"MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?", "MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?",
"MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?", "MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?",
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
"MessageConfirmMarkSeriesFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come completati?", "MessageConfirmMarkSeriesFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come completati?",
"MessageConfirmMarkSeriesNotFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come non completati?", "MessageConfirmMarkSeriesNotFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come non completati?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?", "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
@@ -552,6 +554,8 @@
"MessageM4BFailed": "M4B Fallito!", "MessageM4BFailed": "M4B Fallito!",
"MessageM4BFinished": "M4B Finito!", "MessageM4BFinished": "M4B Finito!",
"MessageMapChapterTitles": "Associa i titoli dei capitoli ai capitoli dell'audiolibro esistente senza modificare i timestamp", "MessageMapChapterTitles": "Associa i titoli dei capitoli ai capitoli dell'audiolibro esistente senza modificare i timestamp",
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
"MessageMarkAsFinished": "Segna come finito", "MessageMarkAsFinished": "Segna come finito",
"MessageMarkAsNotFinished": "Segna come da completare", "MessageMarkAsNotFinished": "Segna come da completare",
"MessageMatchBooksDescription": "tenterà di abbinare i libri nella biblioteca con un libro del provider di ricerca selezionato e inserirà i dettagli vuoti e la copertina. Non sovrascrive i dettagli.", "MessageMatchBooksDescription": "tenterà di abbinare i libri nella biblioteca con un libro del provider di ricerca selezionato e inserirà i dettagli vuoti e la copertina. Non sovrascrive i dettagli.",
+4
View File
@@ -519,6 +519,8 @@
"MessageConfirmDeleteLibrary": "Weet je zeker dat je de bibliotheek \"{0}\" permanent wil verwijderen?", "MessageConfirmDeleteLibrary": "Weet je zeker dat je de bibliotheek \"{0}\" permanent wil verwijderen?",
"MessageConfirmDeleteSession": "Weet je zeker dat je deze sessie wil verwijderen?", "MessageConfirmDeleteSession": "Weet je zeker dat je deze sessie wil verwijderen?",
"MessageConfirmForceReScan": "Weet je zeker dat je geforceerd opnieuw wil scannen?", "MessageConfirmForceReScan": "Weet je zeker dat je geforceerd opnieuw wil scannen?",
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
"MessageConfirmMarkSeriesFinished": "Weet je zeker dat je alle boeken in deze serie wil markeren als voltooid?", "MessageConfirmMarkSeriesFinished": "Weet je zeker dat je alle boeken in deze serie wil markeren als voltooid?",
"MessageConfirmMarkSeriesNotFinished": "Weet je zeker dat je alle boeken in deze serie wil markeren als niet voltooid?", "MessageConfirmMarkSeriesNotFinished": "Weet je zeker dat je alle boeken in deze serie wil markeren als niet voltooid?",
"MessageConfirmRemoveAllChapters": "Weet je zeker dat je alle hoofdstukken wil verwijderen?", "MessageConfirmRemoveAllChapters": "Weet je zeker dat je alle hoofdstukken wil verwijderen?",
@@ -552,6 +554,8 @@
"MessageM4BFailed": "M4B mislukt!", "MessageM4BFailed": "M4B mislukt!",
"MessageM4BFinished": "M4B voltooid!", "MessageM4BFinished": "M4B voltooid!",
"MessageMapChapterTitles": "Map hoofdstuktitels naar je bestaande audioboekhoofdstukken zonder aanpassing van tijden", "MessageMapChapterTitles": "Map hoofdstuktitels naar je bestaande audioboekhoofdstukken zonder aanpassing van tijden",
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
"MessageMarkAsFinished": "Markeer als Voltooid", "MessageMarkAsFinished": "Markeer als Voltooid",
"MessageMarkAsNotFinished": "Markeer als Niet Voltooid", "MessageMarkAsNotFinished": "Markeer als Niet Voltooid",
"MessageMatchBooksDescription": "zal proberen boeken in de bibliotheek te matchen met een boek uit de geselecteerde bron en lege details en coverafbeelding te vullen. Overschrijft details niet.", "MessageMatchBooksDescription": "zal proberen boeken in de bibliotheek te matchen met een boek uit de geselecteerde bron en lege details en coverafbeelding te vullen. Overschrijft details niet.",
+4
View File
@@ -519,6 +519,8 @@
"MessageConfirmDeleteLibrary": "Czy na pewno chcesz trwale usunąć bibliotekę \"{0}\"?", "MessageConfirmDeleteLibrary": "Czy na pewno chcesz trwale usunąć bibliotekę \"{0}\"?",
"MessageConfirmDeleteSession": "Czy na pewno chcesz usunąć tę sesję?", "MessageConfirmDeleteSession": "Czy na pewno chcesz usunąć tę sesję?",
"MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?", "MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?",
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?", "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?", "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
@@ -552,6 +554,8 @@
"MessageM4BFailed": "Tworzenie pliku M4B nie powiodło się", "MessageM4BFailed": "Tworzenie pliku M4B nie powiodło się",
"MessageM4BFinished": "Tworzenie pliku M4B zakończyło się!", "MessageM4BFinished": "Tworzenie pliku M4B zakończyło się!",
"MessageMapChapterTitles": "Mapowanie tytułów rozdziałów do istniejących rozdziałów audiobooka bez dostosowywania znaczników czasu", "MessageMapChapterTitles": "Mapowanie tytułów rozdziałów do istniejących rozdziałów audiobooka bez dostosowywania znaczników czasu",
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
"MessageMarkAsFinished": "Oznacz jako ukończone", "MessageMarkAsFinished": "Oznacz jako ukończone",
"MessageMarkAsNotFinished": "Oznacz jako nieukończone", "MessageMarkAsNotFinished": "Oznacz jako nieukończone",
"MessageMatchBooksDescription": "spróbuje dopasować książki w bibliotece bez plików audio, korzystając z wybranego dostawcy wyszukiwania i wypełnić puste szczegóły i okładki. Nie nadpisuje informacji.", "MessageMatchBooksDescription": "spróbuje dopasować książki w bibliotece bez plików audio, korzystając z wybranego dostawcy wyszukiwania i wypełnić puste szczegóły i okładki. Nie nadpisuje informacji.",
+4
View File
@@ -519,6 +519,8 @@
"MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?", "MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?",
"MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?", "MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?",
"MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?", "MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?",
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
"MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как законченные?", "MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как законченные?",
"MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как незаконченные?", "MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как незаконченные?",
"MessageConfirmRemoveAllChapters": "Вы уверены, что хотите удалить все главы?", "MessageConfirmRemoveAllChapters": "Вы уверены, что хотите удалить все главы?",
@@ -552,6 +554,8 @@
"MessageM4BFailed": "M4B Ошибка!", "MessageM4BFailed": "M4B Ошибка!",
"MessageM4BFinished": "M4B Завершено!", "MessageM4BFinished": "M4B Завершено!",
"MessageMapChapterTitles": "Сопоставление названий глав с существующими главами аудиокниги без корректировки временных меток", "MessageMapChapterTitles": "Сопоставление названий глав с существующими главами аудиокниги без корректировки временных меток",
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
"MessageMarkAsFinished": "Отметить, как завершенную", "MessageMarkAsFinished": "Отметить, как завершенную",
"MessageMarkAsNotFinished": "Отметить, как не завершенную", "MessageMarkAsNotFinished": "Отметить, как не завершенную",
"MessageMatchBooksDescription": "попытается сопоставить книги в библиотеке с книгой из выбранного поставщика поиска и заполнить пустые детали и обложку. Не перезаписывает сведения.", "MessageMatchBooksDescription": "попытается сопоставить книги в библиотеке с книгой из выбранного поставщика поиска и заполнить пустые детали и обложку. Не перезаписывает сведения.",
+4
View File
@@ -519,6 +519,8 @@
"MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?", "MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?",
"MessageConfirmDeleteSession": "你确定要删除此会话吗?", "MessageConfirmDeleteSession": "你确定要删除此会话吗?",
"MessageConfirmForceReScan": "你确定要强制重新扫描吗?", "MessageConfirmForceReScan": "你确定要强制重新扫描吗?",
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
"MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?", "MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?",
"MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?", "MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?",
"MessageConfirmRemoveAllChapters": "你确定要移除所有章节吗?", "MessageConfirmRemoveAllChapters": "你确定要移除所有章节吗?",
@@ -552,6 +554,8 @@
"MessageM4BFailed": "M4B 失败!", "MessageM4BFailed": "M4B 失败!",
"MessageM4BFinished": "M4B 完成!", "MessageM4BFinished": "M4B 完成!",
"MessageMapChapterTitles": "将章节标题映射到现有的有声读物章节, 无需调整时间戳", "MessageMapChapterTitles": "将章节标题映射到现有的有声读物章节, 无需调整时间戳",
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
"MessageMarkAsFinished": "标记为已听完", "MessageMarkAsFinished": "标记为已听完",
"MessageMarkAsNotFinished": "标记为未听完", "MessageMarkAsNotFinished": "标记为未听完",
"MessageMatchBooksDescription": "尝试将媒体库中的图书与所选搜索提供商的图书进行匹配, 并填写空白的详细信息和封面. 不覆盖详细信息.", "MessageMatchBooksDescription": "尝试将媒体库中的图书与所选搜索提供商的图书进行匹配, 并填写空白的详细信息和封面. 不覆盖详细信息.",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.23", "version": "2.3.1",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.23", "version": "2.3.1",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^0.27.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.23", "version": "2.3.1",
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+18 -9
View File
@@ -128,6 +128,18 @@ class Database {
const startTime = Date.now() const startTime = Date.now()
const settingsData = await this.models.setting.getOldSettings()
this.settings = settingsData.settings
this.emailSettings = settingsData.emailSettings
this.serverSettings = settingsData.serverSettings
this.notificationSettings = settingsData.notificationSettings
global.ServerSettings = this.serverSettings.toJSON()
// Version specific migrations
if (this.serverSettings.version === '2.3.0' && packageJson.version !== '2.3.0') {
await dbMigration.migrationPatch(this)
}
this.libraryItems = await this.models.libraryItem.getAllOldLibraryItems() this.libraryItems = await this.models.libraryItem.getAllOldLibraryItems()
this.users = await this.models.user.getOldUsers() this.users = await this.models.user.getOldUsers()
this.libraries = await this.models.library.getAllOldLibraries() this.libraries = await this.models.library.getAllOldLibraries()
@@ -137,13 +149,6 @@ class Database {
this.series = await this.models.series.getAllOldSeries() this.series = await this.models.series.getAllOldSeries()
this.feeds = await this.models.feed.getOldFeeds() this.feeds = await this.models.feed.getOldFeeds()
const settingsData = await this.models.setting.getOldSettings()
this.settings = settingsData.settings
this.emailSettings = settingsData.emailSettings
this.serverSettings = settingsData.serverSettings
this.notificationSettings = settingsData.notificationSettings
global.ServerSettings = this.serverSettings.toJSON()
Logger.info(`[Database] Db data loaded in ${Date.now() - startTime}ms`) Logger.info(`[Database] Db data loaded in ${Date.now() - startTime}ms`)
if (packageJson.version !== this.serverSettings.version) { if (packageJson.version !== this.serverSettings.version) {
@@ -357,7 +362,11 @@ class Database {
} }
getLibraryItem(libraryItemId) { getLibraryItem(libraryItemId) {
if (!this.sequelize) return false if (!this.sequelize || !libraryItemId) return false
// Temp support for old library item ids from mobile
if (libraryItemId.startsWith('li_')) return this.libraryItems.find(li => li.oldLibraryItemId === libraryItemId)
return this.libraryItems.find(li => li.id === libraryItemId) return this.libraryItems.find(li => li.id === libraryItemId)
} }
@@ -438,7 +447,7 @@ class Database {
async createAuthor(oldAuthor) { async createAuthor(oldAuthor) {
if (!this.sequelize) return false if (!this.sequelize) return false
await this.models.createFromOld(oldAuthor) await this.models.author.createFromOld(oldAuthor)
this.authors.push(oldAuthor) this.authors.push(oldAuthor)
} }
+6 -1
View File
@@ -198,6 +198,7 @@ class LibraryController {
include: include.join(',') include: include.join(',')
} }
const mediaIsBook = payload.mediaType === 'book' const mediaIsBook = payload.mediaType === 'book'
const mediaIsPodcast = payload.mediaType === 'podcast'
// Step 1 - Filter the retrieved library items // Step 1 - Filter the retrieved library items
let filterSeries = null let filterSeries = null
@@ -236,7 +237,6 @@ class LibraryController {
const sortArray = [] const sortArray = []
// When on the series page, sort by sequence only // When on the series page, sort by sequence only
if (payload.sortBy === 'book.volumeNumber') payload.sortBy = null // TODO: Remove temp fix after mobile release 0.9.60
if (filterSeries && !payload.sortBy) { if (filterSeries && !payload.sortBy) {
sortArray.push({ asc: (li) => li.media.metadata.getSeries(filterSeries).sequence }) sortArray.push({ asc: (li) => li.media.metadata.getSeries(filterSeries).sequence })
// If no series sequence then fallback to sorting by title (or collapsed series name for sub-series) // If no series sequence then fallback to sorting by title (or collapsed series name for sub-series)
@@ -360,6 +360,11 @@ class LibraryController {
json.rssFeed = feedData ? feedData.toJSONMinified() : null json.rssFeed = feedData ? feedData.toJSONMinified() : null
} }
// add numEpisodesIncomplete if "include=numEpisodesIncomplete" was put in query string (only for podcasts)
if (mediaIsPodcast && include.includes('numepisodesincomplete')) {
json.numEpisodesIncomplete = req.user.getNumEpisodesIncompleteForPodcast(li)
}
if (filterSeries) { if (filterSeries) {
// If filtering by series, make sure to include the series metadata // If filtering by series, make sure to include the series metadata
json.media.metadata.series = li.media.metadata.getSeries(filterSeries) json.media.metadata.series = li.media.metadata.getSeries(filterSeries)
+3 -1
View File
@@ -88,7 +88,9 @@ class LibraryItemController {
} }
const libraryItemPath = req.libraryItem.path const libraryItemPath = req.libraryItem.path
const filename = `${req.libraryItem.media.metadata.title}.zip` const itemTitle = req.libraryItem.media.metadata.title
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)
const filename = `${itemTitle}.zip`
zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res) zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res)
} }
+1 -1
View File
@@ -70,7 +70,7 @@ class SeriesController {
* Filter out any library items not accessible to user * Filter out any library items not accessible to user
*/ */
const libraryItems = Database.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id)) const libraryItems = Database.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id))
const libraryItemsAccessible = libraryItems.filter(req.user.checkCanAccessLibraryItem) const libraryItemsAccessible = libraryItems.filter(li => req.user.checkCanAccessLibraryItem(li))
if (libraryItems.length && !libraryItemsAccessible.length) { if (libraryItems.length && !libraryItemsAccessible.length) {
Logger.warn(`[SeriesController] User attempted to access series "${series.id}" without access to any of the books`, req.user) Logger.warn(`[SeriesController] User attempted to access series "${series.id}" without access to any of the books`, req.user)
return res.sendStatus(403) return res.sendStatus(403)
+1 -1
View File
@@ -94,7 +94,7 @@ class SessionController {
// POST: api/session/local // POST: api/session/local
syncLocal(req, res) { syncLocal(req, res) {
this.playbackSessionManager.syncLocalSessionRequest(req.user, req.body, res) this.playbackSessionManager.syncLocalSessionRequest(req, res)
} }
// POST: api/session/local-all // POST: api/session/local-all
+37 -4
View File
@@ -1,3 +1,4 @@
const uuidv4 = require("uuid").v4
const Path = require('path') const Path = require('path')
const serverVersion = require('../../package.json').version const serverVersion = require('../../package.json').version
const Logger = require('../Logger') const Logger = require('../Logger')
@@ -19,6 +20,7 @@ class PlaybackSessionManager {
constructor() { constructor() {
this.StreamsPath = Path.join(global.MetadataPath, 'streams') this.StreamsPath = Path.join(global.MetadataPath, 'streams')
this.oldPlaybackSessionMap = {} // TODO: Remove after updated mobile versions
this.sessions = [] this.sessions = []
} }
@@ -74,13 +76,14 @@ class PlaybackSessionManager {
} }
async syncLocalSessionsRequest(req, res) { async syncLocalSessionsRequest(req, res) {
const deviceInfo = await this.getDeviceInfo(req)
const user = req.user const user = req.user
const sessions = req.body.sessions || [] const sessions = req.body.sessions || []
const syncResults = [] const syncResults = []
for (const sessionJson of sessions) { for (const sessionJson of sessions) {
Logger.info(`[PlaybackSessionManager] Syncing local session "${sessionJson.displayTitle}" (${sessionJson.id})`) Logger.info(`[PlaybackSessionManager] Syncing local session "${sessionJson.displayTitle}" (${sessionJson.id})`)
const result = await this.syncLocalSession(user, sessionJson) const result = await this.syncLocalSession(user, sessionJson, deviceInfo)
syncResults.push(result) syncResults.push(result)
} }
@@ -89,7 +92,7 @@ class PlaybackSessionManager {
}) })
} }
async syncLocalSession(user, sessionJson) { async syncLocalSession(user, sessionJson, deviceInfo) {
const libraryItem = Database.getLibraryItem(sessionJson.libraryItemId) const libraryItem = Database.getLibraryItem(sessionJson.libraryItemId)
const episode = (sessionJson.episodeId && libraryItem && libraryItem.isPodcast) ? libraryItem.media.getEpisode(sessionJson.episodeId) : null const episode = (sessionJson.episodeId && libraryItem && libraryItem.isPodcast) ? libraryItem.media.getEpisode(sessionJson.episodeId) : null
if (!libraryItem || (libraryItem.isPodcast && !episode)) { if (!libraryItem || (libraryItem.isPodcast && !episode)) {
@@ -101,10 +104,37 @@ class PlaybackSessionManager {
} }
} }
// TODO: Temp update local playback session id to uuidv4 & library item/book/episode ids
if (sessionJson.id?.startsWith('play_local_')) {
if (!this.oldPlaybackSessionMap[sessionJson.id]) {
const newSessionId = uuidv4()
this.oldPlaybackSessionMap[sessionJson.id] = newSessionId
sessionJson.id = newSessionId
} else {
sessionJson.id = this.oldPlaybackSessionMap[sessionJson.id]
}
}
if (sessionJson.libraryItemId !== libraryItem.id) {
Logger.info(`[PlaybackSessionManager] Mapped old libraryItemId "${sessionJson.libraryItemId}" to ${libraryItem.id}`)
sessionJson.libraryItemId = libraryItem.id
sessionJson.bookId = episode ? null : libraryItem.media.id
}
if (!sessionJson.bookId && !episode) {
sessionJson.bookId = libraryItem.media.id
}
if (episode && sessionJson.episodeId !== episode.id) {
Logger.info(`[PlaybackSessionManager] Mapped old episodeId "${sessionJson.episodeId}" to ${episode.id}`)
sessionJson.episodeId = episode.id
}
if (sessionJson.libraryId !== libraryItem.libraryId) {
sessionJson.libraryId = libraryItem.libraryId
}
let session = await Database.getPlaybackSession(sessionJson.id) let session = await Database.getPlaybackSession(sessionJson.id)
if (!session) { if (!session) {
// New session from local // New session from local
session = new PlaybackSession(sessionJson) session = new PlaybackSession(sessionJson)
session.deviceInfo = deviceInfo
Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`) Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`)
await Database.createPlaybackSession(session) await Database.createPlaybackSession(session)
} else { } else {
@@ -152,8 +182,11 @@ class PlaybackSessionManager {
return result return result
} }
async syncLocalSessionRequest(user, sessionJson, res) { async syncLocalSessionRequest(req, res) {
const result = await this.syncLocalSession(user, sessionJson) const deviceInfo = await this.getDeviceInfo(req)
const user = req.user
const sessionJson = req.body
const result = await this.syncLocalSession(user, sessionJson, deviceInfo)
if (result.error) { if (result.error) {
res.status(500).send(result.error) res.status(500).send(result.error)
} else { } else {
+11 -3
View File
@@ -6,7 +6,8 @@ module.exports = (sequelize) => {
class Library extends Model { class Library extends Model {
static async getAllOldLibraries() { static async getAllOldLibraries() {
const libraries = await this.findAll({ const libraries = await this.findAll({
include: sequelize.models.libraryFolder include: sequelize.models.libraryFolder,
order: [['displayOrder', 'ASC']]
}) })
return libraries.map(lib => this.getOldLibrary(lib)) return libraries.map(lib => this.getOldLibrary(lib))
} }
@@ -22,6 +23,7 @@ module.exports = (sequelize) => {
}) })
return new oldLibrary({ return new oldLibrary({
id: libraryExpanded.id, id: libraryExpanded.id,
oldLibraryId: libraryExpanded.extraData?.oldLibraryId || null,
name: libraryExpanded.name, name: libraryExpanded.name,
folders, folders,
displayOrder: libraryExpanded.displayOrder, displayOrder: libraryExpanded.displayOrder,
@@ -92,6 +94,10 @@ module.exports = (sequelize) => {
} }
static getFromOld(oldLibrary) { static getFromOld(oldLibrary) {
const extraData = {}
if (oldLibrary.oldLibraryId) {
extraData.oldLibraryId = oldLibrary.oldLibraryId
}
return { return {
id: oldLibrary.id, id: oldLibrary.id,
name: oldLibrary.name, name: oldLibrary.name,
@@ -101,7 +107,8 @@ module.exports = (sequelize) => {
provider: oldLibrary.provider, provider: oldLibrary.provider,
settings: oldLibrary.settings?.toJSON() || {}, settings: oldLibrary.settings?.toJSON() || {},
createdAt: oldLibrary.createdAt, createdAt: oldLibrary.createdAt,
updatedAt: oldLibrary.lastUpdate updatedAt: oldLibrary.lastUpdate,
extraData
} }
} }
@@ -127,7 +134,8 @@ module.exports = (sequelize) => {
provider: DataTypes.STRING, provider: DataTypes.STRING,
lastScan: DataTypes.DATE, lastScan: DataTypes.DATE,
lastScanVersion: DataTypes.STRING, lastScanVersion: DataTypes.STRING,
settings: DataTypes.JSON settings: DataTypes.JSON,
extraData: DataTypes.JSON
}, { }, {
sequelize, sequelize,
modelName: 'library' modelName: 'library'
+9 -2
View File
@@ -49,6 +49,7 @@ module.exports = (sequelize) => {
return new oldLibraryItem({ return new oldLibraryItem({
id: libraryItemExpanded.id, id: libraryItemExpanded.id,
ino: libraryItemExpanded.ino, ino: libraryItemExpanded.ino,
oldLibraryItemId: libraryItemExpanded.extraData?.oldLibraryItemId || null,
libraryId: libraryItemExpanded.libraryId, libraryId: libraryItemExpanded.libraryId,
folderId: libraryItemExpanded.libraryFolderId, folderId: libraryItemExpanded.libraryFolderId,
path: libraryItemExpanded.path, path: libraryItemExpanded.path,
@@ -261,6 +262,10 @@ module.exports = (sequelize) => {
} }
static getFromOld(oldLibraryItem) { static getFromOld(oldLibraryItem) {
const extraData = {}
if (oldLibraryItem.oldLibraryItemId) {
extraData.oldLibraryItemId = oldLibraryItem.oldLibraryItemId
}
return { return {
id: oldLibraryItem.id, id: oldLibraryItem.id,
ino: oldLibraryItem.ino, ino: oldLibraryItem.ino,
@@ -278,7 +283,8 @@ module.exports = (sequelize) => {
lastScanVersion: oldLibraryItem.scanVersion, lastScanVersion: oldLibraryItem.scanVersion,
libraryId: oldLibraryItem.libraryId, libraryId: oldLibraryItem.libraryId,
libraryFolderId: oldLibraryItem.folderId, libraryFolderId: oldLibraryItem.folderId,
libraryFiles: oldLibraryItem.libraryFiles?.map(lf => lf.toJSON()) || [] libraryFiles: oldLibraryItem.libraryFiles?.map(lf => lf.toJSON()) || [],
extraData
} }
} }
@@ -317,7 +323,8 @@ module.exports = (sequelize) => {
birthtime: DataTypes.DATE(6), birthtime: DataTypes.DATE(6),
lastScan: DataTypes.DATE, lastScan: DataTypes.DATE,
lastScanVersion: DataTypes.STRING, lastScanVersion: DataTypes.STRING,
libraryFiles: DataTypes.JSON libraryFiles: DataTypes.JSON,
extraData: DataTypes.JSON
}, { }, {
sequelize, sequelize,
modelName: 'libraryItem' modelName: 'libraryItem'
+9 -2
View File
@@ -15,6 +15,7 @@ module.exports = (sequelize) => {
libraryItemId: libraryItemId || null, libraryItemId: libraryItemId || null,
podcastId: this.podcastId, podcastId: this.podcastId,
id: this.id, id: this.id,
oldEpisodeId: this.extraData?.oldEpisodeId || null,
index: this.index, index: this.index,
season: this.season, season: this.season,
episode: this.episode, episode: this.episode,
@@ -38,6 +39,10 @@ module.exports = (sequelize) => {
} }
static getFromOld(oldEpisode) { static getFromOld(oldEpisode) {
const extraData = {}
if (oldEpisode.oldEpisodeId) {
extraData.oldEpisodeId = oldEpisode.oldEpisodeId
}
return { return {
id: oldEpisode.id, id: oldEpisode.id,
index: oldEpisode.index, index: oldEpisode.index,
@@ -54,7 +59,8 @@ module.exports = (sequelize) => {
publishedAt: oldEpisode.publishedAt, publishedAt: oldEpisode.publishedAt,
podcastId: oldEpisode.podcastId, podcastId: oldEpisode.podcastId,
audioFile: oldEpisode.audioFile?.toJSON() || null, audioFile: oldEpisode.audioFile?.toJSON() || null,
chapters: oldEpisode.chapters chapters: oldEpisode.chapters,
extraData
} }
} }
} }
@@ -79,7 +85,8 @@ module.exports = (sequelize) => {
publishedAt: DataTypes.DATE, publishedAt: DataTypes.DATE,
audioFile: DataTypes.JSON, audioFile: DataTypes.JSON,
chapters: DataTypes.JSON chapters: DataTypes.JSON,
extraData: DataTypes.JSON
}, { }, {
sequelize, sequelize,
modelName: 'podcastEpisode' modelName: 'podcastEpisode'
+3
View File
@@ -6,6 +6,7 @@ const { filePathToPOSIX } = require('../utils/fileUtils')
class Library { class Library {
constructor(library = null) { constructor(library = null) {
this.id = null this.id = null
this.oldLibraryId = null // TODO: Temp
this.name = null this.name = null
this.folders = [] this.folders = []
this.displayOrder = 1 this.displayOrder = 1
@@ -39,6 +40,7 @@ class Library {
construct(library) { construct(library) {
this.id = library.id this.id = library.id
this.oldLibraryId = library.oldLibraryId
this.name = library.name this.name = library.name
this.folders = (library.folders || []).map(f => new Folder(f)) this.folders = (library.folders || []).map(f => new Folder(f))
this.displayOrder = library.displayOrder || 1 this.displayOrder = library.displayOrder || 1
@@ -74,6 +76,7 @@ class Library {
toJSON() { toJSON() {
return { return {
id: this.id, id: this.id,
oldLibraryId: this.oldLibraryId,
name: this.name, name: this.name,
folders: (this.folders || []).map(f => f.toJSON()), folders: (this.folders || []).map(f => f.toJSON()),
displayOrder: this.displayOrder, displayOrder: this.displayOrder,
+5
View File
@@ -16,6 +16,7 @@ class LibraryItem {
constructor(libraryItem = null) { constructor(libraryItem = null) {
this.id = null this.id = null
this.ino = null // Inode this.ino = null // Inode
this.oldLibraryItemId = null
this.libraryId = null this.libraryId = null
this.folderId = null this.folderId = null
@@ -52,6 +53,7 @@ class LibraryItem {
construct(libraryItem) { construct(libraryItem) {
this.id = libraryItem.id this.id = libraryItem.id
this.ino = libraryItem.ino || null this.ino = libraryItem.ino || null
this.oldLibraryItemId = libraryItem.oldLibraryItemId
this.libraryId = libraryItem.libraryId this.libraryId = libraryItem.libraryId
this.folderId = libraryItem.folderId this.folderId = libraryItem.folderId
this.path = libraryItem.path this.path = libraryItem.path
@@ -97,6 +99,7 @@ class LibraryItem {
return { return {
id: this.id, id: this.id,
ino: this.ino, ino: this.ino,
oldLibraryItemId: this.oldLibraryItemId,
libraryId: this.libraryId, libraryId: this.libraryId,
folderId: this.folderId, folderId: this.folderId,
path: this.path, path: this.path,
@@ -121,6 +124,7 @@ class LibraryItem {
return { return {
id: this.id, id: this.id,
ino: this.ino, ino: this.ino,
oldLibraryItemId: this.oldLibraryItemId,
libraryId: this.libraryId, libraryId: this.libraryId,
folderId: this.folderId, folderId: this.folderId,
path: this.path, path: this.path,
@@ -145,6 +149,7 @@ class LibraryItem {
return { return {
id: this.id, id: this.id,
ino: this.ino, ino: this.ino,
oldLibraryItemId: this.oldLibraryItemId,
libraryId: this.libraryId, libraryId: this.libraryId,
folderId: this.folderId, folderId: this.folderId,
path: this.path, path: this.path,
+12 -1
View File
@@ -115,13 +115,24 @@ class PlaybackSession {
this.userId = session.userId this.userId = session.userId
this.libraryId = session.libraryId || null this.libraryId = session.libraryId || null
this.libraryItemId = session.libraryItemId this.libraryItemId = session.libraryItemId
this.bookId = session.bookId this.bookId = session.bookId || null
this.episodeId = session.episodeId this.episodeId = session.episodeId
this.mediaType = session.mediaType this.mediaType = session.mediaType
this.duration = session.duration this.duration = session.duration
this.playMethod = session.playMethod this.playMethod = session.playMethod
this.mediaPlayer = session.mediaPlayer || null this.mediaPlayer = session.mediaPlayer || null
// Temp do not store old IDs
if (this.libraryId?.startsWith('lib_')) {
this.libraryId = null
}
if (this.libraryItemId?.startsWith('li_') || this.libraryItemId?.startsWith('local_')) {
this.libraryItemId = null
}
if (this.episodeId?.startsWith('ep_') || this.episodeId?.startsWith('local_')) {
this.episodeId = null
}
if (session.deviceInfo instanceof DeviceInfo) { if (session.deviceInfo instanceof DeviceInfo) {
this.deviceInfo = new DeviceInfo(session.deviceInfo.toJSON()) this.deviceInfo = new DeviceInfo(session.deviceInfo.toJSON())
} else { } else {
@@ -10,6 +10,7 @@ class PodcastEpisode {
this.libraryItemId = null this.libraryItemId = null
this.podcastId = null this.podcastId = null
this.id = null this.id = null
this.oldEpisodeId = null
this.index = null this.index = null
this.season = null this.season = null
@@ -36,6 +37,7 @@ class PodcastEpisode {
this.libraryItemId = episode.libraryItemId this.libraryItemId = episode.libraryItemId
this.podcastId = episode.podcastId this.podcastId = episode.podcastId
this.id = episode.id this.id = episode.id
this.oldEpisodeId = episode.oldEpisodeId
this.index = episode.index this.index = episode.index
this.season = episode.season this.season = episode.season
this.episode = episode.episode this.episode = episode.episode
@@ -59,6 +61,7 @@ class PodcastEpisode {
libraryItemId: this.libraryItemId, libraryItemId: this.libraryItemId,
podcastId: this.podcastId, podcastId: this.podcastId,
id: this.id, id: this.id,
oldEpisodeId: this.oldEpisodeId,
index: this.index, index: this.index,
season: this.season, season: this.season,
episode: this.episode, episode: this.episode,
@@ -81,6 +84,7 @@ class PodcastEpisode {
libraryItemId: this.libraryItemId, libraryItemId: this.libraryItemId,
podcastId: this.podcastId, podcastId: this.podcastId,
id: this.id, id: this.id,
oldEpisodeId: this.oldEpisodeId,
index: this.index, index: this.index,
season: this.season, season: this.season,
episode: this.episode, episode: this.episode,
+5
View File
@@ -335,6 +335,11 @@ class Podcast {
} }
getEpisode(episodeId) { getEpisode(episodeId) {
if (!episodeId) return null
// Support old episode ids for mobile downloads
if (episodeId.startsWith('ep_')) return this.episodes.find(ep => ep.oldEpisodeId == episodeId)
return this.episodes.find(ep => ep.id == episodeId) return this.episodes.find(ep => ep.id == episodeId)
} }
+18
View File
@@ -416,5 +416,23 @@ class User {
if (!progress) return false if (!progress) return false
return progress.removeFromContinueListening() return progress.removeFromContinueListening()
} }
/**
* Number of podcast episodes not finished for library item
* Note: libraryItem passed in from libraryHelpers is not a LibraryItem class instance
* @param {LibraryItem|object} libraryItem
* @returns {number}
*/
getNumEpisodesIncompleteForPodcast(libraryItem) {
if (!libraryItem?.media.episodes) return 0
let numEpisodesIncomplete = 0
for (const episode of libraryItem.media.episodes) {
const mediaProgress = this.getMediaProgress(libraryItem.id, episode.id)
if (!mediaProgress?.isFinished) {
numEpisodesIncomplete++
}
}
return numEpisodesIncomplete
}
} }
module.exports = User module.exports = User
+33 -17
View File
@@ -14,7 +14,7 @@ module.exports = {
getFilteredLibraryItems(libraryItems, filterBy, user, feedsArray) { getFilteredLibraryItems(libraryItems, filterBy, user, feedsArray) {
let filtered = libraryItems let filtered = libraryItems
const searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'missing', 'languages', 'tracks', 'ebooks'] const searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'publishers', 'missing', 'languages', 'tracks', 'ebooks']
const group = searchGroups.find(_group => filterBy.startsWith(_group + '.')) const group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) { if (group) {
const filterVal = filterBy.replace(`${group}.`, '') const filterVal = filterBy.replace(`${group}.`, '')
@@ -29,6 +29,7 @@ module.exports = {
} }
else if (group === 'authors') filtered = filtered.filter(li => li.isBook && li.media.metadata.hasAuthor(filter)) else if (group === 'authors') filtered = filtered.filter(li => li.isBook && li.media.metadata.hasAuthor(filter))
else if (group === 'narrators') filtered = filtered.filter(li => li.isBook && li.media.metadata.hasNarrator(filter)) else if (group === 'narrators') filtered = filtered.filter(li => li.isBook && li.media.metadata.hasNarrator(filter))
else if (group === 'publishers') filtered = filtered.filter(li => li.isBook && li.media.metadata.publisher === filter)
else if (group === 'progress') { else if (group === 'progress') {
filtered = filtered.filter(li => { filtered = filtered.filter(li => {
const itemProgress = user.getMediaProgress(li.id) const itemProgress = user.getMediaProgress(li.id)
@@ -82,16 +83,17 @@ module.exports = {
// Returns false if should be filtered out // Returns false if should be filtered out
checkFilterForSeriesLibraryItem(libraryItem, filterBy) { checkFilterForSeriesLibraryItem(libraryItem, filterBy) {
var searchGroups = ['genres', 'tags', 'authors', 'progress', 'narrators', 'languages'] const searchGroups = ['genres', 'tags', 'authors', 'progress', 'narrators', 'publishers', 'languages']
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.')) const group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) { if (group) {
var filterVal = filterBy.replace(`${group}.`, '') const filterVal = filterBy.replace(`${group}.`, '')
var filter = this.decode(filterVal) const filter = this.decode(filterVal)
if (group === 'genres') return libraryItem.media.metadata.genres.includes(filter) if (group === 'genres') return libraryItem.media.metadata.genres.includes(filter)
else if (group === 'tags') return libraryItem.media.tags.includes(filter) else if (group === 'tags') return libraryItem.media.tags.includes(filter)
else if (group === 'authors') return libraryItem.isBook && libraryItem.media.metadata.hasAuthor(filter) else if (group === 'authors') return libraryItem.isBook && libraryItem.media.metadata.hasAuthor(filter)
else if (group === 'narrators') return libraryItem.isBook && libraryItem.media.metadata.hasNarrator(filter) else if (group === 'narrators') return libraryItem.isBook && libraryItem.media.metadata.hasNarrator(filter)
else if (group === 'publishers') return libraryItem.isBook && libraryItem.media.metadata.publisher === filter
else if (group === 'languages') { else if (group === 'languages') {
return libraryItem.media.metadata.language === filter return libraryItem.media.metadata.language === filter
} }
@@ -123,27 +125,28 @@ module.exports = {
}, },
getDistinctFilterDataNew(libraryItems) { getDistinctFilterDataNew(libraryItems) {
var data = { const data = {
authors: [], authors: [],
genres: [], genres: [],
tags: [], tags: [],
series: [], series: [],
narrators: [], narrators: [],
languages: [] languages: [],
publishers: []
} }
libraryItems.forEach((li) => { libraryItems.forEach((li) => {
var mediaMetadata = li.media.metadata const mediaMetadata = li.media.metadata
if (mediaMetadata.authors && mediaMetadata.authors.length) { if (mediaMetadata.authors?.length) {
mediaMetadata.authors.forEach((author) => { mediaMetadata.authors.forEach((author) => {
if (author && !data.authors.find(au => au.id === author.id)) data.authors.push({ id: author.id, name: author.name }) if (author && !data.authors.some(au => au.id === author.id)) data.authors.push({ id: author.id, name: author.name })
}) })
} }
if (mediaMetadata.series && mediaMetadata.series.length) { if (mediaMetadata.series?.length) {
mediaMetadata.series.forEach((series) => { mediaMetadata.series.forEach((series) => {
if (series && !data.series.find(se => se.id === series.id)) data.series.push({ id: series.id, name: series.name }) if (series && !data.series.some(se => se.id === series.id)) data.series.push({ id: series.id, name: series.name })
}) })
} }
if (mediaMetadata.genres && mediaMetadata.genres.length) { if (mediaMetadata.genres?.length) {
mediaMetadata.genres.forEach((genre) => { mediaMetadata.genres.forEach((genre) => {
if (genre && !data.genres.includes(genre)) data.genres.push(genre) if (genre && !data.genres.includes(genre)) data.genres.push(genre)
}) })
@@ -153,18 +156,24 @@ module.exports = {
if (tag && !data.tags.includes(tag)) data.tags.push(tag) if (tag && !data.tags.includes(tag)) data.tags.push(tag)
}) })
} }
if (mediaMetadata.narrators && mediaMetadata.narrators.length) { if (mediaMetadata.narrators?.length) {
mediaMetadata.narrators.forEach((narrator) => { mediaMetadata.narrators.forEach((narrator) => {
if (narrator && !data.narrators.includes(narrator)) data.narrators.push(narrator) if (narrator && !data.narrators.includes(narrator)) data.narrators.push(narrator)
}) })
} }
if (mediaMetadata.language && !data.languages.includes(mediaMetadata.language)) data.languages.push(mediaMetadata.language) if (mediaMetadata.publisher && !data.publishers.includes(mediaMetadata.publisher)) {
data.publishers.push(mediaMetadata.publisher)
}
if (mediaMetadata.language && !data.languages.includes(mediaMetadata.language)) {
data.languages.push(mediaMetadata.language)
}
}) })
data.authors = naturalSort(data.authors).asc(au => au.name) data.authors = naturalSort(data.authors).asc(au => au.name)
data.genres = naturalSort(data.genres).asc() data.genres = naturalSort(data.genres).asc()
data.tags = naturalSort(data.tags).asc() data.tags = naturalSort(data.tags).asc()
data.series = naturalSort(data.series).asc(se => se.name) data.series = naturalSort(data.series).asc(se => se.name)
data.narrators = naturalSort(data.narrators).asc() data.narrators = naturalSort(data.narrators).asc()
data.publishers = naturalSort(data.publishers).asc()
data.languages = naturalSort(data.languages).asc() data.languages = naturalSort(data.languages).asc()
return data return data
}, },
@@ -351,6 +360,7 @@ module.exports = {
const mediaType = library.mediaType const mediaType = library.mediaType
const isPodcastLibrary = mediaType === 'podcast' const isPodcastLibrary = mediaType === 'podcast'
const includeRssFeed = include.includes('rssfeed') const includeRssFeed = include.includes('rssfeed')
const includeNumEpisodesIncomplete = include.includes('numepisodesincomplete') // Podcasts only
const hideSingleBookSeries = library.settings.hideSingleBookSeries const hideSingleBookSeries = library.settings.hideSingleBookSeries
const shelves = [ const shelves = [
@@ -447,12 +457,18 @@ module.exports = {
for (const libraryItem of libraryItems) { for (const libraryItem of libraryItems) {
if (libraryItem.addedAt > categoryMap['recently-added'].smallest) { if (libraryItem.addedAt > categoryMap['recently-added'].smallest) {
const libraryItemObj = libraryItem.toJSONMinified()
// add numEpisodesIncomplete if "include=numEpisodesIncomplete" was put in query string (only for podcasts)
if (includeNumEpisodesIncomplete && libraryItem.isPodcast) {
libraryItemObj.numEpisodesIncomplete = user.getNumEpisodesIncompleteForPodcast(libraryItem)
}
const indexToPut = categoryMap['recently-added'].items.findIndex(i => libraryItem.addedAt > i.addedAt) const indexToPut = categoryMap['recently-added'].items.findIndex(i => libraryItem.addedAt > i.addedAt)
if (indexToPut >= 0) { if (indexToPut >= 0) {
categoryMap['recently-added'].items.splice(indexToPut, 0, libraryItem.toJSONMinified()) categoryMap['recently-added'].items.splice(indexToPut, 0, libraryItemObj)
} else { } else {
categoryMap['recently-added'].items.push(libraryItem.toJSONMinified()) categoryMap['recently-added'].items.push(libraryItemObj)
} }
if (categoryMap['recently-added'].items.length > maxEntitiesPerShelf) { if (categoryMap['recently-added'].items.length > maxEntitiesPerShelf) {
+581 -78
View File
@@ -1,3 +1,4 @@
const { DataTypes, QueryInterface } = require('sequelize')
const Path = require('path') const Path = require('path')
const uuidv4 = require("uuid").v4 const uuidv4 = require("uuid").v4
const Logger = require('../../Logger') const Logger = require('../../Logger')
@@ -17,29 +18,6 @@ const oldDbIdMap = {
podcasts: {}, // key is library item id podcasts: {}, // key is library item id
devices: {} // key is a json stringify of the old DeviceInfo data OR deviceId if it exists devices: {} // key is a json stringify of the old DeviceInfo data OR deviceId if it exists
} }
const newRecords = {
user: [],
library: [],
libraryFolder: [],
author: [],
book: [],
podcast: [],
libraryItem: [],
bookAuthor: [],
series: [],
bookSeries: [],
podcastEpisode: [],
mediaProgress: [],
device: [],
playbackSession: [],
collection: [],
collectionBook: [],
playlist: [],
playlistMediaItem: [],
feed: [],
feedEpisode: [],
setting: []
}
function getDeviceInfoString(deviceInfo, UserId) { function getDeviceInfoString(deviceInfo, UserId) {
if (!deviceInfo) return null if (!deviceInfo) return null
@@ -60,9 +38,22 @@ function getDeviceInfoString(deviceInfo, UserId) {
return 'temp-' + Buffer.from(keys.join('-'), 'utf-8').toString('base64') return 'temp-' + Buffer.from(keys.join('-'), 'utf-8').toString('base64')
} }
/**
* Migrate oldLibraryItem.media to Book model
* Migrate BookSeries and BookAuthor
* @param {objects.LibraryItem} oldLibraryItem
* @param {object} LibraryItem models.LibraryItem object
* @returns {object} { book: object, bookSeries: [], bookAuthor: [] }
*/
function migrateBook(oldLibraryItem, LibraryItem) { function migrateBook(oldLibraryItem, LibraryItem) {
const oldBook = oldLibraryItem.media const oldBook = oldLibraryItem.media
const _newRecords = {
book: null,
bookSeries: [],
bookAuthor: []
}
// //
// Migrate Book // Migrate Book
// //
@@ -91,17 +82,23 @@ function migrateBook(oldLibraryItem, LibraryItem) {
tags: oldBook.tags, tags: oldBook.tags,
genres: oldBook.metadata.genres genres: oldBook.metadata.genres
} }
newRecords.book.push(Book) _newRecords.book = Book
oldDbIdMap.books[oldLibraryItem.id] = Book.id oldDbIdMap.books[oldLibraryItem.id] = Book.id
// //
// Migrate BookAuthors // Migrate BookAuthors
// //
const bookAuthorsInserted = []
for (const oldBookAuthor of oldBook.metadata.authors) { for (const oldBookAuthor of oldBook.metadata.authors) {
if (oldDbIdMap.authors[LibraryItem.libraryId][oldBookAuthor.id]) { if (oldDbIdMap.authors[LibraryItem.libraryId][oldBookAuthor.id]) {
newRecords.bookAuthor.push({ const authorId = oldDbIdMap.authors[LibraryItem.libraryId][oldBookAuthor.id]
if (bookAuthorsInserted.includes(authorId)) continue // Duplicate prevention
bookAuthorsInserted.push(authorId)
_newRecords.bookAuthor.push({
id: uuidv4(), id: uuidv4(),
authorId: oldDbIdMap.authors[LibraryItem.libraryId][oldBookAuthor.id], authorId,
bookId: Book.id bookId: Book.id
}) })
} else { } else {
@@ -112,22 +109,40 @@ function migrateBook(oldLibraryItem, LibraryItem) {
// //
// Migrate BookSeries // Migrate BookSeries
// //
const bookSeriesInserted = []
for (const oldBookSeries of oldBook.metadata.series) { for (const oldBookSeries of oldBook.metadata.series) {
if (oldDbIdMap.series[LibraryItem.libraryId][oldBookSeries.id]) { if (oldDbIdMap.series[LibraryItem.libraryId][oldBookSeries.id]) {
const BookSeries = { const seriesId = oldDbIdMap.series[LibraryItem.libraryId][oldBookSeries.id]
if (bookSeriesInserted.includes(seriesId)) continue // Duplicate prevention
bookSeriesInserted.push(seriesId)
_newRecords.bookSeries.push({
id: uuidv4(), id: uuidv4(),
sequence: oldBookSeries.sequence, sequence: oldBookSeries.sequence,
seriesId: oldDbIdMap.series[LibraryItem.libraryId][oldBookSeries.id], seriesId: oldDbIdMap.series[LibraryItem.libraryId][oldBookSeries.id],
bookId: Book.id bookId: Book.id
} })
newRecords.bookSeries.push(BookSeries)
} else { } else {
Logger.warn(`[dbMigration] migrateBook: Series not found "${oldBookSeries.name}"`) Logger.warn(`[dbMigration] migrateBook: Series not found "${oldBookSeries.name}"`)
} }
} }
return _newRecords
} }
/**
* Migrate oldLibraryItem.media to Podcast model
* Migrate PodcastEpisode
* @param {objects.LibraryItem} oldLibraryItem
* @param {object} LibraryItem models.LibraryItem object
* @returns {object} { podcast: object, podcastEpisode: [] }
*/
function migratePodcast(oldLibraryItem, LibraryItem) { function migratePodcast(oldLibraryItem, LibraryItem) {
const _newRecords = {
podcast: null,
podcastEpisode: []
}
const oldPodcast = oldLibraryItem.media const oldPodcast = oldLibraryItem.media
const oldPodcastMetadata = oldPodcast.metadata const oldPodcastMetadata = oldPodcast.metadata
@@ -161,7 +176,7 @@ function migratePodcast(oldLibraryItem, LibraryItem) {
tags: oldPodcast.tags, tags: oldPodcast.tags,
genres: oldPodcastMetadata.genres genres: oldPodcastMetadata.genres
} }
newRecords.podcast.push(Podcast) _newRecords.podcast = Podcast
oldDbIdMap.podcasts[oldLibraryItem.id] = Podcast.id oldDbIdMap.podcasts[oldLibraryItem.id] = Podcast.id
// //
@@ -173,6 +188,7 @@ function migratePodcast(oldLibraryItem, LibraryItem) {
const PodcastEpisode = { const PodcastEpisode = {
id: uuidv4(), id: uuidv4(),
oldEpisodeId: oldEpisode.id,
index: oldEpisode.index, index: oldEpisode.index,
season: oldEpisode.season || null, season: oldEpisode.season || null,
episode: oldEpisode.episode || null, episode: oldEpisode.episode || null,
@@ -191,12 +207,26 @@ function migratePodcast(oldLibraryItem, LibraryItem) {
audioFile: oldEpisode.audioFile, audioFile: oldEpisode.audioFile,
chapters: oldEpisode.chapters || [] chapters: oldEpisode.chapters || []
} }
newRecords.podcastEpisode.push(PodcastEpisode) _newRecords.podcastEpisode.push(PodcastEpisode)
oldDbIdMap.podcastEpisodes[oldEpisode.id] = PodcastEpisode.id oldDbIdMap.podcastEpisodes[oldEpisode.id] = PodcastEpisode.id
} }
return _newRecords
} }
/**
* Migrate libraryItems to LibraryItem, Book, Podcast models
* @param {Array<objects.LibraryItem>} oldLibraryItems
* @returns {object} { libraryItem: [], book: [], podcast: [], podcastEpisode: [], bookSeries: [], bookAuthor: [] }
*/
function migrateLibraryItems(oldLibraryItems) { function migrateLibraryItems(oldLibraryItems) {
const _newRecords = {
book: [],
podcast: [],
podcastEpisode: [],
bookSeries: [],
bookAuthor: [],
libraryItem: []
}
for (const oldLibraryItem of oldLibraryItems) { for (const oldLibraryItem of oldLibraryItems) {
const libraryFolderId = oldDbIdMap.libraryFolders[oldLibraryItem.folderId] const libraryFolderId = oldDbIdMap.libraryFolders[oldLibraryItem.folderId]
if (!libraryFolderId) { if (!libraryFolderId) {
@@ -218,6 +248,7 @@ function migrateLibraryItems(oldLibraryItems) {
// //
const LibraryItem = { const LibraryItem = {
id: uuidv4(), id: uuidv4(),
oldLibraryItemId: oldLibraryItem.id,
ino: oldLibraryItem.ino, ino: oldLibraryItem.ino,
path: oldLibraryItem.path, path: oldLibraryItem.path,
relPath: oldLibraryItem.relPath, relPath: oldLibraryItem.relPath,
@@ -241,22 +272,39 @@ function migrateLibraryItems(oldLibraryItems) {
}) })
} }
oldDbIdMap.libraryItems[oldLibraryItem.id] = LibraryItem.id oldDbIdMap.libraryItems[oldLibraryItem.id] = LibraryItem.id
newRecords.libraryItem.push(LibraryItem) _newRecords.libraryItem.push(LibraryItem)
// //
// Migrate Book/Podcast // Migrate Book/Podcast
// //
if (oldLibraryItem.mediaType === 'book') { if (oldLibraryItem.mediaType === 'book') {
migrateBook(oldLibraryItem, LibraryItem) const bookRecords = migrateBook(oldLibraryItem, LibraryItem)
_newRecords.book.push(bookRecords.book)
_newRecords.bookAuthor.push(...bookRecords.bookAuthor)
_newRecords.bookSeries.push(...bookRecords.bookSeries)
LibraryItem.mediaId = oldDbIdMap.books[oldLibraryItem.id] LibraryItem.mediaId = oldDbIdMap.books[oldLibraryItem.id]
} else if (oldLibraryItem.mediaType === 'podcast') { } else if (oldLibraryItem.mediaType === 'podcast') {
migratePodcast(oldLibraryItem, LibraryItem) const podcastRecords = migratePodcast(oldLibraryItem, LibraryItem)
_newRecords.podcast.push(podcastRecords.podcast)
_newRecords.podcastEpisode.push(...podcastRecords.podcastEpisode)
LibraryItem.mediaId = oldDbIdMap.podcasts[oldLibraryItem.id] LibraryItem.mediaId = oldDbIdMap.podcasts[oldLibraryItem.id]
} }
} }
return _newRecords
} }
/**
* Migrate Library and LibraryFolder
* @param {Array<objects.Library>} oldLibraries
* @returns {object} { library: [], libraryFolder: [] }
*/
function migrateLibraries(oldLibraries) { function migrateLibraries(oldLibraries) {
const _newRecords = {
library: [],
libraryFolder: []
}
for (const oldLibrary of oldLibraries) { for (const oldLibrary of oldLibraries) {
if (!['book', 'podcast'].includes(oldLibrary.mediaType)) { if (!['book', 'podcast'].includes(oldLibrary.mediaType)) {
Logger.error(`[dbMigration] migrateLibraries: Not migrating library with mediaType=${oldLibrary.mediaType}`) Logger.error(`[dbMigration] migrateLibraries: Not migrating library with mediaType=${oldLibrary.mediaType}`)
@@ -268,6 +316,7 @@ function migrateLibraries(oldLibraries) {
// //
const Library = { const Library = {
id: uuidv4(), id: uuidv4(),
oldLibraryId: oldLibrary.id,
name: oldLibrary.name, name: oldLibrary.name,
displayOrder: oldLibrary.displayOrder, displayOrder: oldLibrary.displayOrder,
icon: oldLibrary.icon || null, icon: oldLibrary.icon || null,
@@ -278,7 +327,7 @@ function migrateLibraries(oldLibraries) {
updatedAt: oldLibrary.lastUpdate updatedAt: oldLibrary.lastUpdate
} }
oldDbIdMap.libraries[oldLibrary.id] = Library.id oldDbIdMap.libraries[oldLibrary.id] = Library.id
newRecords.library.push(Library) _newRecords.library.push(Library)
// //
// Migrate LibraryFolders // Migrate LibraryFolders
@@ -292,12 +341,21 @@ function migrateLibraries(oldLibraries) {
libraryId: Library.id libraryId: Library.id
} }
oldDbIdMap.libraryFolders[oldFolder.id] = LibraryFolder.id oldDbIdMap.libraryFolders[oldFolder.id] = LibraryFolder.id
newRecords.libraryFolder.push(LibraryFolder) _newRecords.libraryFolder.push(LibraryFolder)
} }
} }
return _newRecords
} }
/**
* Migrate Author
* Previously Authors were shared between libraries, this will ensure every author has one library
* @param {Array<objects.entities.Author>} oldAuthors
* @param {Array<objects.LibraryItem>} oldLibraryItems
* @returns {Array<object>} Array of Author model objs
*/
function migrateAuthors(oldAuthors, oldLibraryItems) { function migrateAuthors(oldAuthors, oldLibraryItems) {
const _newRecords = []
for (const oldAuthor of oldAuthors) { for (const oldAuthor of oldAuthors) {
// Get an array of NEW library ids that have this author // Get an array of NEW library ids that have this author
const librariesWithThisAuthor = [...new Set(oldLibraryItems.map(li => { const librariesWithThisAuthor = [...new Set(oldLibraryItems.map(li => {
@@ -325,12 +383,21 @@ function migrateAuthors(oldAuthors, oldLibraryItems) {
} }
if (!oldDbIdMap.authors[libraryId]) oldDbIdMap.authors[libraryId] = {} if (!oldDbIdMap.authors[libraryId]) oldDbIdMap.authors[libraryId] = {}
oldDbIdMap.authors[libraryId][oldAuthor.id] = Author.id oldDbIdMap.authors[libraryId][oldAuthor.id] = Author.id
newRecords.author.push(Author) _newRecords.push(Author)
} }
} }
return _newRecords
} }
/**
* Migrate Series
* Previously Series were shared between libraries, this will ensure every series has one library
* @param {Array<objects.entities.Series>} oldSerieses
* @param {Array<objects.LibraryItem>} oldLibraryItems
* @returns {Array<object>} Array of Series model objs
*/
function migrateSeries(oldSerieses, oldLibraryItems) { function migrateSeries(oldSerieses, oldLibraryItems) {
const _newRecords = []
// Originaly series were shared between libraries if they had the same name // Originaly series were shared between libraries if they had the same name
// Series will be separate between libraries // Series will be separate between libraries
for (const oldSeries of oldSerieses) { for (const oldSeries of oldSerieses) {
@@ -355,16 +422,47 @@ function migrateSeries(oldSerieses, oldLibraryItems) {
} }
if (!oldDbIdMap.series[libraryId]) oldDbIdMap.series[libraryId] = {} if (!oldDbIdMap.series[libraryId]) oldDbIdMap.series[libraryId] = {}
oldDbIdMap.series[libraryId][oldSeries.id] = Series.id oldDbIdMap.series[libraryId][oldSeries.id] = Series.id
newRecords.series.push(Series) _newRecords.push(Series)
} }
} }
return _newRecords
} }
/**
* Migrate users to User and MediaProgress models
* @param {Array<objects.User>} oldUsers
* @returns {object} { user: [], mediaProgress: [] }
*/
function migrateUsers(oldUsers) { function migrateUsers(oldUsers) {
const _newRecords = {
user: [],
mediaProgress: []
}
for (const oldUser of oldUsers) { for (const oldUser of oldUsers) {
// //
// Migrate User // Migrate User
// //
// Convert old library ids to new ids
const librariesAccessible = (oldUser.librariesAccessible || []).map((lid) => oldDbIdMap.libraries[lid]).filter(li => li)
// Convert old library item ids to new ids
const bookmarks = (oldUser.bookmarks || []).map(bm => {
bm.libraryItemId = oldDbIdMap.libraryItems[bm.libraryItemId]
return bm
}).filter(bm => bm.libraryItemId)
// Convert old series ids to new
const seriesHideFromContinueListening = (oldUser.seriesHideFromContinueListening || []).map(oldSeriesId => {
// Series were split to be per library
// This will use the first series it finds
for (const libraryId in oldDbIdMap.series) {
if (oldDbIdMap.series[libraryId][oldSeriesId]) {
return oldDbIdMap.series[libraryId][oldSeriesId]
}
}
return null
}).filter(se => se)
const User = { const User = {
id: uuidv4(), id: uuidv4(),
username: oldUser.username, username: oldUser.username,
@@ -374,19 +472,19 @@ function migrateUsers(oldUsers) {
isActive: !!oldUser.isActive, isActive: !!oldUser.isActive,
lastSeen: oldUser.lastSeen || null, lastSeen: oldUser.lastSeen || null,
extraData: { extraData: {
seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [], seriesHideFromContinueListening,
oldUserId: oldUser.id // Used to keep old tokens oldUserId: oldUser.id // Used to keep old tokens
}, },
createdAt: oldUser.createdAt || Date.now(), createdAt: oldUser.createdAt || Date.now(),
permissions: { permissions: {
...oldUser.permissions, ...oldUser.permissions,
librariesAccessible: oldUser.librariesAccessible || [], librariesAccessible,
itemTagsSelected: oldUser.itemTagsSelected || [] itemTagsSelected: oldUser.itemTagsSelected || []
}, },
bookmarks: oldUser.bookmarks bookmarks
} }
oldDbIdMap.users[oldUser.id] = User.id oldDbIdMap.users[oldUser.id] = User.id
newRecords.user.push(User) _newRecords.user.push(User)
// //
// Migrate MediaProgress // Migrate MediaProgress
@@ -425,12 +523,23 @@ function migrateUsers(oldUsers) {
progress: oldMediaProgress.progress progress: oldMediaProgress.progress
} }
} }
newRecords.mediaProgress.push(MediaProgress) _newRecords.mediaProgress.push(MediaProgress)
} }
} }
return _newRecords
} }
/**
* Migrate playbackSessions to PlaybackSession and Device models
* @param {Array<objects.PlaybackSession>} oldSessions
* @returns {object} { playbackSession: [], device: [] }
*/
function migrateSessions(oldSessions) { function migrateSessions(oldSessions) {
const _newRecords = {
device: [],
playbackSession: []
}
for (const oldSession of oldSessions) { for (const oldSession of oldSessions) {
const userId = oldDbIdMap.users[oldSession.userId] const userId = oldDbIdMap.users[oldSession.userId]
if (!userId) { if (!userId) {
@@ -495,12 +604,12 @@ function migrateSessions(oldSessions) {
userId, userId,
extraData extraData
} }
newRecords.device.push(Device) deviceId = Device.id
_newRecords.device.push(Device)
oldDbIdMap.devices[deviceDeviceId] = Device.id oldDbIdMap.devices[deviceDeviceId] = Device.id
} }
} }
// //
// Migrate PlaybackSession // Migrate PlaybackSession
// //
@@ -528,7 +637,7 @@ function migrateSessions(oldSessions) {
serverVersion: oldSession.deviceInfo?.serverVersion || null, serverVersion: oldSession.deviceInfo?.serverVersion || null,
createdAt: oldSession.startedAt, createdAt: oldSession.startedAt,
updatedAt: oldSession.updatedAt, updatedAt: oldSession.updatedAt,
userId, // Can be null userId,
deviceId, deviceId,
timeListening: oldSession.timeListening, timeListening: oldSession.timeListening,
coverPath: oldSession.coverPath, coverPath: oldSession.coverPath,
@@ -539,11 +648,21 @@ function migrateSessions(oldSessions) {
libraryItemId: oldDbIdMap.libraryItems[oldSession.libraryItemId] libraryItemId: oldDbIdMap.libraryItems[oldSession.libraryItemId]
} }
} }
newRecords.playbackSession.push(PlaybackSession) _newRecords.playbackSession.push(PlaybackSession)
} }
return _newRecords
} }
/**
* Migrate collections to Collection & CollectionBook
* @param {Array<objects.Collection>} oldCollections
* @returns {object} { collection: [], collectionBook: [] }
*/
function migrateCollections(oldCollections) { function migrateCollections(oldCollections) {
const _newRecords = {
collection: [],
collectionBook: []
}
for (const oldCollection of oldCollections) { for (const oldCollection of oldCollections) {
const libraryId = oldDbIdMap.libraries[oldCollection.libraryId] const libraryId = oldDbIdMap.libraries[oldCollection.libraryId]
if (!libraryId) { if (!libraryId) {
@@ -566,7 +685,7 @@ function migrateCollections(oldCollections) {
libraryId libraryId
} }
oldDbIdMap.collections[oldCollection.id] = Collection.id oldDbIdMap.collections[oldCollection.id] = Collection.id
newRecords.collection.push(Collection) _newRecords.collection.push(Collection)
let order = 1 let order = 1
BookIds.forEach((bookId) => { BookIds.forEach((bookId) => {
@@ -577,12 +696,22 @@ function migrateCollections(oldCollections) {
collectionId: Collection.id, collectionId: Collection.id,
order: order++ order: order++
} }
newRecords.collectionBook.push(CollectionBook) _newRecords.collectionBook.push(CollectionBook)
}) })
} }
return _newRecords
} }
/**
* Migrate playlists to Playlist and PlaylistMediaItem
* @param {Array<objects.Playlist>} oldPlaylists
* @returns {object} { playlist: [], playlistMediaItem: [] }
*/
function migratePlaylists(oldPlaylists) { function migratePlaylists(oldPlaylists) {
const _newRecords = {
playlist: [],
playlistMediaItem: []
}
for (const oldPlaylist of oldPlaylists) { for (const oldPlaylist of oldPlaylists) {
const libraryId = oldDbIdMap.libraries[oldPlaylist.libraryId] const libraryId = oldDbIdMap.libraries[oldPlaylist.libraryId]
if (!libraryId) { if (!libraryId) {
@@ -622,7 +751,7 @@ function migratePlaylists(oldPlaylists) {
userId, userId,
libraryId libraryId
} }
newRecords.playlist.push(Playlist) _newRecords.playlist.push(Playlist)
let order = 1 let order = 1
MediaItemIds.forEach((mediaItemId) => { MediaItemIds.forEach((mediaItemId) => {
@@ -634,12 +763,22 @@ function migratePlaylists(oldPlaylists) {
playlistId: Playlist.id, playlistId: Playlist.id,
order: order++ order: order++
} }
newRecords.playlistMediaItem.push(PlaylistMediaItem) _newRecords.playlistMediaItem.push(PlaylistMediaItem)
}) })
} }
return _newRecords
} }
/**
* Migrate feeds to Feed and FeedEpisode models
* @param {Array<objects.Feed>} oldFeeds
* @returns {object} { feed: [], feedEpisode: [] }
*/
function migrateFeeds(oldFeeds) { function migrateFeeds(oldFeeds) {
const _newRecords = {
feed: [],
feedEpisode: []
}
for (const oldFeed of oldFeeds) { for (const oldFeed of oldFeeds) {
if (!oldFeed.episodes?.length) { if (!oldFeed.episodes?.length) {
continue continue
@@ -698,7 +837,7 @@ function migrateFeeds(oldFeeds) {
updatedAt: oldFeed.updatedAt, updatedAt: oldFeed.updatedAt,
userId userId
} }
newRecords.feed.push(Feed) _newRecords.feed.push(Feed)
// //
// Migrate FeedEpisodes // Migrate FeedEpisodes
@@ -724,65 +863,227 @@ function migrateFeeds(oldFeeds) {
updatedAt: oldFeed.updatedAt, updatedAt: oldFeed.updatedAt,
feedId: Feed.id feedId: Feed.id
} }
newRecords.feedEpisode.push(FeedEpisode) _newRecords.feedEpisode.push(FeedEpisode)
} }
} }
return _newRecords
} }
/**
* Migrate ServerSettings, NotificationSettings and EmailSettings to Setting model
* @param {Array<objects.settings.*>} oldSettings
* @returns {Array<object>} Array of Setting model objs
*/
function migrateSettings(oldSettings) { function migrateSettings(oldSettings) {
const _newRecords = []
const serverSettings = oldSettings.find(s => s.id === 'server-settings') const serverSettings = oldSettings.find(s => s.id === 'server-settings')
const notificationSettings = oldSettings.find(s => s.id === 'notification-settings') const notificationSettings = oldSettings.find(s => s.id === 'notification-settings')
const emailSettings = oldSettings.find(s => s.id === 'email-settings') const emailSettings = oldSettings.find(s => s.id === 'email-settings')
if (serverSettings) { if (serverSettings) {
newRecords.setting.push({ _newRecords.push({
key: 'server-settings', key: 'server-settings',
value: serverSettings value: serverSettings
}) })
} }
if (notificationSettings) { if (notificationSettings) {
newRecords.setting.push({ _newRecords.push({
key: 'notification-settings', key: 'notification-settings',
value: notificationSettings value: notificationSettings
}) })
} }
if (emailSettings) { if (emailSettings) {
newRecords.setting.push({ _newRecords.push({
key: 'email-settings', key: 'email-settings',
value: emailSettings value: emailSettings
}) })
} }
return _newRecords
}
/**
* Load old libraries and bulkCreate new Library and LibraryFolder rows
* @param {Map<string,Model>} DatabaseModels
*/
async function handleMigrateLibraries(DatabaseModels) {
const oldLibraries = await oldDbFiles.loadOldData('libraries')
const newLibraryRecords = migrateLibraries(oldLibraries)
for (const model in newLibraryRecords) {
Logger.info(`[dbMigration] Inserting ${newLibraryRecords[model].length} ${model} rows`)
await DatabaseModels[model].bulkCreate(newLibraryRecords[model])
}
}
/**
* Load old EmailSettings, NotificationSettings and ServerSettings and bulkCreate new Setting rows
* @param {Map<string,Model>} DatabaseModels
*/
async function handleMigrateSettings(DatabaseModels) {
const oldSettings = await oldDbFiles.loadOldData('settings')
const newSettings = migrateSettings(oldSettings)
Logger.info(`[dbMigration] Inserting ${newSettings.length} setting rows`)
await DatabaseModels.setting.bulkCreate(newSettings)
}
/**
* Load old authors and bulkCreate new Author rows
* @param {Map<string,Model>} DatabaseModels
* @param {Array<objects.LibraryItem>} oldLibraryItems
*/
async function handleMigrateAuthors(DatabaseModels, oldLibraryItems) {
const oldAuthors = await oldDbFiles.loadOldData('authors')
const newAuthors = migrateAuthors(oldAuthors, oldLibraryItems)
Logger.info(`[dbMigration] Inserting ${newAuthors.length} author rows`)
await DatabaseModels.author.bulkCreate(newAuthors)
}
/**
* Load old series and bulkCreate new Series rows
* @param {Map<string,Model>} DatabaseModels
* @param {Array<objects.LibraryItem>} oldLibraryItems
*/
async function handleMigrateSeries(DatabaseModels, oldLibraryItems) {
const oldSeries = await oldDbFiles.loadOldData('series')
const newSeries = migrateSeries(oldSeries, oldLibraryItems)
Logger.info(`[dbMigration] Inserting ${newSeries.length} series rows`)
await DatabaseModels.series.bulkCreate(newSeries)
}
/**
* bulkCreate new LibraryItem, Book and Podcast rows
* @param {Map<string,Model>} DatabaseModels
* @param {Array<objects.LibraryItem>} oldLibraryItems
*/
async function handleMigrateLibraryItems(DatabaseModels, oldLibraryItems) {
const newItemsBooksPodcasts = migrateLibraryItems(oldLibraryItems)
for (const model in newItemsBooksPodcasts) {
Logger.info(`[dbMigration] Inserting ${newItemsBooksPodcasts[model].length} ${model} rows`)
await DatabaseModels[model].bulkCreate(newItemsBooksPodcasts[model])
}
}
/**
* Migrate authors, series then library items in chunks
* Authors and series require old library items loaded first
* @param {Map<string,Model>} DatabaseModels
*/
async function handleMigrateAuthorsSeriesAndLibraryItems(DatabaseModels) {
const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems')
await handleMigrateAuthors(DatabaseModels, oldLibraryItems)
await handleMigrateSeries(DatabaseModels, oldLibraryItems)
// Migrate library items in chunks of 1000
const numChunks = Math.ceil(oldLibraryItems.length / 1000)
for (let i = 0; i < numChunks; i++) {
let start = i * 1000
await handleMigrateLibraryItems(DatabaseModels, oldLibraryItems.slice(start, start + 1000))
}
}
/**
* Load old users and bulkCreate new User rows
* @param {Map<string,Model>} DatabaseModels
*/
async function handleMigrateUsers(DatabaseModels) {
const oldUsers = await oldDbFiles.loadOldData('users')
const newUserRecords = migrateUsers(oldUsers)
for (const model in newUserRecords) {
Logger.info(`[dbMigration] Inserting ${newUserRecords[model].length} ${model} rows`)
await DatabaseModels[model].bulkCreate(newUserRecords[model])
}
}
/**
* Load old sessions and bulkCreate new PlaybackSession & Device rows
* @param {Map<string,Model>} DatabaseModels
*/
async function handleMigrateSessions(DatabaseModels) {
const oldSessions = await oldDbFiles.loadOldData('sessions')
let chunkSize = 1000
let numChunks = Math.ceil(oldSessions.length / chunkSize)
for (let i = 0; i < numChunks; i++) {
let start = i * chunkSize
const newSessionRecords = migrateSessions(oldSessions.slice(start, start + chunkSize))
for (const model in newSessionRecords) {
Logger.info(`[dbMigration] Inserting ${newSessionRecords[model].length} ${model} rows`)
await DatabaseModels[model].bulkCreate(newSessionRecords[model])
}
}
}
/**
* Load old collections and bulkCreate new Collection, CollectionBook models
* @param {Map<string,Model>} DatabaseModels
*/
async function handleMigrateCollections(DatabaseModels) {
const oldCollections = await oldDbFiles.loadOldData('collections')
const newCollectionRecords = migrateCollections(oldCollections)
for (const model in newCollectionRecords) {
Logger.info(`[dbMigration] Inserting ${newCollectionRecords[model].length} ${model} rows`)
await DatabaseModels[model].bulkCreate(newCollectionRecords[model])
}
}
/**
* Load old playlists and bulkCreate new Playlist, PlaylistMediaItem models
* @param {Map<string,Model>} DatabaseModels
*/
async function handleMigratePlaylists(DatabaseModels) {
const oldPlaylists = await oldDbFiles.loadOldData('playlists')
const newPlaylistRecords = migratePlaylists(oldPlaylists)
for (const model in newPlaylistRecords) {
Logger.info(`[dbMigration] Inserting ${newPlaylistRecords[model].length} ${model} rows`)
await DatabaseModels[model].bulkCreate(newPlaylistRecords[model])
}
}
/**
* Load old feeds and bulkCreate new Feed, FeedEpisode models
* @param {Map<string,Model>} DatabaseModels
*/
async function handleMigrateFeeds(DatabaseModels) {
const oldFeeds = await oldDbFiles.loadOldData('feeds')
const newFeedRecords = migrateFeeds(oldFeeds)
for (const model in newFeedRecords) {
Logger.info(`[dbMigration] Inserting ${newFeedRecords[model].length} ${model} rows`)
await DatabaseModels[model].bulkCreate(newFeedRecords[model])
}
} }
module.exports.migrate = async (DatabaseModels) => { module.exports.migrate = async (DatabaseModels) => {
Logger.info(`[dbMigration] Starting migration`) Logger.info(`[dbMigration] Starting migration`)
const data = await oldDbFiles.init()
const start = Date.now() const start = Date.now()
migrateSettings(data.settings)
migrateLibraries(data.libraries)
migrateAuthors(data.authors, data.libraryItems)
migrateSeries(data.series, data.libraryItems)
migrateLibraryItems(data.libraryItems)
migrateUsers(data.users)
migrateSessions(data.sessions)
migrateCollections(data.collections)
migratePlaylists(data.playlists)
migrateFeeds(data.feeds)
let totalRecords = 0 // Migrate to Library and LibraryFolder models
for (const model in newRecords) { await handleMigrateLibraries(DatabaseModels)
Logger.info(`[dbMigration] Inserting ${newRecords[model].length} ${model} rows`)
if (newRecords[model].length) {
await DatabaseModels[model].bulkCreate(newRecords[model])
totalRecords += newRecords[model].length
}
}
const elapsed = Date.now() - start // Migrate EmailSettings, NotificationSettings and ServerSettings to Setting model
await handleMigrateSettings(DatabaseModels)
// Migrate Series, Author, LibraryItem, Book, Podcast
await handleMigrateAuthorsSeriesAndLibraryItems(DatabaseModels)
// Migrate User, MediaProgress
await handleMigrateUsers(DatabaseModels)
// Migrate PlaybackSession, Device
await handleMigrateSessions(DatabaseModels)
// Migrate Collection, CollectionBook
await handleMigrateCollections(DatabaseModels)
// Migrate Playlist, PlaylistMediaItem
await handleMigratePlaylists(DatabaseModels)
// Migrate Feed, FeedEpisode
await handleMigrateFeeds(DatabaseModels)
// Purge author images and cover images from cache // Purge author images and cover images from cache
try { try {
@@ -796,7 +1097,8 @@ module.exports.migrate = async (DatabaseModels) => {
// Put all old db folders into a zipfile oldDb.zip // Put all old db folders into a zipfile oldDb.zip
await oldDbFiles.zipWrapOldDb() await oldDbFiles.zipWrapOldDb()
Logger.info(`[dbMigration] Migration complete. ${totalRecords} rows. Elapsed ${(elapsed / 1000).toFixed(2)}s`) const elapsed = Date.now() - start
Logger.info(`[dbMigration] Migration complete. Elapsed ${(elapsed / 1000).toFixed(2)}s`)
} }
/** /**
@@ -806,3 +1108,204 @@ module.exports.checkShouldMigrate = async () => {
if (await oldDbFiles.checkHasOldDb()) return true if (await oldDbFiles.checkHasOldDb()) return true
return oldDbFiles.checkHasOldDbZip() return oldDbFiles.checkHasOldDbZip()
} }
/**
* Migration from 2.3.0 to 2.3.1 - create extraData columns in LibraryItem and PodcastEpisode
* @param {QueryInterface} queryInterface
*/
async function migrationPatchNewColumns(queryInterface) {
try {
return queryInterface.sequelize.transaction(t => {
return Promise.all([
queryInterface.addColumn('libraryItems', 'extraData', {
type: DataTypes.JSON
}, { transaction: t }),
queryInterface.addColumn('podcastEpisodes', 'extraData', {
type: DataTypes.JSON
}, { transaction: t }),
queryInterface.addColumn('libraries', 'extraData', {
type: DataTypes.JSON
}, { transaction: t })
])
})
} catch (error) {
Logger.error(`[dbMigration] Migration from 2.3.0+ column creation failed`, error)
return false
}
}
/**
* Migration from 2.3.0 to 2.3.1 - old library item ids
* @param {/src/Database} ctx
*/
async function handleOldLibraryItems(ctx) {
const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems')
const libraryItems = await ctx.models.libraryItem.getAllOldLibraryItems()
const bulkUpdateItems = []
const bulkUpdateEpisodes = []
for (const libraryItem of libraryItems) {
// Find matching old library item by ino
const matchingOldLibraryItem = oldLibraryItems.find(oli => oli.ino === libraryItem.ino)
if (matchingOldLibraryItem) {
oldDbIdMap.libraryItems[matchingOldLibraryItem.id] = libraryItem.id
bulkUpdateItems.push({
id: libraryItem.id,
extraData: {
oldLibraryItemId: matchingOldLibraryItem.id
}
})
if (libraryItem.media.episodes?.length && matchingOldLibraryItem.media.episodes?.length) {
for (const podcastEpisode of libraryItem.media.episodes) {
// Find matching old episode by audio file ino
const matchingOldPodcastEpisode = matchingOldLibraryItem.media.episodes.find(oep => oep.audioFile?.ino && oep.audioFile.ino === podcastEpisode.audioFile?.ino)
if (matchingOldPodcastEpisode) {
oldDbIdMap.podcastEpisodes[matchingOldPodcastEpisode.id] = podcastEpisode.id
bulkUpdateEpisodes.push({
id: podcastEpisode.id,
extraData: {
oldEpisodeId: matchingOldPodcastEpisode.id
}
})
}
}
}
}
}
if (bulkUpdateEpisodes.length) {
await ctx.models.podcastEpisode.bulkCreate(bulkUpdateEpisodes, {
updateOnDuplicate: ['extraData']
})
}
if (bulkUpdateItems.length) {
await ctx.models.libraryItem.bulkCreate(bulkUpdateItems, {
updateOnDuplicate: ['extraData']
})
}
Logger.info(`[dbMigration] Migration 2.3.0+: Updated ${bulkUpdateItems.length} library items & ${bulkUpdateEpisodes.length} episodes`)
}
/**
* Migration from 2.3.0 to 2.3.1 - updating oldLibraryId
* @param {/src/Database} ctx
*/
async function handleOldLibraries(ctx) {
const oldLibraries = await oldDbFiles.loadOldData('libraries')
const libraries = await ctx.models.library.getAllOldLibraries()
let librariesUpdated = 0
for (const library of libraries) {
// Find matching old library using exact match on folder paths, exact match on library name
const matchingOldLibrary = oldLibraries.find(ol => {
if (ol.name !== library.name) {
return false
}
const folderPaths = ol.folders?.map(f => f.fullPath) || []
return folderPaths.join(',') === library.folderPaths.join(',')
})
if (matchingOldLibrary) {
library.oldLibraryId = matchingOldLibrary.id
oldDbIdMap.libraries[library.oldLibraryId] = library.id
await ctx.models.library.updateFromOld(library)
librariesUpdated++
}
}
Logger.info(`[dbMigration] Migration 2.3.0+: Updated ${librariesUpdated} libraries`)
}
/**
* Migration from 2.3.0 to 2.3.1 - fixing librariesAccessible and bookmarks
* @param {/src/Database} ctx
*/
async function handleOldUsers(ctx) {
const users = await ctx.models.user.getOldUsers()
let usersUpdated = 0
for (const user of users) {
let hasUpdates = false
if (user.bookmarks?.length) {
user.bookmarks = user.bookmarks.map(bm => {
// Only update if this is not the old id format
if (!bm.libraryItemId.startsWith('li_')) return bm
bm.libraryItemId = oldDbIdMap.libraryItems[bm.libraryItemId]
hasUpdates = true
return bm
}).filter(bm => bm.libraryItemId)
}
// Convert old library ids to new library ids
if (user.librariesAccessible?.length) {
user.librariesAccessible = user.librariesAccessible.map(lid => {
if (!lid.startsWith('lib_') && lid !== 'main') return lid // Already not an old library id so dont change
hasUpdates = true
return oldDbIdMap.libraries[lid]
}).filter(lid => lid)
}
if (user.seriesHideFromContinueListening?.length) {
user.seriesHideFromContinueListening = user.seriesHideFromContinueListening.map((seriesId) => {
if (seriesId.startsWith('se_')) {
hasUpdates = true
return null // Filter out old series ids
}
return seriesId
}).filter(se => se)
}
if (hasUpdates) {
await ctx.models.user.updateFromOld(user)
usersUpdated++
}
}
Logger.info(`[dbMigration] Migration 2.3.0+: Updated ${usersUpdated} users`)
}
/**
* Migration from 2.3.0 to 2.3.1
* @param {/src/Database} ctx
*/
module.exports.migrationPatch = async (ctx) => {
const queryInterface = ctx.sequelize.getQueryInterface()
const librariesTableDescription = await queryInterface.describeTable('libraries')
if (librariesTableDescription?.extraData) {
Logger.info(`[dbMigration] Migration patch 2.3.0+ - extraData columns already on model`)
} else {
const migrationResult = await migrationPatchNewColumns(queryInterface)
if (migrationResult === false) {
return
}
}
const oldDbPath = Path.join(global.ConfigPath, 'oldDb.zip')
if (!await fs.pathExists(oldDbPath)) {
Logger.info(`[dbMigration] Migration patch 2.3.0+ unnecessary - no oldDb.zip found`)
return
}
const migrationStart = Date.now()
Logger.info(`[dbMigration] Applying migration patch from 2.3.0+`)
// Extract from oldDb.zip
if (!await oldDbFiles.checkExtractItemsUsersAndLibraries()) {
return
}
await handleOldLibraryItems(ctx)
await handleOldLibraries(ctx)
await handleOldUsers(ctx)
await oldDbFiles.removeOldItemsUsersAndLibrariesFolders()
const elapsed = Date.now() - migrationStart
Logger.info(`[dbMigration] Migration patch 2.3.0+ finished. Elapsed ${(elapsed / 1000).toFixed(2)}s`)
}
+58 -21
View File
@@ -71,27 +71,11 @@ async function loadDbData(dbpath) {
} }
} }
module.exports.init = async () => { module.exports.loadOldData = async (dbName) => {
const dbs = { const dbPath = Path.join(global.ConfigPath, dbName, 'data')
libraryItems: Path.join(global.ConfigPath, 'libraryItems', 'data'), const dbData = await loadDbData(dbPath) || []
users: Path.join(global.ConfigPath, 'users', 'data'), Logger.info(`[oldDbFiles] ${dbData.length} ${dbName} loaded`)
sessions: Path.join(global.ConfigPath, 'sessions', 'data'), return dbData
libraries: Path.join(global.ConfigPath, 'libraries', 'data'),
settings: Path.join(global.ConfigPath, 'settings', 'data'),
collections: Path.join(global.ConfigPath, 'collections', 'data'),
playlists: Path.join(global.ConfigPath, 'playlists', 'data'),
authors: Path.join(global.ConfigPath, 'authors', 'data'),
series: Path.join(global.ConfigPath, 'series', 'data'),
feeds: Path.join(global.ConfigPath, 'feeds', 'data')
}
const data = {}
for (const key in dbs) {
data[key] = await loadDbData(dbs[key])
Logger.info(`[oldDbFiles] ${data[key].length} ${key} loaded`)
}
return data
} }
module.exports.zipWrapOldDb = async () => { module.exports.zipWrapOldDb = async () => {
@@ -184,6 +168,59 @@ module.exports.checkHasOldDbZip = async () => {
// Extract oldDb.zip // Extract oldDb.zip
const zip = new StreamZip.async({ file: oldDbPath }) const zip = new StreamZip.async({ file: oldDbPath })
await zip.extract(null, global.ConfigPath) await zip.extract(null, global.ConfigPath)
await zip.close()
return this.checkHasOldDb() return this.checkHasOldDb()
} }
/**
* Used for migration from 2.3.0 -> 2.3.1
* @returns {boolean} true if extracted
*/
module.exports.checkExtractItemsUsersAndLibraries = async () => {
const oldDbPath = Path.join(global.ConfigPath, 'oldDb.zip')
const zip = new StreamZip.async({ file: oldDbPath })
const libraryItemsPath = Path.join(global.ConfigPath, 'libraryItems')
await zip.extract('libraryItems/', libraryItemsPath)
if (!await fs.pathExists(libraryItemsPath)) {
Logger.error(`[oldDbFiles] Failed to extract old libraryItems from oldDb.zip`)
return false
}
const usersPath = Path.join(global.ConfigPath, 'users')
await zip.extract('users/', usersPath)
if (!await fs.pathExists(usersPath)) {
Logger.error(`[oldDbFiles] Failed to extract old users from oldDb.zip`)
await fs.remove(libraryItemsPath) // Remove old library items folder
return false
}
const librariesPath = Path.join(global.ConfigPath, 'libraries')
await zip.extract('libraries/', librariesPath)
if (!await fs.pathExists(librariesPath)) {
Logger.error(`[oldDbFiles] Failed to extract old libraries from oldDb.zip`)
await fs.remove(usersPath) // Remove old users folder
await fs.remove(libraryItemsPath) // Remove old library items folder
return false
}
await zip.close()
return true
}
/**
* Used for migration from 2.3.0 -> 2.3.1
*/
module.exports.removeOldItemsUsersAndLibrariesFolders = async () => {
const libraryItemsPath = Path.join(global.ConfigPath, 'libraryItems')
const usersPath = Path.join(global.ConfigPath, 'users')
const librariesPath = Path.join(global.ConfigPath, 'libraries')
await fs.remove(libraryItemsPath)
await fs.remove(usersPath)
await fs.remove(librariesPath)
}
+2
View File
@@ -43,6 +43,8 @@ module.exports.parse = (nameString) => {
// Example &LF: Friedman, Milton & Friedman, Rose // Example &LF: Friedman, Milton & Friedman, Rose
if (nameString.includes('&')) { if (nameString.includes('&')) {
nameString.split('&').forEach((asa) => splitNames = splitNames.concat(asa.split(','))) nameString.split('&').forEach((asa) => splitNames = splitNames.concat(asa.split(',')))
} else if (nameString.includes(' and ')) {
nameString.split(' and ').forEach((asa) => splitNames = splitNames.concat(asa.split(',')))
} else if (nameString.includes(';')) { } else if (nameString.includes(';')) {
nameString.split(';').forEach((asa) => splitNames = splitNames.concat(asa.split(','))) nameString.split(';').forEach((asa) => splitNames = splitNames.concat(asa.split(',')))
} else { } else {
+3 -3
View File
@@ -17,7 +17,7 @@ function parseCreators(metadata) {
function fetchCreators(creators, role) { function fetchCreators(creators, role) {
if (!creators || !creators.length) return null if (!creators || !creators.length) return null
return creators.filter(c => c.role === role).map(c => c.value) return [...new Set(creators.filter(c => c.role === role).map(c => c.value))]
} }
function fetchTagString(metadata, tag) { function fetchTagString(metadata, tag) {
@@ -92,7 +92,7 @@ function fetchDescription(metadata) {
function fetchGenres(metadata) { function fetchGenres(metadata) {
if (!metadata['dc:subject'] || !metadata['dc:subject'].length) return [] if (!metadata['dc:subject'] || !metadata['dc:subject'].length) return []
return metadata['dc:subject'].map(g => typeof g === 'string' ? g : null).filter(g => !!g) return [...new Set(metadata['dc:subject'].filter(g => typeof g === 'string'))]
} }
function fetchLanguage(metadata) { function fetchLanguage(metadata) {
@@ -122,7 +122,7 @@ function fetchNarrators(creators, metadata) {
function fetchTags(metadata) { function fetchTags(metadata) {
if (!metadata['dc:tag'] || !metadata['dc:tag'].length) return [] if (!metadata['dc:tag'] || !metadata['dc:tag'].length) return []
return metadata['dc:tag'].filter(tag => (typeof tag === 'string')) return [...new Set(metadata['dc:tag'].filter(tag => typeof tag === 'string'))]
} }
function stripPrefix(str) { function stripPrefix(str) {
+7 -6
View File
@@ -139,15 +139,16 @@ function isNullOrNaN(val) {
*/ */
function parseChapters(chapters) { function parseChapters(chapters) {
if (!chapters) return [] if (!chapters) return []
let index = 0
return chapters.map(chap => { return chapters.map(chap => {
var title = chap['TAG:title'] || chap.title || '' let title = chap['TAG:title'] || chap.title || ''
if (!title && chap.tags && chap.tags.title) title = chap.tags.title if (!title && chap.tags?.title) title = chap.tags.title
var timebase = chap.time_base && chap.time_base.includes('/') ? Number(chap.time_base.split('/')[1]) : 1 const timebase = chap.time_base?.includes('/') ? Number(chap.time_base.split('/')[1]) : 1
var start = !isNullOrNaN(chap.start_time) ? Number(chap.start_time) : !isNullOrNaN(chap.start) ? Number(chap.start) / timebase : 0 const start = !isNullOrNaN(chap.start_time) ? Number(chap.start_time) : !isNullOrNaN(chap.start) ? Number(chap.start) / timebase : 0
var end = !isNullOrNaN(chap.end_time) ? Number(chap.end_time) : !isNullOrNaN(chap.end) ? Number(chap.end) / timebase : 0 const end = !isNullOrNaN(chap.end_time) ? Number(chap.end_time) : !isNullOrNaN(chap.end) ? Number(chap.end) / timebase : 0
return { return {
id: chap.id, id: index++,
start, start,
end, end,
title title