Compare commits

...

64 Commits

Author SHA1 Message Date
advplyr 80e0cac474 Version bump v2.15.0 2024-10-12 16:18:45 -05:00
advplyr 37273dd51c Merge pull request #3486 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2024-10-12 16:06:27 -05:00
Languages add-on 926a85fff0 Added translation using Weblate (English (United States)) 2024-10-12 20:57:08 +00:00
J. Lavoie 70273ba2ba Translated using Weblate (Italian)
Currently translated at 99.7% (991 of 993 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/it/
2024-10-12 20:57:08 +00:00
J. Lavoie 158cdeed57 Translated using Weblate (French)
Currently translated at 100.0% (993 of 993 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/
2024-10-12 20:57:07 +00:00
J. Lavoie ba9595a1be Translated using Weblate (German)
Currently translated at 100.0% (993 of 993 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-10-12 20:57:07 +00:00
Mathias Franco 347e3ff674 Translated using Weblate (Dutch)
Currently translated at 67.6% (672 of 993 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/
2024-10-12 20:57:06 +00:00
gallegonovato 2b6fb46cdb Translated using Weblate (Spanish)
Currently translated at 100.0% (993 of 993 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2024-10-12 20:57:06 +00:00
biuklija 465775bd55 Translated using Weblate (Croatian)
Currently translated at 100.0% (993 of 993 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2024-10-12 20:57:05 +00:00
thehijacker 44e82fc454 Translated using Weblate (Slovenian)
Currently translated at 100.0% (993 of 993 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-10-12 20:57:04 +00:00
thehijacker c4963d0de8 Translated using Weblate (Slovenian)
Currently translated at 100.0% (993 of 993 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-10-12 20:57:04 +00:00
thehijacker ff81d70cb1 Translated using Weblate (Slovenian)
Currently translated at 100.0% (991 of 991 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-10-12 20:57:03 +00:00
Petras Šukys d7a543e143 Translated using Weblate (Lithuanian)
Currently translated at 71.1% (705 of 991 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/lt/
2024-10-12 20:57:03 +00:00
biuklija cba547083d Translated using Weblate (Croatian)
Currently translated at 100.0% (991 of 991 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2024-10-12 20:57:02 +00:00
gallegonovato 47b1d2a2c2 Translated using Weblate (Spanish)
Currently translated at 100.0% (991 of 991 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2024-10-12 20:57:02 +00:00
Daniel Schosser abc378954c Translated using Weblate (German)
Currently translated at 100.0% (991 of 991 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-10-12 20:57:01 +00:00
Soaibuzzaman fdf871af17 Translated using Weblate (Bengali)
Currently translated at 99.8% (990 of 991 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bn/
2024-10-12 20:57:01 +00:00
thehijacker 83fcb0efdc Translated using Weblate (Slovenian)
Currently translated at 100.0% (991 of 991 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-10-12 20:57:00 +00:00
DiamondtipDR 0c43f3d15a Translated using Weblate (Spanish)
Currently translated at 100.0% (991 of 991 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2024-10-12 20:57:00 +00:00
Alexander Künzel 88e087d50f Translated using Weblate (German)
Currently translated at 99.8% (990 of 991 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-10-12 20:56:59 +00:00
gallegonovato a9fb6eb8bc Translated using Weblate (Spanish)
Currently translated at 100.0% (991 of 991 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2024-10-12 20:56:58 +00:00
Charlie 08acfdcd24 Translated using Weblate (French)
Currently translated at 100.0% (990 of 990 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/
2024-10-12 20:56:58 +00:00
K. J 576eb9106f Translated using Weblate (German)
Currently translated at 100.0% (990 of 990 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-10-12 20:56:57 +00:00
advplyr ddd2c0ae4e Add:Filter for missing chapters & alphabetize missing subitems #3497 2024-10-12 15:56:49 -05:00
advplyr e58d7db03b Merge pull request #3417 from nichwall/series_cleanup_2
Add: series migration to be unique
2024-10-12 15:48:04 -05:00
advplyr 1cac42aec5 Add localization on logs page and confirm embed #3495 2024-10-12 15:32:51 -05:00
advplyr f94449a659 Merge pull request #3500 from nichwall/2_14_0_strings
2.14.0 string localization
2024-10-12 15:25:41 -05:00
advplyr df6afc957f Add localization for notification descriptions 2024-10-12 15:22:21 -05:00
advplyr 076f71d490 Fix:Handle undefined page/limit in paginated library queries #3499 2024-10-11 17:15:16 -05:00
advplyr 33eae1e03a Fix:Server crash on podcast add page, adds API endpoint to get podcast titles #3499
- Instead of loading all podcast library items this page now loads only the needed data
2024-10-11 16:55:09 -05:00
Nicholas Wallace 8a20510cde Localize: subtitle books 2024-10-10 22:12:31 -07:00
Nicholas Wallace c33b470fca Tools Manager strings 2024-10-10 21:58:17 -07:00
Nicholas Wallace 29db5f1990 Update: tools strings 2024-10-10 21:21:15 -07:00
Nicholas Wallace f98f78a5bd Podcast search strings 2024-10-10 21:14:51 -07:00
advplyr d258b42e01 Fix:Podcast episode batch mark as finished only showing for admin and up #3496 2024-10-10 08:03:47 -05:00
advplyr a6da32430f Merge pull request #3492 from mikiher/author-image-ar
Use object-cover for author images unless AR is really high or low
2024-10-09 17:31:24 -05:00
advplyr cfae607310 AuthorImage remove aspectRatio unused var 2024-10-09 17:22:38 -05:00
mikiher 7653e72e88 Use object-cover for author images unless AR is really high or low. 2024-10-09 15:04:25 +03:00
Greg Lorenzen f38b6636e3 Add published decade filter option (#3489)
* Add strings for PublishedDecade and PublishedDecades

* Add publishedDecades filter options to LibraryFilterSelect

* Add publishedDecades to libraries store

* Add publishedDecades to getFilterData

* Add database method to add published decades to filter data

* Add published decade in BookScanner

* Add 'publishedDecades' to invalidFilters in user.js

* Add publishedDecades filter group to MediaGroupQuery

* Update client/strings/en-us.json

* Auto formatting

---------

Co-authored-by: advplyr <dev@advplyr.com>
Co-authored-by: advplyr <advplyr@protonmail.com>
2024-10-08 17:20:42 -05:00
advplyr e42db121ea Merge pull request #3491 from thatguy7/unicode-author-series
retire unicode handling workaround for Author and Series title
2024-10-08 17:04:21 -05:00
advplyr 0adceaa3f0 Remove asciiOnlyToLowerCase 2024-10-08 16:59:45 -05:00
Oleg Ivasenko e6db1495ab retire unicode handling workaround for Author and Series title 2024-10-08 19:52:26 +00:00
Nicholas Wallace e6e494a92c Rename for next minor release 2024-10-07 18:52:14 -07:00
advplyr 549f95b259 Merge pull request #3488 from mikiher/nunicode-musl
Use musl-based libnusqlite3 in Docker
2024-10-07 16:43:38 -05:00
mikiher d92626071e Use musl-based libnusqlite3 in Docker 2024-10-07 20:48:52 +03:00
advplyr a7ac82b023 Merge pull request #3487 from mikiher/lazy-bookshelf-authors
Move authors to LazyBookshelf
2024-10-06 16:32:42 -05:00
advplyr 64b78b5822 Move pagination limit/page query param validation to middleware & check for positive integer 2024-10-06 16:29:30 -05:00
advplyr 8ba17db877 Fix authors button in SideRail selected 2024-10-06 15:58:23 -05:00
mikiher 6820d9ae4e Fix AuthorCard component test 2024-10-06 18:57:13 +03:00
mikiher 0bdc2fb05e Move authors to lazyBookshelf 2024-10-06 18:25:08 +03:00
advplyr 5154e31c1c Update migration to v2.14.0 2024-09-24 17:06:00 -05:00
advplyr c67b5e950e Update MigrationManager.test.js - moved migrations ensureDir to init() 2024-09-24 16:54:13 -05:00
advplyr 8a7b5cc87d Ensure series-column-unique migration is idempotent 2024-09-24 16:47:09 -05:00
Nicholas Wallace 66b290577c Clean up unused parts of statement 2024-09-17 20:00:06 -07:00
Nicholas Wallace 8b95dd65d9 Fix: test cases checking the wrong bookSeriesId 2024-09-14 15:43:10 -07:00
Nicholas Wallace 691ed88096 Add more logging, clean up typo 2024-09-14 15:34:38 -07:00
Nicholas Wallace 836d772cd4 Update: remove the same book if occurs multiple times in duplicate series 2024-09-14 15:23:29 -07:00
Nicholas Wallace 999ada03d1 Fix: missing variables 2024-09-14 14:36:47 -07:00
Nicholas Wallace fa451f362b Add: tests for one book in duplicate series 2024-09-14 12:11:31 -07:00
Nicholas Wallace 868659a2f1 Add: unique constraint on bookseries table 2024-09-14 11:44:19 -07:00
advplyr 8ae62da138 Update migration unit test name 2024-09-14 10:40:01 -05:00
advplyr bedba39af9 Merge branch 'master' into series_cleanup_2 2024-09-14 10:11:16 -05:00
Nicholas Wallace c163f84aec Update migration changelog for series name unique 2024-09-13 17:01:48 -07:00
Nicholas Wallace 2711b989e1 Add: series migration to be unique 2024-09-13 16:55:48 -07:00
55 changed files with 1143 additions and 378 deletions
+2 -3
View File
@@ -16,7 +16,6 @@ RUN apk update && \
tzdata \
ffmpeg \
make \
gcompat \
python3 \
g++ \
tini \
@@ -33,9 +32,9 @@ ENV NUSQLITE3_PATH="${NUSQLITE3_DIR}/libnusqlite3.so"
RUN case "$TARGETPLATFORM" in \
"linux/amd64") \
curl -L -o /tmp/library.zip "https://github.com/mikiher/nunicode-sqlite/releases/download/v1.1/libnusqlite3-linux-x64.zip" ;; \
curl -L -o /tmp/library.zip "https://github.com/mikiher/nunicode-sqlite/releases/download/v1.2/libnusqlite3-linux-musl-x64.zip" ;; \
"linux/arm64") \
curl -L -o /tmp/library.zip "https://github.com/mikiher/nunicode-sqlite/releases/download/v1.1/libnusqlite3-linux-arm64.zip" ;; \
curl -L -o /tmp/library.zip "https://github.com/mikiher/nunicode-sqlite/releases/download/v1.2/libnusqlite3-linux-musl-arm64.zip" ;; \
*) echo "Unsupported platform: $TARGETPLATFORM" && exit 1 ;; \
esac && \
unzip /tmp/library.zip -d $NUSQLITE3_DIR && \
+1 -1
View File
@@ -24,7 +24,7 @@
</div>
<div v-if="shelf.type === 'authors'" class="flex items-center">
<template v-for="entity in shelf.entities">
<cards-author-card :key="entity.id" :author="entity" @hook:updated="updatedBookCard" class="mx-2e" @edit="editAuthor" />
<cards-author-card :key="entity.id" :authorMount="entity" @hook:updated="updatedBookCard" class="mx-2e" @edit="editAuthor" />
</template>
</div>
<div v-if="shelf.type === 'narrators'" class="flex items-center">
+48 -37
View File
@@ -30,7 +30,7 @@
<p v-if="isCollectionsPage" class="text-sm">{{ $strings.ButtonCollections }}</p>
<span v-else class="material-symbols text-lg">&#xe431;</span>
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="flex-grow h-full flex justify-center items-center" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/authors`" class="flex-grow h-full flex justify-center items-center" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p v-if="isAuthorsPage" class="text-sm">{{ $strings.ButtonAuthors }}</p>
<svg v-else class="w-5 h-5" viewBox="0 0 24 24">
<path
@@ -62,7 +62,7 @@
<ui-context-menu-dropdown v-if="!isBatchSelecting && seriesContextMenuItems.length" :items="seriesContextMenuItems" class="mx-px" @action="seriesContextMenuAction" />
</template>
<!-- library & collections page -->
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome">
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome && !isAuthorsPage">
<p class="hidden md:block">{{ $formatNumber(numShowing) }} {{ entityName }}</p>
<div class="flex-grow hidden sm:inline-block" />
@@ -92,12 +92,14 @@
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
</template>
<!-- authors page -->
<template v-else-if="page === 'authors'">
<div class="flex-grow" />
<ui-btn v-if="userCanUpdate && authors?.length && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
<template v-else-if="isAuthorsPage">
<p class="hidden md:block">{{ $formatNumber(numShowing) }} {{ entityName }}</p>
<div class="flex-grow hidden sm:inline-block" />
<ui-btn v-if="userCanUpdate && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
<!-- author sort select -->
<controls-sort-select v-if="authors?.length" v-model="settings.authorSortBy" :descending.sync="settings.authorSortDesc" :items="authorSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateAuthorSort" />
<controls-sort-select v-model="settings.authorSortBy" :descending.sync="settings.authorSortDesc" :items="authorSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateAuthorSort" />
</template>
<!-- home page -->
<template v-else-if="isHome">
@@ -117,11 +119,7 @@ export default {
type: Object,
default: () => null
},
searchQuery: String,
authors: {
type: Array,
default: () => []
}
searchQuery: String
},
data() {
return {
@@ -268,7 +266,7 @@ export default {
return this.$route.name === 'library-library-podcast-latest'
},
isAuthorsPage() {
return this.$route.name === 'library-library-authors'
return this.page === 'authors'
},
isAlbumsPage() {
return this.page === 'albums'
@@ -284,6 +282,7 @@ export default {
if (this.isSeriesPage) return this.$strings.LabelSeries
if (this.isCollectionsPage) return this.$strings.LabelCollections
if (this.isPlaylistsPage) return this.$strings.LabelPlaylists
if (this.isAuthorsPage) return this.$strings.LabelAuthors
return ''
},
seriesId() {
@@ -479,36 +478,48 @@ export default {
this.processingSeries = false
})
},
async fetchAllAuthors() {
// fetch all authors from the server, in the order that they are currently displayed
const response = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/authors?sort=${this.settings.authorSortBy}&desc=${this.settings.authorSortDesc}`)
return response.authors
},
async matchAllAuthors() {
this.processingAuthors = true
for (const author of this.authors) {
const payload = {}
if (author.asin) payload.asin = author.asin
else payload.q = author.name
try {
const authors = await this.fetchAllAuthors()
payload.region = 'us'
if (this.libraryProvider.startsWith('audible.')) {
payload.region = this.libraryProvider.split('.').pop() || 'us'
for (const author of authors) {
const payload = {}
if (author.asin) payload.asin = author.asin
else payload.q = author.name
payload.region = 'us'
if (this.libraryProvider.startsWith('audible.')) {
payload.region = this.libraryProvider.split('.').pop() || 'us'
}
this.$eventBus.$emit(`searching-author-${author.id}`, true)
var response = await this.$axios.$post(`/api/authors/${author.id}/match`, payload).catch((error) => {
console.error('Failed', error)
return null
})
if (!response) {
console.error(`Author ${author.name} not found`)
this.$toast.error(this.$getString('ToastAuthorNotFound', [author.name]))
} else if (response.updated) {
if (response.author.imagePath) console.log(`Author ${response.author.name} was updated`)
else console.log(`Author ${response.author.name} was updated (no image found)`)
} else {
console.log(`No updates were made for Author ${response.author.name}`)
}
this.$eventBus.$emit(`searching-author-${author.id}`, false)
}
this.$eventBus.$emit(`searching-author-${author.id}`, true)
var response = await this.$axios.$post(`/api/authors/${author.id}/match`, payload).catch((error) => {
console.error('Failed', error)
return null
})
if (!response) {
console.error(`Author ${author.name} not found`)
this.$toast.error(this.$getString('ToastAuthorNotFound', [author.name]))
} else if (response.updated) {
if (response.author.imagePath) console.log(`Author ${response.author.name} was updated`)
else console.log(`Author ${response.author.name} was updated (no image found)`)
} else {
console.log(`No updates were made for Author ${response.author.name}`)
}
this.$eventBus.$emit(`searching-author-${author.id}`, false)
} catch (error) {
console.error('Failed to match all authors', error)
this.$toast.error(this.$strings.ToastMatchAllAuthorsFailed)
}
this.processingAuthors = false
},
+46
View File
@@ -91,6 +91,7 @@ export default {
if (this.page === 'series') return this.$strings.MessageBookshelfNoSeries
if (this.page === 'collections') return this.$strings.MessageBookshelfNoCollections
if (this.page === 'playlists') return this.$strings.MessageNoUserPlaylists
if (this.page === 'authors') return this.$strings.MessageNoAuthors
if (this.hasFilter) {
if (this.filterName === 'Issues') return this.$strings.MessageNoIssues
else if (this.filterName === 'Feed-open') return this.$strings.MessageBookshelfNoRSSFeeds
@@ -111,6 +112,12 @@ export default {
seriesFilterBy() {
return this.$store.getters['user/getUserSetting']('seriesFilterBy')
},
authorSortBy() {
return this.$store.getters['user/getUserSetting']('authorSortBy')
},
authorSortDesc() {
return !!this.$store.getters['user/getUserSetting']('authorSortDesc')
},
orderBy() {
return this.$store.getters['user/getUserSetting']('orderBy')
},
@@ -217,6 +224,8 @@ export default {
this.$store.commit('globals/setEditCollection', entity)
} else if (this.entityName === 'playlists') {
this.$store.commit('globals/setEditPlaylist', entity)
} else if (this.entityName === 'authors') {
this.$store.commit('globals/showEditAuthorModal', entity)
}
},
clearSelectedEntities() {
@@ -457,6 +466,9 @@ export default {
if (this.collapseBookSeries) {
searchParams.set('collapseseries', 1)
}
} else if (this.page === 'authors') {
searchParams.set('sort', this.authorSortBy)
searchParams.set('desc', this.authorSortDesc ? 1 : 0)
} else {
if (this.filterBy && this.filterBy !== 'all') {
searchParams.set('filter', this.filterBy)
@@ -601,6 +613,34 @@ export default {
this.executeRebuild()
}
},
authorAdded(author) {
if (this.entityName !== 'authors') return
console.log(`[LazyBookshelf] authorAdded ${author.id}`, author)
this.resetEntities()
},
authorUpdated(author) {
if (this.entityName !== 'authors') return
console.log(`[LazyBookshelf] authorUpdated ${author.id}`, author)
const indexOf = this.entities.findIndex((ent) => ent && ent.id === author.id)
if (indexOf >= 0) {
this.entities[indexOf] = author
if (this.entityComponentRefs[indexOf]) {
this.entityComponentRefs[indexOf].setEntity(author)
}
}
},
authorRemoved(author) {
if (this.entityName !== 'authors') return
console.log(`[LazyBookshelf] authorRemoved ${author.id}`, author)
const indexOf = this.entities.findIndex((ent) => ent && ent.id === author.id)
if (indexOf >= 0) {
this.entities = this.entities.filter((ent) => ent.id !== author.id)
this.totalEntities--
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
this.executeRebuild()
}
},
shareOpen(mediaItemShare) {
if (this.entityName === 'items' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent?.media?.id === mediaItemShare.mediaItemId)
@@ -727,6 +767,9 @@ export default {
this.$root.socket.on('playlist_added', this.playlistAdded)
this.$root.socket.on('playlist_updated', this.playlistUpdated)
this.$root.socket.on('playlist_removed', this.playlistRemoved)
this.$root.socket.on('author_added', this.authorAdded)
this.$root.socket.on('author_updated', this.authorUpdated)
this.$root.socket.on('author_removed', this.authorRemoved)
this.$root.socket.on('share_open', this.shareOpen)
this.$root.socket.on('share_closed', this.shareClosed)
} else {
@@ -756,6 +799,9 @@ export default {
this.$root.socket.off('playlist_added', this.playlistAdded)
this.$root.socket.off('playlist_updated', this.playlistUpdated)
this.$root.socket.off('playlist_removed', this.playlistRemoved)
this.$root.socket.off('author_added', this.authorAdded)
this.$root.socket.off('author_updated', this.authorUpdated)
this.$root.socket.off('author_removed', this.authorRemoved)
this.$root.socket.off('share_open', this.shareOpen)
this.$root.socket.off('share_closed', this.shareClosed)
} else {
+2 -2
View File
@@ -58,7 +58,7 @@
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg class="w-6 h-6" viewBox="0 0 24 24">
<path
fill="currentColor"
@@ -180,7 +180,7 @@ export default {
return this.$route.name === 'library-library-series-id' || this.paramId === 'series'
},
isAuthorsPage() {
return this.$route.name === 'library-library-authors'
return this.libraryBookshelfPage && this.paramId === 'authors'
},
isNarratorsPage() {
return this.$route.name === 'library-library-narrators'
+47 -16
View File
@@ -1,6 +1,6 @@
<template>
<div :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
<nuxt-link :to="`/author/${author.id}`">
<div class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
<nuxt-link :to="`/author/${author?.id}`">
<div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave">
<div cy-id="imageArea" :style="{ height: cardHeight + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
<!-- Image or placeholder -->
@@ -40,7 +40,7 @@
<script>
export default {
props: {
author: {
authorMount: {
type: Object,
default: () => {}
},
@@ -57,7 +57,8 @@ export default {
data() {
return {
searching: false,
isHovering: false
isHovering: false,
author: null
}
},
computed: {
@@ -68,34 +69,37 @@ export default {
return this.height * this.sizeMultiplier
},
userToken() {
return this.$store.getters['user/getToken']
return this.store.getters['user/getToken']
},
_author() {
return this.author || {}
},
authorId() {
return this._author.id
return this._author?.id || ''
},
name() {
return this._author.name || ''
return this._author?.name || ''
},
asin() {
return this._author.asin || ''
return this._author?.asin || ''
},
numBooks() {
return this._author.numBooks || 0
return this._author?.numBooks || 0
},
store() {
return this.$store || this.$nuxt.$store
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
return this.store.getters['user/getUserCanUpdate']
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
return this.store.state.libraries.currentLibraryId
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
return this.store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
},
sizeMultiplier() {
return this.$store.getters['user/getSizeMultiplier']
return this.store.getters['user/getSizeMultiplier']
}
},
methods: {
@@ -132,13 +136,40 @@ export default {
},
setSearching(isSearching) {
this.searching = isSearching
}
},
setEntity(author) {
this.removeListeners()
this.author = author
this.addListeners()
},
addListeners() {
if (this.author) {
this.$eventBus.$on(`searching-author-${this.authorId}`, this.setSearching)
}
},
removeListeners() {
if (this.author) {
this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching)
}
},
destroy() {
// destroy the vue listeners, etc
this.$destroy()
// remove the element from the DOM
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
} else if (this.$el && this.$el.remove) {
this.$el.remove()
}
},
setSelectionMode(val) {}
},
mounted() {
this.$eventBus.$on(`searching-author-${this.authorId}`, this.setSearching)
if (this.authorMount) this.setEntity(this.authorMount)
},
beforeDestroy() {
this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching)
this.removeListeners()
}
}
</script>
+1 -1
View File
@@ -325,7 +325,7 @@ export default {
},
displaySubtitle() {
if (!this.libraryItem) return '\u00A0'
if (this.collapsedSeries) return this.collapsedSeries.numBooks === 1 ? '1 book' : `${this.collapsedSeries.numBooks} books`
if (this.collapsedSeries) return `${this.collapsedSeries.numBooks} ${this.$strings.LabelBooks}`
if (this.mediaMetadata.subtitle) return this.mediaMetadata.subtitle
if (this.mediaMetadata.seriesName) return this.mediaMetadata.seriesName
return ''
@@ -189,6 +189,12 @@ export default {
value: 'publishers',
sublist: true
},
{
text: this.$strings.LabelPublishedDecade,
textPlural: this.$strings.LabelPublishedDecades,
value: 'publishedDecades',
sublist: true
},
{
text: this.$strings.LabelLanguage,
textPlural: this.$strings.LabelLanguages,
@@ -338,6 +344,9 @@ export default {
publishers() {
return this.filterData.publishers || []
},
publishedDecades() {
return this.filterData.publishedDecades || []
},
progress() {
return [
{
@@ -404,21 +413,17 @@ export default {
id: 'isbn',
name: 'ISBN'
},
{
id: 'subtitle',
name: this.$strings.LabelSubtitle
},
{
id: 'authors',
name: this.$strings.LabelAuthor
},
{
id: 'publishedYear',
name: this.$strings.LabelPublishYear
id: 'chapters',
name: this.$strings.LabelChapters
},
{
id: 'series',
name: this.$strings.LabelSeries
id: 'cover',
name: this.$strings.LabelCover
},
{
id: 'description',
@@ -429,24 +434,32 @@ export default {
name: this.$strings.LabelGenres
},
{
id: 'tags',
name: this.$strings.LabelTags
id: 'language',
name: this.$strings.LabelLanguage
},
{
id: 'narrators',
name: this.$strings.LabelNarrator
},
{
id: 'publishedYear',
name: this.$strings.LabelPublishYear
},
{
id: 'publisher',
name: this.$strings.LabelPublisher
},
{
id: 'language',
name: this.$strings.LabelLanguage
id: 'series',
name: this.$strings.LabelSeries
},
{
id: 'cover',
name: this.$strings.LabelCover
id: 'subtitle',
name: this.$strings.LabelSubtitle
},
{
id: 'tags',
name: this.$strings.LabelTags
}
]
},
+1 -6
View File
@@ -65,15 +65,10 @@ export default {
},
methods: {
imageLoaded() {
var aspectRatio = 1.25
if (this.$refs.wrapper) {
aspectRatio = this.$refs.wrapper.clientHeight / this.$refs.wrapper.clientWidth
}
if (this.$refs.img) {
var { naturalWidth, naturalHeight } = this.$refs.img
var imgAr = naturalHeight / naturalWidth
var arDiff = Math.abs(imgAr - aspectRatio)
if (arDiff > 0.15) {
if (imgAr < 0.5 || imgAr > 2) {
this.showCoverBg = true
} else {
this.showCoverBg = false
+4 -4
View File
@@ -33,18 +33,18 @@
<span class="material-symbols text-lg ml-2">launch</span>
</ui-btn>
<ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">Quick Embed</ui-btn>
<ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">{{ $strings.ButtonQuickEmbed }}</ui-btn>
</div>
</div>
<!-- queued alert -->
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mt-4">
<p class="text-lg">Queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
<p class="text-lg">{{ $getString('MessageQuickEmbedQueue', [queuedEmbedLIds.length]) }}</p>
</widgets-alert>
<!-- processing alert -->
<widgets-alert v-if="isEmbedTaskRunning" type="warning" class="mt-4">
<p class="text-lg">Currently embedding metadata</p>
<p class="text-lg">{{ $strings.MessageQuickEmbedInProgress }}</p>
</widgets-alert>
</div>
@@ -113,7 +113,7 @@ export default {
methods: {
quickEmbed() {
const payload = {
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
message: this.$strings.MessageConfirmQuickEmbed,
callback: (confirmed) => {
if (confirmed) {
this.$axios
@@ -77,7 +77,13 @@ export default {
return this.notificationData.events || []
},
eventOptions() {
return this.notificationEvents.map((e) => ({ value: e.name, text: e.name, subtext: e.description }))
return this.notificationEvents.map((e) => {
return {
value: e.name,
text: e.name,
subtext: this.$strings[e.descriptionKey] || e.description
}
})
},
selectedEventData() {
return this.notificationEvents.find((e) => e.name === this.newNotification.eventName)
@@ -93,17 +93,18 @@ export default {
},
computed: {
contextMenuItems() {
if (!this.userIsAdminOrUp) return []
return [
{
const menuItems = []
if (this.userIsAdminOrUp) {
menuItems.push({
text: 'Quick match all episodes',
action: 'quick-match-episodes'
},
{
text: this.allEpisodesFinished ? this.$strings.MessageMarkAllEpisodesNotFinished : this.$strings.MessageMarkAllEpisodesFinished,
action: 'batch-mark-as-finished'
}
]
})
}
menuItems.push({
text: this.allEpisodesFinished ? this.$strings.MessageMarkAllEpisodesNotFinished : this.$strings.MessageMarkAllEpisodesFinished,
action: 'batch-mark-as-finished'
})
return menuItems
},
sortItems() {
return [
@@ -1,14 +1,11 @@
<template>
<ui-tooltip v-if="alreadyInLibrary" :text="$strings.LabelAlreadyInYourLibrary" direction="top">
<span class="material-symbols ml-1 text-success" style="font-size: 0.8rem">check_circle</span>
<ui-tooltip :text="$strings.LabelAlreadyInYourLibrary" direction="top" class="inline-flex">
<span class="material-symbols ml-1 text-sm text-success">check_circle</span>
</ui-tooltip>
</template>
<script>
export default {
props: {
alreadyInLibrary: Boolean
},
data() {
return {}
},
+1 -1
View File
@@ -65,7 +65,7 @@ export default {
},
authors: {
component: 'cards-author-card',
itemPropName: 'author',
itemPropName: 'author-mount',
itemIdFunc: (item) => item.id
},
narrators: {
@@ -5,14 +5,14 @@ import Tooltip from '@/components/ui/Tooltip.vue'
import LoadingSpinner from '@/components/widgets/LoadingSpinner.vue'
describe('AuthorCard', () => {
const author = {
const authorMount = {
id: 1,
name: 'John Doe',
numBooks: 5
}
const propsData = {
author,
authorMount,
nameBelow: false
}
+9 -4
View File
@@ -4,6 +4,7 @@ import LazySeriesCard from '@/components/cards/LazySeriesCard'
import LazyCollectionCard from '@/components/cards/LazyCollectionCard'
import LazyPlaylistCard from '@/components/cards/LazyPlaylistCard'
import LazyAlbumCard from '@/components/cards/LazyAlbumCard'
import AuthorCard from '@/components/cards/AuthorCard'
export default {
data() {
@@ -20,6 +21,7 @@ export default {
if (this.entityName === 'collections') return Vue.extend(LazyCollectionCard)
if (this.entityName === 'playlists') return Vue.extend(LazyPlaylistCard)
if (this.entityName === 'albums') return Vue.extend(LazyAlbumCard)
if (this.entityName === 'authors') return Vue.extend(AuthorCard)
return Vue.extend(LazyBookCard)
},
getComponentName() {
@@ -27,6 +29,7 @@ export default {
if (this.entityName === 'collections') return 'cards-lazy-collection-card'
if (this.entityName === 'playlists') return 'cards-lazy-playlist-card'
if (this.entityName === 'albums') return 'cards-lazy-album-card'
if (this.entityName === 'authors') return 'cards-author-card'
return 'cards-lazy-book-card'
},
async setCardSize() {
@@ -46,13 +49,14 @@ export default {
props.orderBy = this.seriesSortBy
}
const instance = new ComponentClass({
propsData: props
propsData: props,
parent: this
})
instance.$mount()
this.resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
this.cardWidth = entry.contentRect.width
this.cardHeight = entry.contentRect.height
this.cardWidth = entry.borderBoxSize[0].inlineSize
this.cardHeight = entry.borderBoxSize[0].blockSize
this.resizeObserver.disconnect()
this.$refs.bookshelf.removeChild(instance.$el)
}
@@ -72,7 +76,7 @@ export default {
})
const timeAfter = performance.now()
},
async mountEntityCard(index) {
mountEntityCard(index) {
var shelf = Math.floor(index / this.entitiesPerShelf)
var shelfEl = document.getElementById(`shelf-${shelf}`)
if (!shelfEl) {
@@ -114,6 +118,7 @@ export default {
const _this = this
const instance = new ComponentClass({
propsData: props,
parent: this,
created() {
this.$on('edit', (entity) => {
if (_this.editEntity) _this.editEntity(entity)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
"version": "2.14.0",
"version": "2.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.14.0",
"version": "2.15.0",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.14.0",
"version": "2.15.0",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
+18 -18
View File
@@ -63,11 +63,11 @@
<div class="w-full max-w-4xl mx-auto">
<!-- queued alert -->
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mb-4">
<p class="text-lg">Audiobook is queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
<p class="text-lg">{{ $getString('MessageEmbedQueue', [queuedEmbedLIds.length]) }}</p>
</widgets-alert>
<!-- metadata embed action buttons -->
<div v-else-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
<ui-checkbox v-if="!isTaskFinished" v-model="shouldBackupAudioFiles" :disabled="processing" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
<ui-checkbox v-if="!isTaskFinished" v-model="shouldBackupAudioFiles" :disabled="processing" :label="$strings.LabelBackupAudioFiles" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
<div class="flex-grow" />
@@ -78,7 +78,7 @@
<!-- m4b embed action buttons -->
<div v-else class="w-full flex items-center mb-4">
<button :disabled="processing" class="text-sm uppercase text-gray-200 flex items-center pt-px pl-1 pr-2 hover:bg-white/5 rounded-md" @click="showEncodeOptions = !showEncodeOptions">
<span class="material-symbols text-xl">{{ showEncodeOptions || usingCustomEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">Use Advanced Options</span>
<span class="material-symbols text-xl">{{ showEncodeOptions || usingCustomEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">{{ $strings.LabelUseAdvancedOptions }}</span>
</button>
<div class="flex-grow" />
@@ -94,11 +94,11 @@
<transition name="slide">
<div v-if="showEncodeOptions || usingCustomEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
<div class="flex flex-wrap -mx-2">
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="'Audio Bitrate (e.g. 128k)'" class="m-2 max-w-40" @input="bitrateChanged" />
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" :disabled="processing || isTaskFinished" :label="'Audio Channels (1 or 2)'" class="m-2 max-w-40" @input="channelsChanged" />
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" :disabled="processing || isTaskFinished" :label="'Audio Codec'" class="m-2 max-w-40" @input="codecChanged" />
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioBitrate" class="m-2 max-w-40" @input="bitrateChanged" />
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioChannels" class="m-2 max-w-40" @input="channelsChanged" />
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioCodec" class="m-2 max-w-40" @input="codecChanged" />
</div>
<p class="text-sm text-warning">Warning: Do not update these settings unless you are familiar with ffmpeg encoding options.</p>
<p class="text-sm text-warning">{{ $strings.LabelEncodingWarningAdvancedSettings }}</p>
</div>
</transition>
</div>
@@ -106,36 +106,36 @@
<div class="mb-4">
<div v-if="isEmbedTool" class="flex items-start mb-2">
<span class="material-symbols text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Metadata will be embedded in the audio tracks inside your audiobook folder.</p>
<p class="text-gray-200 ml-2">{{ $strings.LabelEncodingInfoEmbedded }}</p>
</div>
<div v-else class="flex items-start mb-2">
<span class="material-symbols text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">
Finished M4B will be put into your audiobook folder at <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">.../{{ libraryItemRelPath }}/</span>.
{{ $strings.LabelEncodingFinishedM4B }} <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">.../{{ libraryItemRelPath }}/</span>.
</p>
</div>
<div v-if="shouldBackupAudioFiles || isM4BTool" class="flex items-start mb-2">
<span class="material-symbols text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">
A backup of your original audio files will be stored in <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">/metadata/cache/items/{{ libraryItemId }}/</span>. Make sure to periodically purge items cache.
{{ $strings.LabelEncodingBackupLocation }} <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">/metadata/cache/items/{{ libraryItemId }}/</span>. {{ $strings.LabelEncodingClearItemCache }}
</p>
</div>
<div v-if="isEmbedTool && audioFiles.length > 1" class="flex items-start mb-2">
<span class="material-symbols text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Chapters are not embedded in multi-track audiobooks.</p>
<p class="text-gray-200 ml-2">{{ $strings.LabelEncodingChaptersNotEmbedded }}</p>
</div>
<div v-if="isM4BTool" class="flex items-start mb-2">
<span class="material-symbols text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Encoding can take up to 30 minutes.</p>
<p class="text-gray-200 ml-2">{{ $strings.LabelEncodingTimeWarning }}</p>
</div>
<div v-if="isM4BTool" class="flex items-start mb-2">
<span class="material-symbols text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">If you have the watcher disabled you will need to re-scan this audiobook afterwards.</p>
<p class="text-gray-200 ml-2">{{ $strings.LabelEncodingWatcherDisabled }}</p>
</div>
<div class="flex items-start mb-2">
<span class="material-symbols text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Once the task is started you can navigate away from this page.</p>
<p class="text-gray-200 ml-2">{{ $strings.LabelEncodingStartedNavigation }}</p>
</div>
</div>
</div>
@@ -269,11 +269,11 @@ export default {
},
availableTools() {
if (this.isSingleM4b) {
return [{ value: 'embed', text: 'Embed Metadata' }]
return [{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata }]
} else {
return [
{ value: 'embed', text: 'Embed Metadata' },
{ value: 'm4b', text: 'M4B Encoder' }
{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata },
{ value: 'm4b', text: this.$strings.LabelToolsM4bEncoder }
]
}
},
@@ -370,7 +370,7 @@ export default {
},
embedClick() {
const payload = {
message: `Are you sure you want to embed metadata in ${this.audioFiles.length} audio files?`,
message: this.$getString('MessageConfirmEmbedMetadataInAudioFiles', [this.audioFiles.length]),
callback: (confirmed) => {
if (confirmed) {
this.updateAudioFileMetadata()
+2 -2
View File
@@ -53,7 +53,7 @@ export default {
})
if (!author) {
return redirect(`/library/${store.state.libraries.currentLibraryId}/authors`)
return redirect(`/library/${store.state.libraries.currentLibraryId}/bookshelf/authors`)
}
if (store.state.libraries.currentLibraryId !== author.libraryId || !store.state.libraries.filterData) {
@@ -109,7 +109,7 @@ export default {
authorRemoved(author) {
if (author.id === this.author.id) {
console.warn('Author was removed')
this.$router.replace(`/library/${this.currentLibraryId}/authors`)
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/authors`)
}
}
},
+2 -2
View File
@@ -10,9 +10,9 @@
</template>
<div class="flex justify-between mb-2 place-items-end">
<ui-text-input ref="input" v-model="search" placeholder="Search filter.." @input="inputUpdate" clearable class="w-full sm:w-40 h-8 text-sm sm:mb-0" />
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" clearable class="w-full sm:w-40 h-8 text-sm sm:mb-0" />
<ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" class="w-full sm:w-44" />
<ui-dropdown v-model="newServerSettings.logLevel" :label="$strings.LabelServerLogLevel" :items="logLevelItems" @input="logLevelUpdated" class="w-full sm:w-44" />
</div>
<div class="relative">
@@ -1,115 +0,0 @@
<template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<app-book-shelf-toolbar page="authors" is-home :authors="authors" />
<div id="bookshelf" class="w-full h-full p-8e overflow-y-auto" :style="{ fontSize: sizeMultiplier + 'rem' }">
<!-- Cover size widget -->
<widgets-cover-size-widget class="fixed right-4 z-50" :style="{ bottom: streamLibraryItem ? '181px' : '16px' }" />
<div class="flex flex-wrap justify-center">
<template v-for="author in authorsSorted">
<cards-author-card :key="author.id" :author="author" class="p-3e" @edit="editAuthor" />
</template>
</div>
</div>
</div>
</template>
<script>
export default {
async asyncData({ store, params, redirect, query, app }) {
var libraryId = params.library
var libraryData = await store.dispatch('libraries/fetch', libraryId)
if (!libraryData) {
return redirect('/oops?message=Library not found')
}
const library = libraryData.library
if (library.mediaType === 'podcast') {
return redirect(`/library/${libraryId}`)
}
return {
libraryId
}
},
data() {
return {
loading: true,
authors: []
}
},
computed: {
sizeMultiplier() {
return this.$store.getters['user/getSizeMultiplier']
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
selectedAuthor() {
return this.$store.state.globals.selectedAuthor
},
authorSortBy() {
return this.$store.getters['user/getUserSetting']('authorSortBy') || 'name'
},
authorSortDesc() {
return !!this.$store.getters['user/getUserSetting']('authorSortDesc')
},
authorsSorted() {
const sortProp = this.authorSortBy
const bDesc = this.authorSortDesc ? -1 : 1
return this.authors.sort((a, b) => {
if (typeof a[sortProp] === 'number' && typeof b[sortProp] === 'number') {
// Fallback to name sort if equal
if (a[sortProp] === b[sortProp]) return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) * bDesc
return a[sortProp] > b[sortProp] ? bDesc : -bDesc
}
return a[sortProp]?.localeCompare(b[sortProp], undefined, { sensitivity: 'base' }) * bDesc
})
}
},
methods: {
async init() {
this.authors = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/authors`)
.then((response) => response.authors)
.catch((error) => {
console.error('Failed to load authors', error)
return []
})
this.loading = false
},
authorAdded(author) {
if (!this.authors.some((au) => au.id === author.id)) {
this.authors.push(author)
}
},
authorUpdated(author) {
this.authors = this.authors.map((au) => {
if (au.id === author.id) {
return author
}
return au
})
},
authorRemoved(author) {
this.authors = this.authors.filter((au) => au.id !== author.id)
},
editAuthor(author) {
this.$store.commit('globals/showEditAuthorModal', author)
}
},
mounted() {
this.init()
this.$root.socket.on('author_added', this.authorAdded)
this.$root.socket.on('author_updated', this.authorUpdated)
this.$root.socket.on('author_removed', this.authorRemoved)
},
beforeDestroy() {
this.$root.socket.off('author_added', this.authorAdded)
this.$root.socket.off('author_updated', this.authorUpdated)
this.$root.socket.off('author_removed', this.authorRemoved)
}
}
</script>
@@ -27,7 +27,7 @@ export default {
// Redirect podcast libraries
const library = libraryData.library
if (library.mediaType === 'podcast' && (params.id === 'collections' || params.id === 'series')) {
if (library.mediaType === 'podcast' && (params.id === 'collections' || params.id === 'series' || params.id === 'authors')) {
return redirect(`/library/${libraryId}`)
}
@@ -5,7 +5,7 @@
<div id="bookshelf" class="w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
<div class="w-full max-w-4xl mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="searchInput" type="search" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2 text-sm md:text-base" />
<ui-text-input v-model="searchInput" type="search" :disabled="processing" :placeholder="$strings.MessagePodcastSearchField" class="flex-grow mr-2 text-sm md:text-base" />
<ui-btn type="submit" :disabled="processing" class="hidden md:block">{{ $strings.ButtonSubmit }}</ui-btn>
<ui-btn type="submit" :disabled="processing" class="block md:hidden" small>{{ $strings.ButtonSubmit }}</ui-btn>
</form>
@@ -22,7 +22,7 @@
<div class="flex items-center">
<a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
<widgets-explicit-indicator v-if="podcast.explicit" />
<widgets-already-in-library-indicator :already-in-library="podcast.alreadyInLibrary" />
<widgets-already-in-library-indicator v-if="podcast.alreadyInLibrary" />
</div>
<p class="text-sm md:text-base text-gray-300 whitespace-nowrap truncate">{{ $getString('LabelByAuthor', [podcast.artistName]) }}</p>
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
@@ -108,7 +108,7 @@ export default {
if (!txt || !txt.includes('<opml') || !txt.includes('<outline ')) {
// Quick lazy check for valid OPML
this.$toast.error('Invalid OPML file <opml> tag not found OR an <outline> tag was not found')
this.$toast.error(this.$strings.MessageTaskOpmlParseFastFail)
this.processing = false
return
}
@@ -117,7 +117,7 @@ export default {
.$post(`/api/podcasts/opml/parse`, { opmlText: txt })
.then((data) => {
if (!data.feeds?.length) {
this.$toast.error('No feeds found in OPML file')
this.$toast.error(this.$strings.MessageTaskOpmlParseNoneFound)
} else {
this.opmlFeeds = data.feeds || []
this.showOPMLFeedsModal = true
@@ -125,7 +125,7 @@ export default {
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Failed to parse OPML file')
this.$toast.error(this.$strings.MessageTaskOpmlParseFailed)
})
.finally(() => {
this.processing = false
@@ -191,7 +191,7 @@ export default {
return
}
if (!podcast.feedUrl) {
this.$toast.error('Invalid podcast - no feed')
this.$toast.error(this.$strings.MessageNoPodcastFeed)
return
}
this.processing = true
@@ -211,15 +211,15 @@ export default {
async fetchExistentPodcastsInYourLibrary() {
this.processing = true
const podcasts = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/items?page=0&minified=1`).catch((error) => {
const podcastsResponse = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/podcast-titles`).catch((error) => {
console.error('Failed to fetch podcasts', error)
return []
})
this.existentPodcasts = podcasts.results.map((p) => {
this.existentPodcasts = podcastsResponse.podcasts.map((p) => {
return {
title: p.media.metadata.title.toLowerCase(),
itunesId: p.media.metadata.itunesId,
id: p.id
title: p.title.toLowerCase(),
itunesId: p.itunesId,
id: p.libraryItemId
}
})
this.processing = false
+12 -1
View File
@@ -240,7 +240,8 @@ export const mutations = {
series: [],
narrators: [],
languages: [],
publishers: []
publishers: [],
publishedDecades: []
}
*/
const mediaMetadata = libraryItem.media.metadata
@@ -307,6 +308,16 @@ export const mutations = {
state.filterData.publishers.sort((a, b) => a.localeCompare(b))
}
// Add publishedDecades
if (mediaMetadata.publishedYear) {
const publishedYear = parseInt(mediaMetadata.publishedYear, 10)
const decade = Math.floor(publishedYear / 10) * 10
if (!state.filterData.publishedDecades.includes(decade)) {
state.filterData.publishedDecades.push(decade)
state.filterData.publishedDecades.sort((a, b) => a - b)
}
}
// Add language
if (mediaMetadata.language && !state.filterData.languages.includes(mediaMetadata.language)) {
state.filterData.languages.push(mediaMetadata.language)
+1 -1
View File
@@ -90,7 +90,7 @@ export const actions = {
if (state.settings.orderBy == 'media.metadata.publishedYear') {
settingsUpdate.orderBy = 'media.metadata.title'
}
const invalidFilters = ['series', 'authors', 'narrators', 'publishers', 'languages', 'progress', 'issues', 'ebooks', 'abridged']
const invalidFilters = ['series', 'authors', 'narrators', 'publishers', 'publishedDecades', 'languages', 'progress', 'issues', 'ebooks', 'abridged']
const filterByFirstPart = (state.settings.filterBy || '').split('.').shift()
if (invalidFilters.includes(filterByFirstPart)) {
settingsUpdate.filterBy = 'all'
+2 -1
View File
@@ -550,7 +550,7 @@
"LabelSleepTimer": "স্লিপ টাইমার",
"LabelSlug": "স্লাগ",
"LabelStart": "শুরু",
"LabelStartTime": "শুরু করার সময়",
"LabelStartTime": "শুরুর সময়",
"LabelStarted": "শুরু হয়েছে",
"LabelStartedAt": "এতে শুরু হয়েছে",
"LabelStatsAudioTracks": "অডিও ট্র্যাক",
@@ -901,6 +901,7 @@
"ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না",
"ToastFailedToLoadData": "ডেটা লোড করা যায়নি",
"ToastFailedToShare": "শেয়ার করতে ব্যর্থ",
"ToastFailedToUpdate": "আপডেট করতে ব্যর্থ হয়েছে",
"ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল",
"ToastInvalidUrl": "অকার্যকর ইউআরএল",
"ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে",
+12 -1
View File
@@ -465,6 +465,8 @@
"LabelPubDate": "Veröffentlichungsdatum",
"LabelPublishYear": "Jahr",
"LabelPublishedDate": "Veröffentlicht {0}",
"LabelPublishedDecade": "Jahrzehnt",
"LabelPublishedDecades": "Jahrzehnte",
"LabelPublisher": "Herausgeber",
"LabelPublishers": "Herausgeber",
"LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail",
@@ -567,7 +569,7 @@
"LabelStatsMinutesListening": "Gehörte Minuten",
"LabelStatsOverallDays": "Gesamte Tage",
"LabelStatsOverallHours": "Gesamte Stunden",
"LabelStatsWeekListening": "Wochenhördauer",
"LabelStatsWeekListening": "7-Tage-Durchschnitt",
"LabelSubtitle": "Untertitel",
"LabelSupportedFileTypes": "Unterstützte Dateitypen",
"LabelTag": "Schlagwort",
@@ -791,17 +793,24 @@
"MessageTaskFailedToMergeAudioFiles": "Fehler beim zusammenführen der Audiodateien",
"MessageTaskFailedToMoveM4bFile": "Fehler beim verschieben der m4b Datei",
"MessageTaskFailedToWriteMetadataFile": "Fehler beim schreiben der Metadaten-Datei",
"MessageTaskMatchingBooksInLibrary": "Vergleiche Bücher in Bibliothek \"{0}\"",
"MessageTaskNoFilesToScan": "Keine Dateien zum scannen",
"MessageTaskOpmlImport": "OPML-Import",
"MessageTaskOpmlImportDescription": "Podcasts von {0} RSS-Feeds werden ersrtellt",
"MessageTaskOpmlImportFeed": "OPML-Feed importieren",
"MessageTaskOpmlImportFeedDescription": "RSS-Feed \"{0}\" wird importiert",
"MessageTaskOpmlImportFeedFailed": "Podcast Feed konnte nicht geladen werden",
"MessageTaskOpmlImportFeedPodcastDescription": "Podcast \"{0}\" wird erstellt",
"MessageTaskOpmlImportFeedPodcastExists": "Der Podcast ist bereits im Pfad vorhanden",
"MessageTaskOpmlImportFeedPodcastFailed": "Erstellen des Podcasts fehlgeschlagen",
"MessageTaskOpmlImportFinished": "{0} Podcasts hinzugefügt",
"MessageTaskScanItemsAdded": "{0} hinzugefügt",
"MessageTaskScanItemsMissing": "{0} fehlend",
"MessageTaskScanItemsUpdated": "{0} aktualisiert",
"MessageTaskScanNoChangesNeeded": "Keine Änderungen nötig",
"MessageTaskScanningFileChanges": "Überprüfe \"{0}\" nach geänderten Dateien",
"MessageTaskScanningLibrary": "Bibliothek \"{0}\" wird durchsucht",
"MessageTaskTargetDirectoryNotWritable": "Das Zielverzeichnis ist schreibgeschützt",
"MessageThinking": "Nachdenken...",
"MessageUploaderItemFailed": "Hochladen fehlgeschlagen",
"MessageUploaderItemSuccess": "Erfolgreich hochgeladen!",
@@ -894,6 +903,7 @@
"ToastErrorCannotShare": "Das kann nicht nativ auf diesem Gerät freigegeben werden",
"ToastFailedToLoadData": "Daten laden fehlgeschlagen",
"ToastFailedToShare": "Fehler beim Teilen",
"ToastFailedToUpdate": "Aktualisierung ist fehlgeschlagen",
"ToastInvalidImageUrl": "Ungültiger Bild URL",
"ToastInvalidUrl": "Ungültiger URL",
"ToastItemCoverUpdateSuccess": "Titelbild aktualisiert",
@@ -912,6 +922,7 @@
"ToastLibraryScanFailedToStart": "Scan konnte nicht gestartet werden",
"ToastLibraryScanStarted": "Bibliotheksscan gestartet",
"ToastLibraryUpdateSuccess": "Bibliothek \"{0}\" aktualisiert",
"ToastMatchAllAuthorsFailed": "Nicht alle Autoren konnten zugeordnet werden",
"ToastNameEmailRequired": "Name und E-Mail sind erforderlich",
"ToastNameRequired": "Name ist erforderlich",
"ToastNewUserCreatedFailed": "Fehler beim erstellen des Accounts: \"{ 0}\"",
+33
View File
@@ -66,6 +66,7 @@
"ButtonPurgeItemsCache": "Purge Items Cache",
"ButtonQueueAddItem": "Add to queue",
"ButtonQueueRemoveItem": "Remove from queue",
"ButtonQuickEmbed": "Quick Embed",
"ButtonQuickEmbedMetadata": "Quick Embed Metadata",
"ButtonQuickMatch": "Quick Match",
"ButtonReScan": "Re-Scan",
@@ -225,6 +226,9 @@
"LabelAllUsersIncludingGuests": "All users including guests",
"LabelAlreadyInYourLibrary": "Already in your library",
"LabelAppend": "Append",
"LabelAudioBitrate": "Audio Bitrate (e.g. 128k)",
"LabelAudioChannels": "Audio Channels (1 or 2)",
"LabelAudioCodec": "Audio Codec",
"LabelAuthor": "Author",
"LabelAuthorFirstLast": "Author (First Last)",
"LabelAuthorLastFirst": "Author (Last, First)",
@@ -237,6 +241,7 @@
"LabelAutoRegister": "Auto Register",
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
"LabelBackToUser": "Back to User",
"LabelBackupAudioFiles": "Backup Audio Files",
"LabelBackupLocation": "Backup Location",
"LabelBackupsEnableAutomaticBackups": "Enable automatic backups",
"LabelBackupsEnableAutomaticBackupsHelp": "Backups saved in /metadata/backups",
@@ -303,6 +308,15 @@
"LabelEmailSettingsTestAddress": "Test Address",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Enable",
"LabelEncodingBackupLocation": "A backup of your original audio files will be stored in:",
"LabelEncodingChaptersNotEmbedded": "Chapters are not embedded in multi-track audiobooks.",
"LabelEncodingClearItemCache": "Make sure to periodically purge items cache.",
"LabelEncodingFinishedM4B": "Finished M4B will be put into your audiobook folder at:",
"LabelEncodingInfoEmbedded": "Metadata will be embedded in the audio tracks inside your audiobook folder.",
"LabelEncodingStartedNavigation": "Once the task is started you can navigate away from this page.",
"LabelEncodingTimeWarning": "Encoding can take up to 30 minutes.",
"LabelEncodingWarningAdvancedSettings": "Warning: Do not update these settings unless you are familiar with ffmpeg encoding options.",
"LabelEncodingWatcherDisabled": "If you have the watcher disabled you will need to re-scan this audiobook afterwards.",
"LabelEnd": "End",
"LabelEndOfChapter": "End of Chapter",
"LabelEpisode": "Episode",
@@ -465,6 +479,8 @@
"LabelPubDate": "Pub Date",
"LabelPublishYear": "Publish Year",
"LabelPublishedDate": "Published {0}",
"LabelPublishedDecade": "Published Decade",
"LabelPublishedDecades": "Published Decades",
"LabelPublisher": "Publisher",
"LabelPublishers": "Publishers",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
@@ -499,6 +515,7 @@
"LabelSeries": "Series",
"LabelSeriesName": "Series Name",
"LabelSeriesProgress": "Series Progress",
"LabelServerLogLevel": "Server Log Level",
"LabelServerYearReview": "Server Year in Review ({0})",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
@@ -594,6 +611,7 @@
"LabelTitle": "Title",
"LabelToolsEmbedMetadata": "Embed Metadata",
"LabelToolsEmbedMetadataDescription": "Embed metadata into audio files including cover image and chapters.",
"LabelToolsM4bEncoder": "M4B Encoder",
"LabelToolsMakeM4b": "Make M4B Audiobook File",
"LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.",
"LabelToolsSplitM4b": "Split M4B to MP3's",
@@ -619,6 +637,7 @@
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
"LabelUploaderDropFiles": "Drop files",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseAdvancedOptions": "Use Advanced Options",
"LabelUseChapterTrack": "Use chapter track",
"LabelUseFullTrack": "Use full track",
"LabelUser": "User",
@@ -667,6 +686,7 @@
"MessageConfirmDeleteMetadataProvider": "Are you sure you want to delete custom metadata provider \"{0}\"?",
"MessageConfirmDeleteNotification": "Are you sure you want to delete this notification?",
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmEmbedMetadataInAudioFiles": "Are you sure you want to embed metadata in {0} audio files?",
"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?",
@@ -700,6 +720,7 @@
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFailed": "Embed Failed!",
"MessageEmbedFinished": "Embed Finished!",
"MessageEmbedQueue": "Queued for metadata embed ({0} in queue)",
"MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "Feed URL will be {0}",
@@ -744,6 +765,7 @@
"MessageNoLogs": "No Logs",
"MessageNoMediaProgress": "No Media Progress",
"MessageNoNotifications": "No Notifications",
"MessageNoPodcastFeed": "Invalid podcast: No Feed",
"MessageNoPodcastsFound": "No podcasts found",
"MessageNoResults": "No Results",
"MessageNoSearchResultsFor": "No search results for \"{0}\"",
@@ -760,6 +782,9 @@
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePleaseWait": "Please wait...",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching",
"MessagePodcastSearchField": "Enter search term or RSS feed URL",
"MessageQuickEmbedInProgress": "Quick embed in progress",
"MessageQuickEmbedQueue": "Queued for quick embed ({0} in queue)",
"MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.",
"MessageRemoveChapter": "Remove chapter",
"MessageRemoveEpisodes": "Remove {0} episode(s)",
@@ -802,6 +827,9 @@
"MessageTaskOpmlImportFeedPodcastExists": "Podcast already exists at path",
"MessageTaskOpmlImportFeedPodcastFailed": "Failed to create podcast",
"MessageTaskOpmlImportFinished": "Added {0} podcasts",
"MessageTaskOpmlParseFailed": "Failed to parse OPML file",
"MessageTaskOpmlParseFastFail": "Invalid OPML file <opml> tag not found OR an <outline> tag was not found",
"MessageTaskOpmlParseNoneFound": "No feeds found in OPML file",
"MessageTaskScanItemsAdded": "{0} added",
"MessageTaskScanItemsMissing": "{0} missing",
"MessageTaskScanItemsUpdated": "{0} updated",
@@ -826,6 +854,10 @@
"NoteUploaderFoldersWithMediaFiles": "Folders with media files will be handled as separate library items.",
"NoteUploaderOnlyAudioFiles": "If uploading only audio files then each audio file will be handled as a separate audiobook.",
"NoteUploaderUnsupportedFiles": "Unsupported files are ignored. When choosing or dropping a folder, other files that are not in an item folder are ignored.",
"NotificationOnBackupCompletedDescription": "Triggered when a backup is completed",
"NotificationOnBackupFailedDescription": "Triggered when a backup fails",
"NotificationOnEpisodeDownloadedDescription": "Triggered when a podcast episode is auto-downloaded",
"NotificationOnTestDescription": "Event for testing the notification system",
"PlaceholderNewCollection": "New collection name",
"PlaceholderNewFolderPath": "New folder path",
"PlaceholderNewPlaylist": "New playlist name",
@@ -920,6 +952,7 @@
"ToastLibraryScanFailedToStart": "Failed to start scan",
"ToastLibraryScanStarted": "Library scan started",
"ToastLibraryUpdateSuccess": "Library \"{0}\" updated",
"ToastMatchAllAuthorsFailed": "Failed to match all authors",
"ToastNameEmailRequired": "Name and email are required",
"ToastNameRequired": "Name is required",
"ToastNewUserCreatedFailed": "Failed to create account: \"{0}\"",
+1
View File
@@ -0,0 +1 @@
{}
+4 -1
View File
@@ -465,6 +465,8 @@
"LabelPubDate": "Fecha de publicación",
"LabelPublishYear": "Año de publicación",
"LabelPublishedDate": "Publicado {0}",
"LabelPublishedDecade": "Una década de publicaciones",
"LabelPublishedDecades": "Décadas publicadas",
"LabelPublisher": "Editor",
"LabelPublishers": "Editores",
"LabelRSSFeedCustomOwnerEmail": "Correo electrónico de dueño personalizado",
@@ -920,7 +922,8 @@
"ToastLibraryScanFailedToStart": "Error al iniciar el escaneo",
"ToastLibraryScanStarted": "Se inició el escaneo de la biblioteca",
"ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" actualizada",
"ToastNameEmailRequired": "Nombre y correo electrónico obligatorios",
"ToastMatchAllAuthorsFailed": "No coincide con todos los autores",
"ToastNameEmailRequired": "Son obligatorios el nombre y el correo electrónico",
"ToastNameRequired": "Nombre obligatorio",
"ToastNewUserCreatedFailed": "Error al crear la cuenta: \"{0}\"",
"ToastNewUserCreatedSuccess": "Nueva cuenta creada",
+4
View File
@@ -465,6 +465,8 @@
"LabelPubDate": "Date de publication",
"LabelPublishYear": "Année de publication",
"LabelPublishedDate": "Publié en {0}",
"LabelPublishedDecade": "Décennie de publication",
"LabelPublishedDecades": "Décennies de publication",
"LabelPublisher": "Éditeur",
"LabelPublishers": "Éditeurs",
"LabelRSSFeedCustomOwnerEmail": "Courriel personnalisée du propriétaire",
@@ -901,6 +903,7 @@
"ToastErrorCannotShare": "Impossible de partager nativement sur cet appareil",
"ToastFailedToLoadData": "Échec du chargement des données",
"ToastFailedToShare": "Échec du partage",
"ToastFailedToUpdate": "Échec de la mise à jour",
"ToastInvalidImageUrl": "URL de l'image invalide",
"ToastInvalidUrl": "URL invalide",
"ToastItemCoverUpdateSuccess": "Couverture mise à jour",
@@ -919,6 +922,7 @@
"ToastLibraryScanFailedToStart": "Échec du démarrage de lanalyse",
"ToastLibraryScanStarted": "Analyse de la bibliothèque démarrée",
"ToastLibraryUpdateSuccess": "Bibliothèque « {0} » mise à jour",
"ToastMatchAllAuthorsFailed": "Tous les auteurs et autrices nont pas pu être classés",
"ToastNameEmailRequired": "Le nom et le courriel sont requis",
"ToastNameRequired": "Le nom est requis",
"ToastNewUserCreatedFailed": "La création du compte à échouée: « {0} »",
+4 -1
View File
@@ -463,8 +463,10 @@
"LabelProvider": "Dobavljač",
"LabelProviderAuthorizationValue": "Vrijednost autorizacijskog zaglavlja",
"LabelPubDate": "Datum izdavanja",
"LabelPublishYear": "Godina izdavanja",
"LabelPublishYear": "Godina objavljivanja",
"LabelPublishedDate": "Objavljeno {0}",
"LabelPublishedDecade": "Desetljeće objavljivanja",
"LabelPublishedDecades": "Desetljeća objavljivanja",
"LabelPublisher": "Izdavač",
"LabelPublishers": "Izdavači",
"LabelRSSFeedCustomOwnerEmail": "Prilagođena adresa e-pošte vlasnika",
@@ -920,6 +922,7 @@
"ToastLibraryScanFailedToStart": "Skeniranje nije uspjelo",
"ToastLibraryScanStarted": "Skeniranje knjižnice započelo",
"ToastLibraryUpdateSuccess": "Knjižnica \"{0}\" ažurirana",
"ToastMatchAllAuthorsFailed": "Nisu prepoznati svi autori",
"ToastNameEmailRequired": "Ime i adresa e-pošte su obavezni",
"ToastNameRequired": "Ime je obavezno",
"ToastNewUserCreatedFailed": "Račun \"{0}\" nije uspješno izrađen",
+36
View File
@@ -465,6 +465,8 @@
"LabelPubDate": "Data di pubblicazione",
"LabelPublishYear": "Anno di pubblicazione",
"LabelPublishedDate": "{0} pubblicati",
"LabelPublishedDecade": "Decennio di pubblicazione",
"LabelPublishedDecades": "Decenni di pubblicazione",
"LabelPublisher": "Editore",
"LabelPublishers": "Editori",
"LabelRSSFeedCustomOwnerEmail": "E-mail del proprietario personalizzato",
@@ -777,6 +779,38 @@
"MessageShareExpiresIn": "Scade in {0}",
"MessageShareURLWillBe": "L'indirizzo sarà: <strong>{0}</strong>",
"MessageStartPlaybackAtTime": "Avvia la riproduzione per \"{0}\" a {1}?",
"MessageTaskAudioFileNotWritable": "Il file audio «{0}» non è scrivibile",
"MessageTaskCanceledByUser": "Attività annullata dall'utente",
"MessageTaskDownloadingEpisodeDescription": "Scaricamento dell'episodio «{0}»",
"MessageTaskEmbeddingMetadata": "Metadati integrati",
"MessageTaskEmbeddingMetadataDescription": "Integrazione dei metadati nell'audiolibro «{0}»",
"MessageTaskEncodingM4b": "Codifica M4B",
"MessageTaskEncodingM4bDescription": "Codifica dell'audiolibro «{0}» in un singolo file m4b",
"MessageTaskFailed": "Fallimento",
"MessageTaskFailedToBackupAudioFile": "Non riuscita a eseguire il backup del file audio «{0}»",
"MessageTaskFailedToCreateCacheDirectory": "Non riuscita a creare la cartella della cache",
"MessageTaskFailedToEmbedMetadataInFile": "Non ha inserito i metadati nel file «{0}»",
"MessageTaskFailedToMergeAudioFiles": "Non è riuscito a fondere i file audio",
"MessageTaskFailedToMoveM4bFile": "Non è riuscito a spostare il file m4b",
"MessageTaskFailedToWriteMetadataFile": "Non è riuscito a scrivere file di metadati",
"MessageTaskMatchingBooksInLibrary": "Libri di corrispondenza in biblioteca «{0}»",
"MessageTaskNoFilesToScan": "Nessun file per la scansione",
"MessageTaskOpmlImport": "Importazione OPML",
"MessageTaskOpmlImportDescription": "Creazione di podcast da {0} flusso RSS",
"MessageTaskOpmlImportFeed": "Flusso di importazione OPML",
"MessageTaskOpmlImportFeedDescription": "Importazione del flusso RSS «{0}»",
"MessageTaskOpmlImportFeedFailed": "Impossibile ottenere il flusso del podcast",
"MessageTaskOpmlImportFeedPodcastDescription": "Creazione di podcast «{0}»",
"MessageTaskOpmlImportFeedPodcastExists": "Il podcast esiste già nel percorso",
"MessageTaskOpmlImportFeedPodcastFailed": "Errore durante la creazione del podcast",
"MessageTaskOpmlImportFinished": "{0} podcast aggiunti",
"MessageTaskScanItemsAdded": "{0} aggiunti",
"MessageTaskScanItemsMissing": "{0} mancanti",
"MessageTaskScanItemsUpdated": "{0} aggiornati",
"MessageTaskScanNoChangesNeeded": "Nessuna modifica necessaria",
"MessageTaskScanningFileChanges": "Cambiamenti di file di scansione in «{0}»",
"MessageTaskScanningLibrary": "Scansione della biblioteca «{0}»",
"MessageTaskTargetDirectoryNotWritable": "La cartella di destinazione non è scrivibile",
"MessageThinking": "Elaborazione...",
"MessageUploaderItemFailed": "Caricamento Fallito",
"MessageUploaderItemSuccess": "Caricato con successo!",
@@ -869,6 +903,7 @@
"ToastErrorCannotShare": "Impossibile condividere in modo nativo su questo dispositivo",
"ToastFailedToLoadData": "Impossibile caricare i dati",
"ToastFailedToShare": "Impossibile condividere",
"ToastFailedToUpdate": "Non aggiornato",
"ToastInvalidImageUrl": "URL dell'immagine non valido",
"ToastInvalidUrl": "URL non valido",
"ToastItemCoverUpdateSuccess": "Cover aggiornata",
@@ -887,6 +922,7 @@
"ToastLibraryScanFailedToStart": "Errore inizio scansione",
"ToastLibraryScanStarted": "Scansione Libreria iniziata",
"ToastLibraryUpdateSuccess": "Libreria \"{0}\" aggiornata",
"ToastMatchAllAuthorsFailed": "Tutti gli autori non hanno potuto essere classificati",
"ToastNameEmailRequired": "Nome ed email sono obbligatori",
"ToastNameRequired": "Il nome è obbligatorio",
"ToastNewUserCreatedFailed": "Impossibile creare l'account: \"{0}\"",
+32 -3
View File
@@ -19,6 +19,7 @@
"ButtonChooseFiles": "Pasirinkite failus",
"ButtonClearFilter": "Valyti filtrą",
"ButtonCloseFeed": "Uždaryti srautą",
"ButtonCloseSession": "Uždaryti Atidarytą sesiją",
"ButtonCollections": "Kolekcijos",
"ButtonConfigureScanner": "Konfigūruoti skenerį",
"ButtonCreate": "Kurti",
@@ -28,11 +29,14 @@
"ButtonEdit": "Redaguoti",
"ButtonEditChapters": "Redaguoti skyrius",
"ButtonEditPodcast": "Redaguoti tinklalaidę",
"ButtonEnable": "Įjungti",
"ButtonForceReScan": "Priverstinai nuskaityti iš naujo",
"ButtonFullPath": "Visas kelias",
"ButtonHide": "Slėpti",
"ButtonHome": "Pradžia",
"ButtonIssues": "Problemos",
"ButtonJumpBackward": "Peršokti atgal",
"ButtonJumpForward": "Peršokti į priekį",
"ButtonLatest": "Naujausias",
"ButtonLibrary": "Biblioteka",
"ButtonLogout": "Atsijungti",
@@ -42,12 +46,19 @@
"ButtonMatchAllAuthors": "Pritaikyti visus autorius",
"ButtonMatchBooks": "Pritaikyti knygas",
"ButtonNevermind": "Nesvarbu",
"ButtonNext": "Kitas",
"ButtonNextChapter": "Kitas Skyrius",
"ButtonNextItemInQueue": "Kitas eilėje",
"ButtonOk": "Ok",
"ButtonOpenFeed": "Atidaryti srautą",
"ButtonOpenManager": "Atidaryti tvarkyklę",
"ButtonPause": "Pauzė",
"ButtonPlay": "Groti",
"ButtonPlayAll": "Groti Visus",
"ButtonPlaying": "Grojama",
"ButtonPlaylists": "Grojaraščiai",
"ButtonPrevious": "Praeitas",
"ButtonPreviousChapter": "Praeitas Skyrius",
"ButtonPurgeAllCache": "Valyti visą saugyklą",
"ButtonPurgeItemsCache": "Valyti elementų saugyklą",
"ButtonQueueAddItem": "Pridėti į eilę",
@@ -55,6 +66,9 @@
"ButtonQuickMatch": "Greitas pritaikymas",
"ButtonReScan": "Iš naujo nuskaityti",
"ButtonRead": "Skaityti",
"ButtonReadLess": "Mažiau",
"ButtonReadMore": "Daugiau",
"ButtonRefresh": "Atnaujinti",
"ButtonRemove": "Pašalinti",
"ButtonRemoveAll": "Pašalinti viską",
"ButtonRemoveAllLibraryItems": "Pašalinti visus bibliotekos elementus",
@@ -72,12 +86,15 @@
"ButtonSelectFolderPath": "Pasirinkti aplanko kelią",
"ButtonSeries": "Serijos",
"ButtonSetChaptersFromTracks": "Nustatyti skyrius iš takelių",
"ButtonShare": "Dalintis",
"ButtonShiftTimes": "Perstumti laikus",
"ButtonShow": "Rodyti",
"ButtonStartM4BEncode": "Pradėti M4B kodavimą",
"ButtonStartMetadataEmbed": "Pradėti metaduomenų įterpimą",
"ButtonStats": "Statistika",
"ButtonSubmit": "Pateikti",
"ButtonTest": "Testuoti",
"ButtonUnlinkOpenId": "Atsieti OpenID",
"ButtonUpload": "Įkelti",
"ButtonUploadBackup": "Įkelti atsarginę kopiją",
"ButtonUploadCover": "Įkelti viršelį",
@@ -86,11 +103,15 @@
"ButtonUserEdit": "Redaguoti naudotoją {0}",
"ButtonViewAll": "Peržiūrėti visus",
"ButtonYes": "Taip",
"ErrorUploadFetchMetadataAPI": "Klaida gaunant metaduomenis",
"ErrorUploadFetchMetadataNoResults": "Nepavyko gauti metaduomenų - pabandykite atnaujinti pavadinimą ir/ar autorių.",
"ErrorUploadLacksTitle": "Pavadinimas yra privalomas",
"HeaderAccount": "Paskyra",
"HeaderAdvanced": "Papildomi",
"HeaderAppriseNotificationSettings": "Apprise pranešimo nustatymai",
"HeaderAudioTracks": "Garso takeliai",
"HeaderAudiobookTools": "Audioknygų failų valdymo įrankiai",
"HeaderAuthentication": "Autentifikacija",
"HeaderBackups": "Atsarginės kopijos",
"HeaderChangePassword": "Pakeisti slaptažodį",
"HeaderChapters": "Skyriai",
@@ -99,6 +120,7 @@
"HeaderCollectionItems": "Kolekcijos elementai",
"HeaderCover": "Viršelis",
"HeaderCurrentDownloads": "Dabartiniai parsisiuntimai",
"HeaderCustomMessageOnLogin": "Pritaikyta prisijungimo žinutė",
"HeaderDetails": "Detalės",
"HeaderDownloadQueue": "Parsisiuntimo eilė",
"HeaderEbookFiles": "Eknygos failai",
@@ -189,7 +211,7 @@
"LabelBackToUser": "Grįžti į naudotoją",
"LabelBackupsEnableAutomaticBackups": "Įjungti automatinį atsarginių kopijų kūrimą",
"LabelBackupsEnableAutomaticBackupsHelp": "Atsarginės kopijos bus išsaugotos /metadata/backups aplanke",
"LabelBackupsMaxBackupSize": "Maksimalus atsarginių kopijų dydis (GB)",
"LabelBackupsMaxBackupSize": "Maksimalus atsarginių kopijų dydis (GB) (0 - neribotai)",
"LabelBackupsMaxBackupSizeHelp": "Jei konfigūruotas dydis viršijamas, atsarginės kopijos nebus sukurtos, kad būtų išvengta klaidingų konfigūracijų.",
"LabelBackupsNumberToKeep": "Laikytinų atsarginių kopijų skaičius",
"LabelBackupsNumberToKeepHelp": "Tik viena atsarginė kopija bus pašalinta vienu metu, todėl jei jau turite daugiau atsarginių kopijų nei nurodyta, turite jas pašalinti rankiniu būdu.",
@@ -397,7 +419,7 @@
"LabelSettingsExperimentalFeatures": "Eksperimentiniai funkcionalumai",
"LabelSettingsExperimentalFeaturesHelp": "Funkcijos, kurios yra kuriamos ir laukiami jūsų komentarai. Spustelėkite, kad atidarytumėte „GitHub“ diskusiją.",
"LabelSettingsFindCovers": "Rasti viršelius",
"LabelSettingsFindCoversHelp": "Jei jūsų audioknyga neturi įterpto viršelio arba viršelio paveikslėlio aplanko, skeneris bandys rasti viršelį.<br>Pastaba: Tai padidins skenavimo trukmę.",
"LabelSettingsFindCoversHelp": "Jei jūsų audioknyga neturi įterpto viršelio arba viršelio paveikslėlio aplanke, bandyti rasti viršelį.<br>Pastaba: Tai padidins skenavimo trukmę.",
"LabelSettingsHideSingleBookSeries": "Slėpti serijas, turinčias tik vieną knygą",
"LabelSettingsHideSingleBookSeriesHelp": "Serijos, turinčios tik vieną knygą, bus paslėptos nuo serijų puslapio ir pagrindinio puslapio lentynų.",
"LabelSettingsHomePageBookshelfView": "Naudoti pagrindinio puslapio knygų lentynų vaizdą",
@@ -413,7 +435,7 @@
"LabelSettingsSquareBookCovers": "Naudoti kvadratinius knygos viršelius",
"LabelSettingsSquareBookCoversHelp": "Naudoti kvadratinius viršelius vietoj standartinių 1.6:1 knygų viršelių",
"LabelSettingsStoreCoversWithItem": "Saugoti viršelius su elementu",
"LabelSettingsStoreCoversWithItemHelp": "Pagal nutylėjimą viršeliai saugomi /metadata/items aplanke, įjungus šią parinktį viršeliai bus saugomi jūsų bibliotekos elemento aplanke. Bus išsaugotas tik vienas cover pavadinimo failas.",
"LabelSettingsStoreCoversWithItemHelp": "Pagal nutylėjimą viršeliai saugomi /metadata/items aplanke, įjungus šią parinktį viršeliai bus saugomi jūsų bibliotekos elemento aplanke. Bus išsaugotas tik vienas failas su \"cover\" pavadinimu.",
"LabelSettingsStoreMetadataWithItem": "Saugoti metaduomenis su elementu",
"LabelSettingsStoreMetadataWithItemHelp": "Pagal nutylėjimą metaduomenų failai saugomi /metadata/items aplanke, įjungus šią parinktį metaduomenų failai bus saugomi jūsų bibliotekos elemento aplanke",
"LabelSettingsTimeFormat": "Laiko formatas",
@@ -642,10 +664,17 @@
"ToastBookmarkUpdateSuccess": "Žyma atnaujinta",
"ToastChaptersHaveErrors": "Skyriai turi klaidų",
"ToastChaptersMustHaveTitles": "Skyriai turi turėti pavadinimus",
"ToastChaptersRemoved": "Skyriai pašalinti",
"ToastCollectionItemsAddFailed": "Nepavyko pridėti į kolekciją",
"ToastCollectionItemsAddSuccess": "Pridėta į kolekciją",
"ToastCollectionItemsRemoveSuccess": "Elementai pašalinti iš kolekcijos",
"ToastCollectionRemoveSuccess": "Kolekcija pašalinta",
"ToastCollectionUpdateSuccess": "Kolekcija atnaujinta",
"ToastCoverUpdateFailed": "Viršelio atnaujinimas nepavyko",
"ToastDeviceTestEmailSuccess": "Bandomasis el. laiškas išsiųstas",
"ToastItemCoverUpdateSuccess": "Elemento viršelis atnaujintas",
"ToastItemDeletedFailed": "Nepavyko ištrinti",
"ToastItemDeletedSuccess": "Ištrinta",
"ToastItemDetailsUpdateSuccess": "Elemento detalės atnaujintos",
"ToastItemMarkedAsFinishedFailed": "Pažymėti kaip Baigta nepavyko",
"ToastItemMarkedAsFinishedSuccess": "Elementas pažymėtas kaip Baigta",
+6 -2
View File
@@ -31,6 +31,7 @@
"ButtonForceReScan": "Forceer nieuwe scan",
"ButtonFullPath": "Volledig pad",
"ButtonHide": "Verberg",
"ButtonHome": "Thuis",
"ButtonIssues": "Problemen",
"ButtonJumpBackward": "Spring achteruit",
"ButtonJumpForward": "Spring vooruit",
@@ -76,6 +77,7 @@
"ButtonScanLibrary": "Scan bibliotheek",
"ButtonSearch": "Zoeken",
"ButtonSelectFolderPath": "Maplocatie selecteren",
"ButtonSeries": "Series",
"ButtonSetChaptersFromTracks": "Maak hoofdstukken op basis van tracks",
"ButtonShare": "Deel",
"ButtonShiftTimes": "Tijden verschuiven",
@@ -93,6 +95,7 @@
"ErrorUploadFetchMetadataAPI": "Error metadata ophalen",
"ErrorUploadFetchMetadataNoResults": "Kan metadata niet ophalen - probeer de titel en/of auteur te updaten",
"ErrorUploadLacksTitle": "Moet een titel hebben",
"HeaderAccount": "Account",
"HeaderAdvanced": "Geavanceerd",
"HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen",
"HeaderAudioTracks": "Audiotracks",
@@ -105,6 +108,7 @@
"HeaderCollectionItems": "Collectie-objecten",
"HeaderCover": "Omslag",
"HeaderCurrentDownloads": "Huidige downloads",
"HeaderDetails": "Details",
"HeaderDownloadQueue": "Download-wachtrij",
"HeaderEbookFiles": "Ebook bestanden",
"HeaderEmail": "E-mail",
@@ -207,8 +211,8 @@
"LabelCollections": "Collecties",
"LabelComplete": "Compleet",
"LabelConfirmPassword": "Bevestig wachtwoord",
"LabelContinueListening": "Verder luisteren",
"LabelContinueReading": "Verder luisteren",
"LabelContinueListening": "Verder Luisteren",
"LabelContinueReading": "Verder lezen",
"LabelContinueSeries": "Ga verder met serie",
"LabelCoverImageURL": "Coverafbeelding URL",
"LabelCreatedAt": "Gecreëerd op",
+42 -39
View File
@@ -134,7 +134,7 @@
"HeaderEmail": "E-pošta",
"HeaderEmailSettings": "Nastavitve e-pošte",
"HeaderEpisodes": "Epizode",
"HeaderEreaderDevices": "Ebralne naprave",
"HeaderEreaderDevices": "E-bralniki",
"HeaderEreaderSettings": "Nastavitve ebralnika",
"HeaderFiles": "Datoteke",
"HeaderFindChapters": "Najdi poglavja",
@@ -146,7 +146,7 @@
"HeaderLibraries": "Knjižnice",
"HeaderLibraryFiles": "Datoteke knjižnice",
"HeaderLibraryStats": "Statistika knjižnice",
"HeaderListeningSessions": "Seje poslušanja",
"HeaderListeningSessions": "Sej poslušanja",
"HeaderListeningStats": "Statistika poslušanja",
"HeaderLogin": "Prijava",
"HeaderLogs": "Dnevniki",
@@ -161,10 +161,10 @@
"HeaderNotificationCreate": "Ustvari obvestilo",
"HeaderNotificationUpdate": "Posodobi obvestilo",
"HeaderNotifications": "Obvestila",
"HeaderOpenIDConnectAuthentication": "Preverjanje pristnosti OpenID Connect",
"HeaderOpenIDConnectAuthentication": "Prijava z OpenID Connect",
"HeaderOpenRSSFeed": "Odpri vir RSS",
"HeaderOtherFiles": "Ostale datoteke",
"HeaderPasswordAuthentication": "Preverjanje pristnosti gesla",
"HeaderPasswordAuthentication": "Preverjanje pristnosti z geslom",
"HeaderPermissions": "Dovoljenja",
"HeaderPlayerQueue": "Čakalna vrsta predvajalnika",
"HeaderPlayerSettings": "Nastavitve predvajalnika",
@@ -186,7 +186,7 @@
"HeaderSettingsDisplay": "Zaslon",
"HeaderSettingsExperimental": "Eksperimentalne funkcije",
"HeaderSettingsGeneral": "Splošno",
"HeaderSettingsScanner": "Skener",
"HeaderSettingsScanner": "Pregledovalnik",
"HeaderSleepTimer": "Časovnik za izklop",
"HeaderStatsLargestItems": "Največji elementi",
"HeaderStatsLongestItems": "Najdaljši elementi (ure)",
@@ -219,7 +219,7 @@
"LabelAddedAt": "Dodano ob",
"LabelAddedDate": "Dodano {0}",
"LabelAdminUsersOnly": "Samo administratorji",
"LabelAll": "Vsi",
"LabelAll": "Vse",
"LabelAllUsers": "Vsi uporabniki",
"LabelAllUsersExcludingGuests": "Vsi uporabniki razen gosti",
"LabelAllUsersIncludingGuests": "Vsi uporabniki vključno z gosti",
@@ -245,7 +245,7 @@
"LabelBackupsNumberToKeep": "Število varnostnih kopij, ki jih je treba hraniti",
"LabelBackupsNumberToKeepHelp": "Naenkrat bo odstranjena samo ena varnostna kopija, če že imate več varnostnih kopij, jih odstranite ročno.",
"LabelBitrate": "Bitna hitrost",
"LabelBooks": "Knjige",
"LabelBooks": "knjig",
"LabelButtonText": "Besedilo gumba",
"LabelByAuthor": "od {0}",
"LabelChangePassword": "Spremeni geslo",
@@ -400,8 +400,8 @@
"LabelMinute": "Minuta",
"LabelMinutes": "Minute",
"LabelMissing": "Manjkajoče",
"LabelMissingEbook": "Nima nobene eknjige",
"LabelMissingSupplementaryEbook": "Nima nobene dodatne eknjige",
"LabelMissingEbook": "Nima nobene e-knjige",
"LabelMissingSupplementaryEbook": "Nima nobene dodatne e-knjige",
"LabelMobileRedirectURIs": "Dovoljeni mobilni preusmeritveni URI-ji",
"LabelMobileRedirectURIsDescription": "To je seznam dovoljenih veljavnih preusmeritvenih URI-jev za mobilne aplikacije. Privzeti je <code>audiobookshelf://oauth</code>, ki ga lahko odstranite ali dopolnite z dodatnimi URI-ji za integracijo aplikacij tretjih oseb. Uporaba zvezdice (<code>*</code>) kot edinega vnosa dovoljuje kateri koli URI.",
"LabelMore": "Več",
@@ -463,10 +463,12 @@
"LabelProvider": "Ponudnik",
"LabelProviderAuthorizationValue": "Vrednost glave avtorizacije",
"LabelPubDate": "Datum objave",
"LabelPublishYear": "Leto objave",
"LabelPublishedDate": "Objavljeno {0}",
"LabelPublisher": "Založnik",
"LabelPublishers": "Založniki",
"LabelPublishYear": "Leto izdaje",
"LabelPublishedDate": "Izdano {0}",
"LabelPublishedDecade": "Desetletje izdaje",
"LabelPublishedDecades": "Desetletja izdaje",
"LabelPublisher": "Izdajatelj",
"LabelPublishers": "Izdajatelji",
"LabelRSSFeedCustomOwnerEmail": "E-pošta lastnika po meri",
"LabelRSSFeedCustomOwnerName": "Ime lastnika po meri",
"LabelRSSFeedOpen": "Odprt vir RSS",
@@ -507,11 +509,11 @@
"LabelSettingsBookshelfViewHelp": "Skeuomorfna oblika z lesenimi policami",
"LabelSettingsChromecastSupport": "Podpora za Chromecast",
"LabelSettingsDateFormat": "Oblika datuma",
"LabelSettingsDisableWatcher": "Onemogoči pregledovalca",
"LabelSettingsDisableWatcherForLibrary": "Onemogoči pregledovalca map za knjižnico",
"LabelSettingsDisableWatcher": "Onemogoči spremljanje datotečnega sistema",
"LabelSettingsDisableWatcherForLibrary": "Onemogoči spremljanje map za knjižnico",
"LabelSettingsDisableWatcherHelp": "Onemogoči samodejno dodajanje/posodabljanje elementov, ko so zaznane spremembe datoteke. *Potreben je ponovni zagon strežnika",
"LabelSettingsEnableWatcher": "Omogoči pregledovalca",
"LabelSettingsEnableWatcherForLibrary": "Omogoči pregledovalca map za knjižnico",
"LabelSettingsEnableWatcher": "Omogoči spremljanje sprememb",
"LabelSettingsEnableWatcherForLibrary": "Omogoči spremljanje sprememb v mapi knjižnice",
"LabelSettingsEnableWatcherHelp": "Omogoča samodejno dodajanje/posodabljanje elementov, ko so zaznane spremembe datoteke. *Potreben je ponovni zagon strežnika",
"LabelSettingsEpubsAllowScriptedContent": "Dovoli skriptirano vsebino v epubih",
"LabelSettingsEpubsAllowScriptedContentHelp": "Dovoli datotekam epub izvajanje skript. Priporočljivo je, da to nastavitev pustite onemogočeno, razen če zaupate viru datotek epub.",
@@ -526,12 +528,12 @@
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči prejšnje knjige v nadaljevanju serije",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Polica z domačo stranjo Nadaljuj serijo prikazuje prvo nezačeto knjigo v seriji, ki ima vsaj eno dokončano knjigo in ni nobene knjige v teku. Če omogočite to nastavitev, se bo serija nadaljevala od najbolj dokončane knjige namesto od prve nezačete knjige.",
"LabelSettingsParseSubtitles": "Uporabi podnapise",
"LabelSettingsParseSubtitlesHelp": "Izvleci podnapise iz imen map zvočnih knjig.<br>Podnaslov mora biti ločen z \" - \"<br>npr. »Naslov knjige Tu podnapis« ima podnaslov »Tu podnapis«",
"LabelSettingsParseSubtitlesHelp": "Izvleci podnapise iz imen map zvočnih knjig.<br>Podnapis mora biti ločen z \" - \"<br>npr. \"Naslov knjige tu podnapis\" ima podnapis \"tu podnapis\"",
"LabelSettingsPreferMatchedMetadata": "Prednost imajo ujemajoči se metapodatki",
"LabelSettingsPreferMatchedMetadataHelp": "Pri uporabi hitrega ujemanja bodo ujemajoči se podatki preglasili podrobnosti artikla. Hitro ujemanje bo privzeto izpolnil samo manjkajoče podrobnosti.",
"LabelSettingsSkipMatchingBooksWithASIN": "Preskoči ujemajoče se knjige, ki že imajo ASIN",
"LabelSettingsSkipMatchingBooksWithISBN": "Preskoči ujemajoče se knjige, ki že imajo oznako ISBN",
"LabelSettingsSortingIgnorePrefixes": "Pri razvrščanju ne upoštevajte predpon",
"LabelSettingsSortingIgnorePrefixes": "Pri razvrščanju ne upoštevaj predpon",
"LabelSettingsSortingIgnorePrefixesHelp": "npr. za naslov knjige s predpono \"the\" bi se \"The Book Title\" razvrstil kot \"Book Title, The\"",
"LabelSettingsSquareBookCovers": "Uporabi kvadratne platnice knjig",
"LabelSettingsSquareBookCoversHelp": "Raje uporabi kvadratne platnice kot standardne knjižne platnice 1.6:1",
@@ -558,15 +560,15 @@
"LabelStatsBestDay": "Najboljši dan",
"LabelStatsDailyAverage": "Dnevno povprečje",
"LabelStatsDays": "Dnevi",
"LabelStatsDaysListened": "Poslušani dnevi",
"LabelStatsDaysListened": "Dnevi poslušanja",
"LabelStatsHours": "Ure",
"LabelStatsInARow": "v vrsti",
"LabelStatsItemsFinished": "Končani elementi",
"LabelStatsItemsInLibrary": "Elementi v knjižnici",
"LabelStatsMinutes": "minute",
"LabelStatsMinutesListening": "Poslušane minute",
"LabelStatsMinutesListening": "Minut poslušanja",
"LabelStatsOverallDays": "Skupaj dnevi",
"LabelStatsOverallHours": "Skupaj ure",
"LabelStatsOverallHours": "Skupaj ur",
"LabelStatsWeekListening": "Tednov poslušanja",
"LabelSubtitle": "Podnapis",
"LabelSupportedFileTypes": "Podprte vrste datotek",
@@ -594,8 +596,8 @@
"LabelTitle": "Naslov",
"LabelToolsEmbedMetadata": "Vdelaj metapodatke",
"LabelToolsEmbedMetadataDescription": "Vdelajte metapodatke v zvočne datoteke, vključno s sliko naslovnice in poglavji.",
"LabelToolsMakeM4b": "Ustvari datoteko zvočne knjige M4B",
"LabelToolsMakeM4bDescription": "Ustvarite datoteko zvočne knjige .M4B z vdelanimi metapodatki, sliko naslovnice in poglavji.",
"LabelToolsMakeM4b": "Ustvari M4B datoteko zvočne knjige",
"LabelToolsMakeM4bDescription": "Ustvari zvočno knjigo v .M4B obliki z vdelanimi metapodatki, sliko naslovnice in poglavji.",
"LabelToolsSplitM4b": "Razdeli M4B v MP3 datoteke",
"LabelToolsSplitM4bDescription": "Ustvarite MP3 datoteke iz datoteke M4B, razdeljene po poglavjih z vdelanimi metapodatki, naslovno sliko in poglavji.",
"LabelTotalDuration": "Skupno trajanje",
@@ -610,7 +612,7 @@
"LabelUnabridged": "Neskrajšano",
"LabelUndo": "Razveljavi",
"LabelUnknown": "Neznano",
"LabelUnknownPublishDate": "Neznan datum objave",
"LabelUnknownPublishDate": "Neznan datum izdaje",
"LabelUpdateCover": "Posodobi naslovnico",
"LabelUpdateCoverHelp": "Dovoli prepisovanje obstoječih naslovnic za izbrane knjige, ko se najde ujemanje",
"LabelUpdateDetails": "Posodobi podrobnosti",
@@ -640,7 +642,7 @@
"LabelYourPlaylists": "Tvoje seznami predvajanj",
"LabelYourProgress": "Tvoj napredek",
"MessageAddToPlayerQueue": "Dodaj v čakalno vrsto predvajalnika",
"MessageAppriseDescription": "Če želite uporabljati to funkcijo, morate imeti zagnan primerek <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> ali API, ki bo obravnaval te iste zahteve. <br />Url API-ja Apprise mora biti celotna pot URL-ja za pošiljanje obvestila, npr. če je vaš primerek API-ja postrežen na <code>http://192.168.1.1:8337</code>, bi morali vnesti <code >http://192.168.1.1:8337/notify</code>.",
"MessageAppriseDescription": "Če želite uporabljati to funkcijo, morate imeti zagnano namestitev <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> ali API, ki bo obravnavala te iste zahteve. <br />Url API-ja Apprise mora biti celotna pot URL-ja za pošiljanje obvestila, npr. če je vaša namestitev API-ja postrežena na <code>http://192.168.1.1:8337</code>, bi morali vnesti <code >http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Varnostne kopije vključujejo uporabnike, napredek uporabnikov, podrobnosti elementov knjižnice, nastavitve strežnika in slike, shranjene v <code>/metadata/items</code> & <code>/metadata/authors</code>. Varnostne kopije <strong>ne</strong> vključujejo datotek, shranjenih v mapah vaše knjižnice.",
"MessageBackupsLocationEditNote": "Opomba: Posodabljanje lokacije varnostne kopije ne bo premaknilo ali spremenilo obstoječih varnostnih kopij",
"MessageBackupsLocationNoEditNote": "Opomba: Lokacija varnostne kopije je nastavljena s spremenljivko okolja in je tu ni mogoče spremeniti.",
@@ -651,9 +653,9 @@
"MessageBookshelfNoResultsForFilter": "Ni rezultatov za filter \"{0}: {1}\"",
"MessageBookshelfNoResultsForQuery": "Ni rezultatov za poizvedbo",
"MessageBookshelfNoSeries": "Nimate serij",
"MessageChapterEndIsAfter": "Konec poglavja je za koncem vaše zvočne knjige",
"MessageChapterEndIsAfter": "Konec poglavja je po koncu zvočne knjige",
"MessageChapterErrorFirstNotZero": "Prvo poglavje se mora začeti pri 0",
"MessageChapterErrorStartGteDuration": "Neveljaven začetni čas mora biti krajši od trajanja zvočne knjige",
"MessageChapterErrorStartGteDuration": "Neveljaven začetni čas, mora biti krajši od trajanja zvočne knjige",
"MessageChapterErrorStartLtPrev": "Neveljaven začetni čas mora biti večji od ali enak začetnemu času prejšnjega poglavja",
"MessageChapterStartIsAfter": "Začetek poglavja je po koncu vaše zvočne knjige",
"MessageCheckingCron": "Preverjam cron...",
@@ -667,7 +669,7 @@
"MessageConfirmDeleteMetadataProvider": "Ali ste prepričani, da želite izbrisati ponudnika metapodatkov po meri \"{0}\"?",
"MessageConfirmDeleteNotification": "Ali ste prepričani, da želite izbrisati to obvestilo?",
"MessageConfirmDeleteSession": "Ali ste prepričani, da želite izbrisati to sejo?",
"MessageConfirmForceReScan": "Ali ste prepričani, da želite vsiliti ponovno iskanje?",
"MessageConfirmForceReScan": "Ali ste prepričani, da želite vsiliti ponovno pregledovanje?",
"MessageConfirmMarkAllEpisodesFinished": "Ali ste prepričani, da želite označiti vse epizode kot dokončane?",
"MessageConfirmMarkAllEpisodesNotFinished": "Ali ste prepričani, da želite vse epizode označiti kot nedokončane?",
"MessageConfirmMarkItemFinished": "Ali ste prepričani, da želite \"{0}\" označiti kot dokončanega?",
@@ -678,7 +680,7 @@
"MessageConfirmPurgeCache": "Čiščenje predpomnilnika bo izbrisalo celoten imenik v <code>/metadata/cache</code>. <br /><br />Ali ste prepričani, da želite odstraniti imenik predpomnilnika?",
"MessageConfirmPurgeItemsCache": "Čiščenje predpomnilnika elementov bo izbrisalo celoten imenik na <code>/metadata/cache/items</code>.<br />Ste prepričani?",
"MessageConfirmQuickEmbed": "Opozorilo! Hitra vdelava ne bo varnostno kopirala vaših zvočnih datotek. Prepričajte se, da imate varnostno kopijo zvočnih datotek. <br><br>Ali želite nadaljevati?",
"MessageConfirmReScanLibraryItems": "Ali ste prepričani, da želite ponovno poiskati {0} elementov?",
"MessageConfirmReScanLibraryItems": "Ali ste prepričani, da želite ponovno pregledati {0} elementov?",
"MessageConfirmRemoveAllChapters": "Ali ste prepričani, da želite odstraniti vsa poglavja?",
"MessageConfirmRemoveAuthor": "Ali ste prepričani, da želite odstraniti avtorja \"{0}\"?",
"MessageConfirmRemoveCollection": "Ali ste prepričani, da želite odstraniti zbirko \"{0}\"?",
@@ -704,7 +706,7 @@
"MessageEreaderDevices": "Da zagotovite dostavo e-knjig, boste morda morali dodati zgornji e-poštni naslov kot veljavnega pošiljatelja za vsako spodaj navedeno napravo.",
"MessageFeedURLWillBe": "URL vira bo {0}",
"MessageFetching": "Pridobivam...",
"MessageForceReScanDescription": "bo znova pregledal vse datoteke kot nov pregled. Oznake ID3 zvočnih datotek, datoteke OPF in besedilne datoteke bodo pregledane kot nove.",
"MessageForceReScanDescription": "bo znova pregledal vse datoteke kot pregled od začetka. Oznake ID3 zvočnih datotek, datoteke OPF in besedilne datoteke bodo pregledane kot nove.",
"MessageImportantNotice": "Pomembno obvestilo!",
"MessageInsertChapterBelow": "Spodaj vstavite poglavje",
"MessageItemsSelected": "{0} izbranih elementov",
@@ -716,12 +718,12 @@
"MessageLogsDescription": "Dnevniki so shranjeni v <code>/metadata/logs</code> kot datoteke JSON. Dnevniki zrušitev so shranjeni v <code>/metadata/logs/crash_logs.txt</code>.",
"MessageM4BFailed": "M4B ni uspel!",
"MessageM4BFinished": "M4B končan!",
"MessageMapChapterTitles": "Preslikajte naslove poglavij v obstoječa poglavja zvočne knjige brez prilagajanja časovnih žigov",
"MessageMapChapterTitles": "Preslikaj naslove poglavij v obstoječa poglavja zvočne knjige brez prilagajanja časovnih indentifikatorjev",
"MessageMarkAllEpisodesFinished": "Označi vse epizode kot končane",
"MessageMarkAllEpisodesNotFinished": "Označi vse epizode kot nedokončane",
"MessageMarkAsFinished": "Označi kot dokončano",
"MessageMarkAsNotFinished": "Označi kot nedokončano",
"MessageMatchBooksDescription": "bo poskušal povezati knjige v knjižnici s knjigo izbranega ponudnika iskanja in izpolniti prazne podatke in naslovnico. Ne prepisujejo se pa podrobnosti.",
"MessageMatchBooksDescription": "bo poskušal povezati knjige v knjižnici s knjigo izbranega ponudnika iskanja in izpolniti prazne podatke in naslovnico. Ne prepisuje čez obstoječe podatke.",
"MessageNoAudioTracks": "Ni zvočnih posnetkov",
"MessageNoAuthors": "Brez avtorjev",
"MessageNoBackups": "Brez varnostnih kopij",
@@ -791,7 +793,7 @@
"MessageTaskFailedToMergeAudioFiles": "Zvočnih datotek ni bilo mogoče združiti",
"MessageTaskFailedToMoveM4bFile": "Datoteke m4b ni bilo mogoče premakniti",
"MessageTaskFailedToWriteMetadataFile": "Metapodatke ni bilo mogoče zapisati v datoteke",
"MessageTaskMatchingBooksInLibrary": "Ujemam knjige v knjižnici \"{0}\"",
"MessageTaskMatchingBooksInLibrary": "Prepoznavam knjige v knjižnici \"{0}\"",
"MessageTaskNoFilesToScan": "Ni datotek za pregledovanje",
"MessageTaskOpmlImport": "Uvoz OPML",
"MessageTaskOpmlImportDescription": "Ustvarjanje podcastov iz {0} virov RSS",
@@ -807,14 +809,14 @@
"MessageTaskScanItemsUpdated": "{0} posodobljeno",
"MessageTaskScanNoChangesNeeded": "Spremembe niso potrebne",
"MessageTaskScanningFileChanges": "Pregledovanje sprememb v datoteki \"{0}\"",
"MessageTaskScanningLibrary": "Pregled knjižnice \"{0}\"",
"MessageTaskScanningLibrary": "Pregledujem knjižnico \"{0}\"",
"MessageTaskTargetDirectoryNotWritable": "Ciljni imenik ni zapisljiv",
"MessageThinking": "Razmišljam...",
"MessageUploaderItemFailed": "Nalaganje ni uspelo",
"MessageUploaderItemSuccess": "Uspešno naloženo!",
"MessageUploading": "Nalaganje...",
"MessageValidCronExpression": "Veljaven cron izraz",
"MessageWatcherIsDisabledGlobally": "Pregledovalec je globalno onemogočen v nastavitvah strežnika",
"MessageWatcherIsDisabledGlobally": "Spremljanje sprememb datotek je globalno onemogočeno v nastavitvah strežnika",
"MessageXLibraryIsEmpty": "{0} Knjižnica je prazna!",
"MessageYourAudiobookDurationIsLonger": "Trajanje vaše zvočne knjige je daljše od ugotovljenega trajanja",
"MessageYourAudiobookDurationIsShorter": "Trajanje vaše zvočne knjige je krajše od ugotovljenega trajanja",
@@ -834,11 +836,11 @@
"StatsAuthorsAdded": "dodanih avtorjev",
"StatsBooksAdded": "dodanih knjig",
"StatsBooksAdditional": "Nekateri dodatki vključujejo…",
"StatsBooksFinished": "končane knjige",
"StatsBooksFinished": "končanih knjig",
"StatsBooksFinishedThisYear": "Nekaj knjig, ki so bile dokončane letos…",
"StatsBooksListenedTo": "poslušane knjige",
"StatsBooksListenedTo": "poslušanih knjig",
"StatsCollectionGrewTo": "Vaša zbirka knjig se je povečala na …",
"StatsSessions": "seje",
"StatsSessions": "sej",
"StatsSpentListening": "porabil za poslušanje",
"StatsTopAuthor": "TOP AVTOR",
"StatsTopAuthors": "TOP AVTORJI",
@@ -920,6 +922,7 @@
"ToastLibraryScanFailedToStart": "Pregleda ni bilo mogoče začeti",
"ToastLibraryScanStarted": "Pregled knjižnice se je začel",
"ToastLibraryUpdateSuccess": "Knjižnica \"{0}\" je bila posodobljena",
"ToastMatchAllAuthorsFailed": "Ujemanje vseh avtorjev ni bilo uspešno",
"ToastNameEmailRequired": "Ime in e-pošta sta obvezna",
"ToastNameRequired": "Ime je obvezno",
"ToastNewUserCreatedFailed": "Računa ni bilo mogoče ustvariti: \"{0}\"",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
"version": "2.14.0",
"version": "2.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.14.0",
"version": "2.15.0",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.14.0",
"version": "2.15.0",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
+5
View File
@@ -607,6 +607,11 @@ class Database {
this.libraryFilterData[libraryId].publishers.push(publisher)
}
addPublishedDecadeToFilterData(libraryId, decade) {
if (!this.libraryFilterData[libraryId] || !decade || this.libraryFilterData[libraryId].publishedDecades.includes(decade)) return
this.libraryFilterData[libraryId].publishedDecades.push(decade)
}
addLanguageToFilterData(libraryId, language) {
if (!this.libraryFilterData[libraryId] || !language || this.libraryFilterData[libraryId].languages.includes(language)) return
this.libraryFilterData[libraryId].languages.push(language)
+119 -27
View File
@@ -9,7 +9,6 @@ const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilter
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
const seriesFilters = require('../utils/queries/seriesFilters')
const fileUtils = require('../utils/fileUtils')
const { asciiOnlyToLowerCase } = require('../utils/index')
const { createNewSortInstance } = require('../libs/fastSort')
const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
@@ -493,8 +492,8 @@ class LibraryController {
const payload = {
results: [],
total: undefined,
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0,
limit: req.query.limit || 0,
page: req.query.page || 0,
sortBy: req.query.sort,
sortDesc: req.query.desc === '1',
filterBy: req.query.filter,
@@ -504,13 +503,6 @@ class LibraryController {
include: include.join(',')
}
if (!Number.isInteger(payload.limit) || payload.limit < 0) {
return res.status(400).send('Invalid request. Limit must be a positive integer')
}
if (!Number.isInteger(payload.page) || payload.page < 0) {
return res.status(400).send('Invalid request. Page must be a positive integer')
}
payload.offset = payload.page * payload.limit
// TODO: Temporary way of handling collapse sub-series. Either remove feature or handle through sql queries
@@ -602,8 +594,8 @@ class LibraryController {
const payload = {
results: [],
total: 0,
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0,
limit: req.query.limit || 0,
page: req.query.page || 0,
sortBy: req.query.sort,
sortDesc: req.query.desc === '1',
filterBy: req.query.filter,
@@ -674,8 +666,8 @@ class LibraryController {
const payload = {
results: [],
total: 0,
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0,
limit: req.query.limit || 0,
page: req.query.page || 0,
sortBy: req.query.sort,
sortDesc: req.query.desc === '1',
filterBy: req.query.filter,
@@ -710,8 +702,8 @@ class LibraryController {
const payload = {
results: [],
total: playlistsForUser.length,
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0
limit: req.query.limit || 0,
page: req.query.page || 0
}
if (payload.limit) {
@@ -742,7 +734,7 @@ class LibraryController {
* @param {Response} res
*/
async getUserPersonalizedShelves(req, res) {
const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) || 10 : 10
const limitPerShelf = req.query.limit || 10
const include = (req.query.include || '')
.split(',')
.map((v) => v.trim().toLowerCase())
@@ -815,8 +807,8 @@ class LibraryController {
return res.status(400).send('Invalid request. Query param "q" must be a string')
}
const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
const query = asciiOnlyToLowerCase(req.query.q.trim())
const limit = req.query.limit || 12
const query = req.query.q.trim()
const matches = await libraryItemFilters.search(req.user, req.library, query, limit)
res.json(matches)
@@ -873,8 +865,40 @@ class LibraryController {
* @param {Response} res
*/
async getAuthors(req, res) {
const isPaginated = req.query.limit && !isNaN(req.query.limit) && !isNaN(req.query.page)
const payload = {
results: [],
total: 0,
limit: isPaginated ? Number(req.query.limit) : 0,
page: isPaginated ? Number(req.query.page) : 0,
sortBy: req.query.sort,
sortDesc: req.query.desc === '1',
filterBy: req.query.filter,
minified: req.query.minified === '1',
include: req.query.include
}
// create order, limit and offset for pagination
let offset = isPaginated ? payload.page * payload.limit : undefined
let limit = isPaginated ? payload.limit : undefined
let order = undefined
const direction = payload.sortDesc ? 'DESC' : 'ASC'
if (payload.sortBy === 'name') {
order = [[Sequelize.literal('name COLLATE NOCASE'), direction]]
} else if (payload.sortBy === 'lastFirst') {
order = [[Sequelize.literal('lastFirst COLLATE NOCASE'), direction]]
} else if (payload.sortBy === 'addedAt') {
order = [['createdAt', direction]]
} else if (payload.sortBy === 'updatedAt') {
order = [['updatedAt', direction]]
} else if (payload.sortBy === 'numBooks') {
offset = undefined
limit = undefined
}
const { bookWhere, replacements } = libraryItemsBookFilters.getUserPermissionBookWhereQuery(req.user)
const authors = await Database.authorModel.findAll({
const { rows: authors, count } = await Database.authorModel.findAndCountAll({
where: {
libraryId: req.library.id
},
@@ -888,10 +912,13 @@ class LibraryController {
attributes: []
}
},
order: [[Sequelize.literal('name COLLATE NOCASE'), 'ASC']]
order: order,
limit: limit,
offset: offset,
distinct: true
})
const oldAuthors = []
let oldAuthors = []
for (const author of authors) {
const oldAuthor = author.toOldJSONExpanded(author.books.length)
@@ -899,9 +926,25 @@ class LibraryController {
oldAuthors.push(oldAuthor)
}
res.json({
authors: oldAuthors
})
// numBooks sort is handled post-query
if (payload.sortBy === 'numBooks') {
oldAuthors.sort((a, b) => (payload.sortDesc ? b.numBooks - a.numBooks : a.numBooks - b.numBooks))
if (isPaginated) {
const startIndex = payload.page * payload.limit
const endIndex = startIndex + payload.limit
oldAuthors = oldAuthors.slice(startIndex, endIndex)
}
}
payload.results = oldAuthors
if (isPaginated) {
payload.total = count
res.json(payload)
} else {
res.json({
authors: payload.results
})
}
}
/**
@@ -1096,8 +1139,8 @@ class LibraryController {
const payload = {
episodes: [],
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0
limit: req.query.limit || 0,
page: req.query.page || 0
}
const offset = payload.page * payload.limit
@@ -1183,6 +1226,44 @@ class LibraryController {
})
}
/**
* GET: /api/libraries/:id/podcast-titles
*
* Get podcast titles with itunesId and libraryItemId for library
* Used on the podcast add page in order to check if a podcast is already in the library and redirect to it
*
* @param {LibraryControllerRequest} req
* @param {Response} res
*/
async getPodcastTitles(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to get podcast titles`)
return res.sendStatus(403)
}
const podcasts = await Database.podcastModel.findAll({
attributes: ['id', 'title', 'itunesId'],
include: {
model: Database.libraryItemModel,
attributes: ['id', 'libraryId'],
where: {
libraryId: req.library.id
}
}
})
res.json({
podcasts: podcasts.map((p) => {
return {
title: p.title,
itunesId: p.itunesId,
libraryItemId: p.libraryItem.id,
libraryId: p.libraryItem.libraryId
}
})
})
}
/**
*
* @param {RequestWithUser} req
@@ -1200,6 +1281,17 @@ class LibraryController {
return res.status(404).send('Library not found')
}
req.library = library
// Ensure pagination query params are positive integers
for (const queryKey of ['limit', 'page']) {
if (req.query[queryKey] !== undefined) {
req.query[queryKey] = !isNaN(req.query[queryKey]) ? Number(req.query[queryKey]) : 0
if (!Number.isInteger(req.query[queryKey]) || req.query[queryKey] < 0) {
return res.status(400).send(`Invalid request. ${queryKey} must be a positive integer`)
}
}
}
next()
}
}
+1 -1
View File
@@ -324,7 +324,7 @@ class BinaryManager {
defaultRequiredBinaries = [
new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries), // ffmpeg executable
new Binary('ffprobe', 'executable', 'FFPROBE_PATH', ['5.1'], ffbinaries), // ffprobe executable
new Binary('libnusqlite3', 'library', 'NUSQLITE3_PATH', ['1.1'], nunicode, false) // nunicode sqlite3 extension
new Binary('libnusqlite3', 'library', 'NUSQLITE3_PATH', ['1.2'], nunicode, false) // nunicode sqlite3 extension
]
constructor(requiredBinaries = this.defaultRequiredBinaries) {
+1 -2
View File
@@ -38,6 +38,7 @@ class MigrationManager {
if (!(await fs.pathExists(this.configPath))) throw new Error(`Config path does not exist: ${this.configPath}`)
this.migrationsDir = path.join(this.configPath, 'migrations')
await fs.ensureDir(this.migrationsDir)
this.serverVersion = this.extractVersionFromTag(serverVersion)
if (!this.serverVersion) throw new Error(`Invalid server version: ${serverVersion}. Expected a version tag like v1.2.3.`)
@@ -222,8 +223,6 @@ class MigrationManager {
}
async copyMigrationsToConfigDir() {
await fs.ensureDir(this.migrationsDir) // Ensure the target directory exists
if (!(await fs.pathExists(this.migrationsSourceDir))) return
const files = await fs.readdir(this.migrationsSourceDir)
+3 -3
View File
@@ -2,6 +2,6 @@
Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time.
| Server Version | Migration Script Name | Description |
| -------------- | --------------------- | ----------- |
| | | |
| Server Version | Migration Script Name | Description |
| -------------- | ---------------------------- | ------------------------------------------------- |
| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library |
@@ -0,0 +1,206 @@
/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
* @property {import('../Logger')} logger - a Logger object.
*
* @typedef MigrationOptions
* @property {MigrationContext} context - an object containing the migration context.
*/
/**
* This upward migration script cleans any duplicate series in the `Series` table and
* adds a unique index on the `name` and `libraryId` columns.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
// Upwards migration script
logger.info('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique ')
// Check if the unique index already exists
const seriesIndexes = await queryInterface.showIndex('Series')
if (seriesIndexes.some((index) => index.name === 'unique_series_name_per_library')) {
logger.info('[2.15.0 migration] Unique index on Series.name and Series.libraryId already exists')
logger.info('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique ')
return
}
// The steps taken to deduplicate the series are as follows:
// 1. Find all duplicate series in the `Series` table.
// 2. Iterate over the duplicate series and find all book IDs that are associated with the duplicate series in `bookSeries` table.
// 2.a For each book ID, check if the ID occurs multiple times for the duplicate series.
// 2.b If so, keep only one of the rows that has this bookId and seriesId.
// 3. Update `bookSeries` table to point to the most recent series.
// 4. Delete the older series.
// Use the queryInterface to get the series table and find duplicates in the `name` and `libraryId` column
const [duplicates] = await queryInterface.sequelize.query(`
SELECT name, libraryId
FROM Series
GROUP BY name, libraryId
HAVING COUNT(name) > 1
`)
// Print out how many duplicates were found
logger.info(`[2.15.0 migration] Found ${duplicates.length} duplicate series`)
// Iterate over each duplicate series
for (const duplicate of duplicates) {
// Report the series name that is being deleted
logger.info(`[2.15.0 migration] Deduplicating series "${duplicate.name}" in library ${duplicate.libraryId}`)
// Determine any duplicate book IDs in the `bookSeries` table for the same series
const [duplicateBookIds] = await queryInterface.sequelize.query(
`
SELECT bookId
FROM BookSeries
WHERE seriesId IN (
SELECT id
FROM Series
WHERE name = :name AND libraryId = :libraryId
)
GROUP BY bookId
HAVING COUNT(bookId) > 1
`,
{
replacements: {
name: duplicate.name,
libraryId: duplicate.libraryId
}
}
)
// Iterate over the duplicate book IDs if there is at least one and only keep the first row that has this bookId and seriesId
for (const { bookId } of duplicateBookIds) {
logger.info(`[2.15.0 migration] Deduplicating bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`)
// Get all rows of `BookSeries` table that have the same `bookId` and `seriesId`. Sort by `sequence` with nulls sorted last
const [duplicateBookSeries] = await queryInterface.sequelize.query(
`
SELECT id
FROM BookSeries
WHERE bookId = :bookId
AND seriesId IN (
SELECT id
FROM Series
WHERE name = :name AND libraryId = :libraryId
)
ORDER BY sequence NULLS LAST
`,
{
replacements: {
bookId,
name: duplicate.name,
libraryId: duplicate.libraryId
}
}
)
// remove the first element from the array
duplicateBookSeries.shift()
// Delete the remaining duplicate rows
if (duplicateBookSeries.length > 0) {
const [deletedBookSeries] = await queryInterface.sequelize.query(
`
DELETE FROM BookSeries
WHERE id IN (:ids)
`,
{
replacements: {
ids: duplicateBookSeries.map((row) => row.id)
}
}
)
}
logger.info(`[2.15.0 migration] Finished cleanup of bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`)
}
// Get all the most recent series which matches the `name` and `libraryId`
const [mostRecentSeries] = await queryInterface.sequelize.query(
`
SELECT id
FROM Series
WHERE name = :name AND libraryId = :libraryId
ORDER BY updatedAt DESC
LIMIT 1
`,
{
replacements: {
name: duplicate.name,
libraryId: duplicate.libraryId
},
type: queryInterface.sequelize.QueryTypes.SELECT
}
)
if (mostRecentSeries) {
// Update all BookSeries records for this series to point to the most recent series
const [seriesUpdated] = await queryInterface.sequelize.query(
`
UPDATE BookSeries
SET seriesId = :mostRecentSeriesId
WHERE seriesId IN (
SELECT id
FROM Series
WHERE name = :name AND libraryId = :libraryId
AND id != :mostRecentSeriesId
)
`,
{
replacements: {
name: duplicate.name,
libraryId: duplicate.libraryId,
mostRecentSeriesId: mostRecentSeries.id
}
}
)
// Delete the older series
const seriesDeleted = await queryInterface.sequelize.query(
`
DELETE FROM Series
WHERE name = :name AND libraryId = :libraryId
AND id != :mostRecentSeriesId
`,
{
replacements: {
name: duplicate.name,
libraryId: duplicate.libraryId,
mostRecentSeriesId: mostRecentSeries.id
}
}
)
}
}
logger.info(`[2.15.0 migration] Deduplication complete`)
// Create a unique index based on the name and library ID for the `Series` table
await queryInterface.addIndex('Series', ['name', 'libraryId'], {
unique: true,
name: 'unique_series_name_per_library'
})
logger.info('[2.15.0 migration] Added unique index on Series.name and Series.libraryId')
logger.info('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique ')
}
/**
* This removes the unique index on the `Series` table.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
// Downward migration script
logger.info('[2.15.0 migration] DOWNGRADE BEGIN: 2.15.0-series-column-unique ')
// Remove the unique index
await queryInterface.removeIndex('Series', 'unique_series_name_per_library')
logger.info('[2.15.0 migration] Removed unique index on Series.name and Series.libraryId')
logger.info('[2.15.0 migration] DOWNGRADE END: 2.15.0-series-column-unique ')
}
module.exports = { up, down }
+1 -2
View File
@@ -1,6 +1,5 @@
const { DataTypes, Model, where, fn, col } = require('sequelize')
const parseNameString = require('../utils/parsers/parseNameString')
const { asciiOnlyToLowerCase } = require('../utils/index')
class Author extends Model {
constructor(values, options) {
@@ -56,7 +55,7 @@ class Author extends Model {
static async getByNameAndLibrary(authorName, libraryId) {
return this.findOne({
where: [
where(fn('lower', col('name')), asciiOnlyToLowerCase(authorName)),
where(fn('lower', col('name')), authorName.toLowerCase()),
{
libraryId
}
+7 -2
View File
@@ -1,7 +1,6 @@
const { DataTypes, Model, where, fn, col } = require('sequelize')
const { getTitlePrefixAtEnd } = require('../utils/index')
const { asciiOnlyToLowerCase } = require('../utils/index')
class Series extends Model {
constructor(values, options) {
@@ -42,7 +41,7 @@ class Series extends Model {
static async getByNameAndLibrary(seriesName, libraryId) {
return this.findOne({
where: [
where(fn('lower', col('name')), asciiOnlyToLowerCase(seriesName)),
where(fn('lower', col('name')), seriesName.toLowerCase()),
{
libraryId
}
@@ -84,6 +83,12 @@ class Series extends Model {
// collate: 'NOCASE'
// }]
// },
{
// unique constraint on name and libraryId
fields: ['name', 'libraryId'],
unique: true,
name: 'unique_series_name_per_library'
},
{
fields: ['libraryId']
}
+1
View File
@@ -96,6 +96,7 @@ class ApiRouter {
this.router.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this))
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
this.router.post('/libraries/:id/remove-metadata', LibraryController.middleware.bind(this), LibraryController.removeAllMetadataFiles.bind(this))
this.router.get('/libraries/:id/podcast-titles', LibraryController.middleware.bind(this), LibraryController.getPodcastTitles.bind(this))
//
// Item Routes
+4
View File
@@ -590,6 +590,10 @@ class BookScanner {
Database.addPublisherToFilterData(libraryItemData.libraryId, libraryItem.book.publisher)
Database.addLanguageToFilterData(libraryItemData.libraryId, libraryItem.book.language)
const publishedYear = libraryItem.book.publishedYear
const decade = publishedYear ? `${Math.floor(publishedYear / 10) * 10}` : null
Database.addPublishedDecadeToFilterData(libraryItemData.libraryId, decade)
// Load for emitting to client
libraryItem.media = await libraryItem.getMedia({
include: [
-23
View File
@@ -194,29 +194,6 @@ module.exports.getTitlePrefixAtEnd = (title) => {
return prefix ? `${sort}, ${prefix}` : title
}
/**
* to lower case for only ascii characters
* used to handle sqlite that doesnt support unicode lower
* @see https://github.com/advplyr/audiobookshelf/issues/2187
*
* @param {string} str
* @returns {string}
*/
module.exports.asciiOnlyToLowerCase = (str) => {
if (!str) return ''
let temp = ''
for (let chars of str) {
let value = chars.charCodeAt()
if (value >= 65 && value <= 90) {
temp += String.fromCharCode(value + 32)
} else {
temp += chars
}
}
return temp
}
/**
* Escape string used in RegExp
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
+4
View File
@@ -7,6 +7,7 @@ module.exports.notificationData = {
requiresLibrary: true,
libraryMediaType: 'podcast',
description: 'Triggered when a podcast episode is auto-downloaded',
descriptionKey: 'NotificationOnEpisodeDownloadedDescription',
variables: ['libraryItemId', 'libraryId', 'podcastTitle', 'podcastAuthor', 'podcastDescription', 'podcastGenres', 'episodeTitle', 'episodeSubtitle', 'episodeDescription', 'libraryName', 'episodeId', 'mediaTags'],
defaults: {
title: 'New {{podcastTitle}} Episode!',
@@ -31,6 +32,7 @@ module.exports.notificationData = {
name: 'onBackupCompleted',
requiresLibrary: false,
description: 'Triggered when a backup is completed',
descriptionKey: 'NotificationOnBackupCompletedDescription',
variables: ['completionTime', 'backupPath', 'backupSize', 'backupCount', 'removedOldest'],
defaults: {
title: 'Backup Completed',
@@ -48,6 +50,7 @@ module.exports.notificationData = {
name: 'onBackupFailed',
requiresLibrary: false,
description: 'Triggered when a backup fails',
descriptionKey: 'NotificationOnBackupFailedDescription',
variables: ['errorMsg'],
defaults: {
title: 'Backup Failed',
@@ -61,6 +64,7 @@ module.exports.notificationData = {
name: 'onTest',
requiresLibrary: false,
description: 'Event for testing the notification system',
descriptionKey: 'NotificationOnTestDescription',
variables: ['version'],
defaults: {
title: 'Test Notification on Abs {{version}}',
+9 -2
View File
@@ -26,7 +26,7 @@ module.exports = {
let filterValue = null
let filterGroup = null
if (filterBy) {
const searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'publishers', 'missing', 'languages', 'tracks', 'ebooks']
const searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'publishers', 'publishedDecades', 'missing', 'languages', 'tracks', 'ebooks']
const group = searchGroups.find((_group) => filterBy.startsWith(_group + '.'))
filterGroup = group || filterBy
filterValue = group ? this.decode(filterBy.replace(`${group}.`, '')) : null
@@ -458,6 +458,7 @@ module.exports = {
narrators: new Set(),
languages: new Set(),
publishers: new Set(),
publishedDecades: new Set(),
numIssues: 0
}
@@ -492,7 +493,7 @@ module.exports = {
libraryId: libraryId
}
},
attributes: ['tags', 'genres', 'publisher', 'narrators', 'language']
attributes: ['tags', 'genres', 'publisher', 'publishedYear', 'narrators', 'language']
})
for (const book of books) {
if (book.libraryItem.isMissing || book.libraryItem.isInvalid) data.numIssues++
@@ -506,6 +507,11 @@ module.exports = {
book.narrators.forEach((narrator) => data.narrators.add(narrator))
}
if (book.publisher) data.publishers.add(book.publisher)
// Check if published year exists and is valid
if (book.publishedYear && !isNaN(book.publishedYear) && book.publishedYear > 0 && book.publishedYear < 3000 && book.publishedYear.toString().length === 4) {
const decade = Math.floor(book.publishedYear / 10) * 10
data.publishedDecades.add(decade.toString())
}
if (book.language) data.languages.add(book.language)
}
@@ -532,6 +538,7 @@ module.exports = {
data.series = naturalSort(data.series).asc((se) => se.name)
data.narrators = naturalSort([...data.narrators]).asc()
data.publishers = naturalSort([...data.publishers]).asc()
data.publishedDecades = naturalSort([...data.publishedDecades]).asc()
data.languages = naturalSort([...data.languages]).asc()
data.loadedAt = Date.now()
Database.libraryFilterData[libraryId] = data
@@ -219,7 +219,7 @@ module.exports = {
mediaWhere[key] = {
[Sequelize.Op.or]: [null, '']
}
} else if (['genres', 'tags', 'narrators'].includes(value)) {
} else if (['genres', 'tags', 'narrators', 'chapters'].includes(value)) {
mediaWhere[value] = {
[Sequelize.Op.or]: [null, Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col(value)), 0)]
}
@@ -228,6 +228,11 @@ module.exports = {
} else if (value === 'series') {
mediaWhere['$series.id$'] = null
}
} else if (group === 'publishedDecades') {
const year = parseInt(value, 10)
mediaWhere['publishedYear'] = {
[Sequelize.Op.between]: year >= 1000 ? [year, year + 9] : [year * 10, (year + 1) * 10 - 1]
}
}
return { mediaWhere, replacements }
@@ -63,6 +63,8 @@ describe('MigrationManager', () => {
await migrationManager.init(serverVersion)
// Assert
expect(fsEnsureDirStub.calledOnce).to.be.true
expect(fsEnsureDirStub.calledWith(migrationManager.migrationsDir)).to.be.true
expect(migrationManager.serverVersion).to.equal(serverVersion)
expect(migrationManager.sequelize).to.equal(sequelizeStub)
expect(migrationManager.migrationsDir).to.equal(path.join(__dirname, 'migrations'))
@@ -353,8 +355,6 @@ describe('MigrationManager', () => {
await migrationManager.copyMigrationsToConfigDir()
// Assert
expect(fsEnsureDirStub.calledOnce).to.be.true
expect(fsEnsureDirStub.calledWith(targetDir)).to.be.true
expect(readdirStub.calledOnce).to.be.true
expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true
expect(fsCopyStub.calledTwice).to.be.true
@@ -382,8 +382,6 @@ describe('MigrationManager', () => {
} catch (error) {}
// Assert
expect(fsEnsureDirStub.calledOnce).to.be.true
expect(fsEnsureDirStub.calledWith(targetDir)).to.be.true
expect(readdirStub.calledOnce).to.be.true
expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true
expect(fsCopyStub.calledTwice).to.be.true
@@ -0,0 +1,335 @@
const { expect } = require('chai')
const sinon = require('sinon')
const { up, down } = require('../../../server/migrations/v2.15.0-series-column-unique')
const { Sequelize } = require('sequelize')
const Logger = require('../../../server/Logger')
const { query } = require('express')
const { logger } = require('sequelize/lib/utils/logger')
const e = require('express')
describe('migration-v2.15.0-series-column-unique', () => {
let sequelize
let queryInterface
let loggerInfoStub
let series1Id
let series2Id
let series3Id
let series1Id_dup
let series3Id_dup
let series1Id_dup2
let book1Id
let book2Id
let book3Id
let book4Id
let book5Id
let book6Id
let library1Id
let library2Id
let bookSeries1Id
let bookSeries2Id
let bookSeries3Id
let bookSeries1Id_dup
let bookSeries3Id_dup
let bookSeries1Id_dup2
beforeEach(() => {
sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
queryInterface = sequelize.getQueryInterface()
loggerInfoStub = sinon.stub(Logger, 'info')
})
afterEach(() => {
sinon.restore()
})
describe('up', () => {
beforeEach(async () => {
await queryInterface.createTable('Series', {
id: { type: Sequelize.UUID, primaryKey: true },
name: { type: Sequelize.STRING, allowNull: false },
libraryId: { type: Sequelize.UUID, allowNull: false },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false }
})
// Create a table for BookSeries, with a unique constraint of bookId and seriesId
await queryInterface.createTable(
'BookSeries',
{
id: { type: Sequelize.UUID, primaryKey: true },
sequence: { type: Sequelize.STRING, allowNull: true },
bookId: { type: Sequelize.UUID, allowNull: false },
seriesId: { type: Sequelize.UUID, allowNull: false }
},
{ uniqueKeys: { book_series_unique: { fields: ['bookId', 'seriesId'] } } }
)
// Set UUIDs for the tests
series1Id = 'fc086255-3fd2-4a95-8a28-840d9206501b'
series2Id = '70f46ac2-ee48-4b3c-9822-933cc15c29bd'
series3Id = '01cac008-142b-4e15-b0ff-cf7cc2c5b64e'
series1Id_dup = 'ad0b3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b'
series3Id_dup = '4b3b4b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b'
series1Id_dup2 = '0123456a-4b3b-4b3b-4b3b-4b3b4b3b4b3b'
book1Id = '4a38b6e5-0ae4-4de4-b119-4e33891bd63f'
book2Id = '8bc2e61d-47f6-42ef-a3f4-93cf2f1de82f'
book3Id = 'ec9bbaaf-1e55-457f-b59c-bd2bd955a404'
book4Id = '876f3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b'
book5Id = '4e5b4b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b'
book6Id = 'abcda123-4b3b-4b3b-4b3b-4b3b4b3b4b3b'
library1Id = '3a5a1c7c-a914-472e-88b0-b871ceae63e7'
library2Id = 'fd6c324a-4f3a-4bb0-99d6-7a330e765e7e'
bookSeries1Id = 'eca24687-2241-4ffa-a9b3-02a0ba03c763'
bookSeries2Id = '56f56105-813b-4395-9689-fd04198e7d5d'
bookSeries3Id = '404a1761-c710-4d86-9d78-68d9a9c0fb6b'
bookSeries1Id_dup = '8bea3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b'
bookSeries3Id_dup = '89656a3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b'
bookSeries1Id_dup2 = '9bea3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b'
})
afterEach(async () => {
await queryInterface.dropTable('Series')
await queryInterface.dropTable('BookSeries')
})
it('upgrade with no duplicate series', async () => {
// Add some entries to the Series table using the UUID for the ids
await queryInterface.bulkInsert('Series', [
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series2Id, name: 'Series 2', libraryId: library2Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series3Id, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }
])
// Add some entries to the BookSeries table
await queryInterface.bulkInsert('BookSeries', [
{ id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id },
{ id: bookSeries2Id, bookId: book2Id, seriesId: series2Id },
{ id: bookSeries3Id, sequence: '1', bookId: book3Id, seriesId: series3Id }
])
await up({ context: { queryInterface, logger: Logger } })
expect(loggerInfoStub.callCount).to.equal(5)
expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true
expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Found 0 duplicate series'))).to.be.true
expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true
expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true
expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true
// Validate rows in tables
const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT })
expect(series).to.have.length(3)
expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id })
expect(series).to.deep.include({ id: series2Id, name: 'Series 2', libraryId: library2Id })
expect(series).to.deep.include({ id: series3Id, name: 'Series 3', libraryId: library1Id })
const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT })
expect(bookSeries).to.have.length(3)
expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id })
expect(bookSeries).to.deep.include({ id: bookSeries2Id, sequence: null, bookId: book2Id, seriesId: series2Id })
expect(bookSeries).to.deep.include({ id: bookSeries3Id, sequence: '1', bookId: book3Id, seriesId: series3Id })
})
it('upgrade with duplicate series and no sequence', async () => {
// Add some entries to the Series table using the UUID for the ids
await queryInterface.bulkInsert('Series', [
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series2Id, name: 'Series 2', libraryId: library2Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series3Id, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series1Id_dup, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series3Id_dup, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series1Id_dup2, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }
])
// Add some entries to the BookSeries table
await queryInterface.bulkInsert('BookSeries', [
{ id: bookSeries1Id, bookId: book1Id, seriesId: series1Id },
{ id: bookSeries2Id, bookId: book2Id, seriesId: series2Id },
{ id: bookSeries3Id, bookId: book3Id, seriesId: series3Id },
{ id: bookSeries1Id_dup, bookId: book4Id, seriesId: series1Id_dup },
{ id: bookSeries3Id_dup, bookId: book5Id, seriesId: series3Id_dup },
{ id: bookSeries1Id_dup2, bookId: book6Id, seriesId: series1Id_dup2 }
])
await up({ context: { queryInterface, logger: Logger } })
expect(loggerInfoStub.callCount).to.equal(7)
expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true
expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Found 2 duplicate series'))).to.be.true
expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true
expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Deduplicating series "Series 3" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true
expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true
expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true
expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true
// Validate rows
const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT })
expect(series).to.have.length(3)
expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id })
expect(series).to.deep.include({ id: series2Id, name: 'Series 2', libraryId: library2Id })
expect(series).to.deep.include({ id: series3Id, name: 'Series 3', libraryId: library1Id })
const bookSeries = await queryInterface.sequelize.query('SELECT "id", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT })
expect(bookSeries).to.have.length(6)
expect(bookSeries).to.deep.include({ id: bookSeries1Id, bookId: book1Id, seriesId: series1Id })
expect(bookSeries).to.deep.include({ id: bookSeries2Id, bookId: book2Id, seriesId: series2Id })
expect(bookSeries).to.deep.include({ id: bookSeries3Id, bookId: book3Id, seriesId: series3Id })
expect(bookSeries).to.deep.include({ id: bookSeries1Id_dup, bookId: book4Id, seriesId: series1Id })
expect(bookSeries).to.deep.include({ id: bookSeries3Id_dup, bookId: book5Id, seriesId: series3Id })
expect(bookSeries).to.deep.include({ id: bookSeries1Id_dup2, bookId: book6Id, seriesId: series1Id })
})
it('upgrade with same series name in different libraries', async () => {
// Add some entries to the Series table using the UUID for the ids
await queryInterface.bulkInsert('Series', [
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series2Id, name: 'Series 1', libraryId: library2Id, createdAt: new Date(), updatedAt: new Date() }
])
// Add some entries to the BookSeries table
await queryInterface.bulkInsert('BookSeries', [
{ id: bookSeries1Id, bookId: book1Id, seriesId: series1Id },
{ id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }
])
await up({ context: { queryInterface, logger: Logger } })
expect(loggerInfoStub.callCount).to.equal(5)
expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true
expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Found 0 duplicate series'))).to.be.true
expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true
expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true
expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true
// Validate rows
const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT })
expect(series).to.have.length(2)
expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id })
expect(series).to.deep.include({ id: series2Id, name: 'Series 1', libraryId: library2Id })
const bookSeries = await queryInterface.sequelize.query('SELECT "id", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT })
expect(bookSeries).to.have.length(2)
expect(bookSeries).to.deep.include({ id: bookSeries1Id, bookId: book1Id, seriesId: series1Id })
expect(bookSeries).to.deep.include({ id: bookSeries2Id, bookId: book2Id, seriesId: series2Id })
})
it('upgrade with one book in two of the same series, both sequence are null', async () => {
// Create two different series with the same name in the same library
await queryInterface.bulkInsert('Series', [
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }
])
// Create a book that is in both series
await queryInterface.bulkInsert('BookSeries', [
{ id: bookSeries1Id, bookId: book1Id, seriesId: series1Id },
{ id: bookSeries2Id, bookId: book1Id, seriesId: series2Id }
])
await up({ context: { queryInterface, logger: Logger } })
expect(loggerInfoStub.callCount).to.equal(8)
expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true
expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Found 1 duplicate series'))).to.be.true
expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true
expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true
expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true
expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true
expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true
expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true
// validate rows
const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT })
expect(series).to.have.length(1)
expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id })
const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT })
expect(bookSeries).to.have.length(1)
// Keep BookSeries 2 because it was edited last from cleaning up duplicate books
expect(bookSeries).to.deep.include({ id: bookSeries2Id, sequence: null, bookId: book1Id, seriesId: series1Id })
})
it('upgrade with one book in two of the same series, one sequence is null', async () => {
// Create two different series with the same name in the same library
await queryInterface.bulkInsert('Series', [
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }
])
// Create a book that is in both series
await queryInterface.bulkInsert('BookSeries', [
{ id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id },
{ id: bookSeries2Id, bookId: book1Id, seriesId: series2Id }
])
await up({ context: { queryInterface, logger: Logger } })
expect(loggerInfoStub.callCount).to.equal(8)
expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true
expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Found 1 duplicate series'))).to.be.true
expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true
expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true
expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true
expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true
expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true
expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true
// validate rows
const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT })
expect(series).to.have.length(1)
expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id })
const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT })
expect(bookSeries).to.have.length(1)
expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id })
})
it('upgrade with one book in two of the same series, both sequence are not null', async () => {
// Create two different series with the same name in the same library
await queryInterface.bulkInsert('Series', [
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }
])
// Create a book that is in both series
await queryInterface.bulkInsert('BookSeries', [
{ id: bookSeries1Id, sequence: '3', bookId: book1Id, seriesId: series1Id },
{ id: bookSeries2Id, sequence: '2', bookId: book1Id, seriesId: series2Id }
])
await up({ context: { queryInterface, logger: Logger } })
expect(loggerInfoStub.callCount).to.equal(8)
expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true
expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Found 1 duplicate series'))).to.be.true
expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true
expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true
expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true
expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true
expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true
expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true
// validate rows
const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT })
expect(series).to.have.length(1)
expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id })
const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT })
expect(bookSeries).to.have.length(1)
// Keep BookSeries 2 because it is the lower sequence number
expect(bookSeries).to.deep.include({ id: bookSeries2Id, sequence: '2', bookId: book1Id, seriesId: series1Id })
})
})
describe('down', () => {
beforeEach(async () => {
await queryInterface.createTable('Series', {
id: { type: Sequelize.UUID, primaryKey: true },
name: { type: Sequelize.STRING, allowNull: false },
libraryId: { type: Sequelize.UUID, allowNull: false },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false }
})
// Create a table for BookSeries, with a unique constraint of bookId and seriesId
await queryInterface.createTable(
'BookSeries',
{
id: { type: Sequelize.UUID, primaryKey: true },
bookId: { type: Sequelize.UUID, allowNull: false },
seriesId: { type: Sequelize.UUID, allowNull: false }
},
{ uniqueKeys: { book_series_unique: { fields: ['bookId', 'seriesId'] } } }
)
})
it('should not have unique constraint on series name and libraryId', async () => {
await up({ context: { queryInterface, logger: Logger } })
await down({ context: { queryInterface, logger: Logger } })
expect(loggerInfoStub.callCount).to.equal(8)
expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true
expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Found 0 duplicate series'))).to.be.true
expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true
expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true
expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true
expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.15.0 migration] DOWNGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true
expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.15.0 migration] Removed unique index on Series.name and Series.libraryId'))).to.be.true
expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.15.0 migration] DOWNGRADE END: 2.15.0-series-column-unique '))).to.be.true
// Ensure index does not exist
const indexes = await queryInterface.showIndex('Series')
expect(indexes).to.not.deep.include({ tableName: 'Series', unique: true, fields: ['name', 'libraryId'], name: 'unique_series_name_per_library' })
})
})
})