diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2e5f4bced..809563018 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -47,7 +47,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -60,7 +60,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # âšī¸ Command-line programs to run using the OS shell. # đ See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -73,6 +73,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: '/language:${{matrix.language}}' diff --git a/Dockerfile b/Dockerfile index 1ba107fd8..816bdd3c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,9 @@ +ARG NUSQLITE3_DIR="/usr/local/lib/nusqlite3" +ARG NUSQLITE3_PATH="${NUSQLITE3_DIR}/libnusqlite3.so" + ### STAGE 0: Build client ### FROM node:20-alpine AS build-client + WORKDIR /client COPY /client /client RUN npm ci && npm cache clean --force @@ -8,6 +12,9 @@ RUN npm run generate ### STAGE 1: Build server ### FROM node:20-alpine AS build-server +ARG NUSQLITE3_DIR +ARG TARGETPLATFORM + ENV NODE_ENV=production RUN apk add --no-cache --update \ @@ -21,11 +28,6 @@ WORKDIR /server COPY index.js package* /server COPY /server /server/server -ARG TARGETPLATFORM - -ENV NUSQLITE3_DIR="/usr/local/lib/nusqlite3" -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.2/libnusqlite3-linux-musl-x64.zip" ;; \ @@ -41,6 +43,9 @@ RUN npm ci --only=production ### STAGE 2: Create minimal runtime image ### FROM node:20-alpine +ARG NUSQLITE3_DIR +ARG NUSQLITE3_PATH + # Install only runtime dependencies RUN apk add --no-cache --update \ tzdata \ @@ -52,13 +57,17 @@ WORKDIR /app # Copy compiled frontend and server from build stages COPY --from=build-client /client/dist /app/client/dist COPY --from=build-server /server /app +COPY --from=build-server ${NUSQLITE3_PATH} ${NUSQLITE3_PATH} EXPOSE 80 ENV PORT=80 +ENV NODE_ENV=production ENV CONFIG_PATH="/config" ENV METADATA_PATH="/metadata" ENV SOURCE="docker" +ENV NUSQLITE3_DIR=${NUSQLITE3_DIR} +ENV NUSQLITE3_PATH=${NUSQLITE3_PATH} ENTRYPOINT ["tini", "--"] CMD ["node", "index.js"] diff --git a/build/debian/DEBIAN/preinst b/build/debian/DEBIAN/preinst index e30bc490c..241a47010 100644 --- a/build/debian/DEBIAN/preinst +++ b/build/debian/DEBIAN/preinst @@ -22,7 +22,7 @@ add_user() { declare -r descr="${4:-No description}" declare -r shell="${5:-/bin/false}" - if ! getent passwd | grep -q "^$user:"; then + if ! getent passwd "$user" 2>&1 >/dev/null; then echo "Creating system user: $user in $group with $descr and shell $shell" useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user fi @@ -39,7 +39,7 @@ add_group() { declare -r gid_flags="--gid $gid" fi - if ! getent group | grep -q "^$group:" ; then + if ! getent group "$group" 2>&1 >/dev/null; then echo "Creating system group: $group" groupadd $gid_flags --system $group fi diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index f74134041..428ae6ebf 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -196,6 +196,7 @@ export default { requestBatchQuickEmbed() { const payload = { message: this.$strings.MessageConfirmQuickEmbed, + allowHtml: true, callback: (confirmed) => { if (confirmed) { this.$axios diff --git a/client/components/app/BookShelfCategorized.vue b/client/components/app/BookShelfCategorized.vue index 8c6804625..a8e8ae469 100644 --- a/client/components/app/BookShelfCategorized.vue +++ b/client/components/app/BookShelfCategorized.vue @@ -217,6 +217,16 @@ export default { }) } + if (this.results.episodes?.length) { + shelves.push({ + id: 'episodes', + label: 'Episodes', + labelStringKey: 'LabelEpisodes', + type: 'episode', + entities: this.results.episodes.map((res) => res.libraryItem) + }) + } + if (this.results.series?.length) { shelves.push({ id: 'series', @@ -290,6 +300,8 @@ export default { }) }, userUpdated(user) { + if (user.id !== this.$store.state.user.user.id) return + if (user.seriesHideFromContinueListening && user.seriesHideFromContinueListening.length) { this.removeAllSeriesFromContinueSeries(user.seriesHideFromContinueListening) } diff --git a/client/components/app/BookShelfRow.vue b/client/components/app/BookShelfRow.vue index 082f9fe37..fac89a70b 100644 --- a/client/components/app/BookShelfRow.vue +++ b/client/components/app/BookShelfRow.vue @@ -93,10 +93,10 @@ export default { editAuthor(author) { this.$store.commit('globals/showEditAuthorModal', author) }, - editItem(libraryItem) { + editItem(libraryItem, tab = 'details') { var itemIds = this.shelf.entities.map((e) => e.id) this.$store.commit('setBookshelfBookIds', itemIds) - this.$store.commit('showEditModal', libraryItem) + this.$store.commit('showEditModalOnTab', { libraryItem, tab: tab || 'details' }) }, editEpisode({ libraryItem, episode }) { this.$store.commit('setEpisodeTableEpisodeIds', [episode.id]) diff --git a/client/components/app/BookShelfToolbar.vue b/client/components/app/BookShelfToolbar.vue index 95e7c378c..b7ecff624 100644 --- a/client/components/app/BookShelfToolbar.vue +++ b/client/components/app/BookShelfToolbar.vue @@ -3,24 +3,18 @@
{{ $strings.ButtonHome }}
- + home{{ $strings.ButtonLibrary }}
- + import_contacts{{ $strings.ButtonLatest }}
{{ $strings.ButtonSeries }}
- + view_column{{ $strings.ButtonPlaylists }}
@@ -32,12 +26,7 @@{{ $strings.ButtonAuthors }}
- + groups{{ $strings.ButtonAdd }}
diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue index 50fa7a06f..32e7e694a 100644 --- a/client/components/app/ConfigSideNav.vue +++ b/client/components/app/ConfigSideNav.vue @@ -70,6 +70,11 @@ export default { title: this.$strings.HeaderUsers, path: '/config/users' }, + { + id: 'config-api-keys', + title: this.$strings.HeaderApiKeys, + path: '/config/api-keys' + }, { id: 'config-sessions', title: this.$strings.HeaderListeningSessions, diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index 61331fb9e..4c72d0d78 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -232,11 +232,11 @@ export default { clearFilter() { this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' }) }, - editEntity(entity) { + editEntity(entity, tab = 'details') { if (this.entityName === 'items' || this.entityName === 'series-books') { const bookIds = this.entities.map((e) => e.id) this.$store.commit('setBookshelfBookIds', bookIds) - this.$store.commit('showEditModal', entity) + this.$store.commit('showEditModalOnTab', { libraryItem: entity, tab: tab || 'details' }) } else if (this.entityName === 'collections') { this.$store.commit('globals/setEditCollection', entity) } else if (this.entityName === 'playlists') { @@ -778,10 +778,6 @@ export default { windowResize() { this.executeRebuild() }, - socketInit() { - // Server settings are set on socket init - this.executeRebuild() - }, initListeners() { window.addEventListener('resize', this.windowResize) @@ -794,7 +790,6 @@ export default { }) this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities) - this.$eventBus.$on('socket_init', this.socketInit) this.$eventBus.$on('user-settings', this.settingsUpdated) if (this.$root.socket) { @@ -826,7 +821,6 @@ export default { } this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities) - this.$eventBus.$off('socket_init', this.socketInit) this.$eventBus.$off('user-settings', this.settingsUpdated) if (this.$root.socket) { diff --git a/client/components/app/SideRail.vue b/client/components/app/SideRail.vue index 2b05ef360..9fa7661a1 100644 --- a/client/components/app/SideRail.vue +++ b/client/components/app/SideRail.vue @@ -5,9 +5,7 @@ diff --git a/client/components/cards/AuthorCard.vue b/client/components/cards/AuthorCard.vue index 82645c570..053473934 100644 --- a/client/components/cards/AuthorCard.vue +++ b/client/components/cards/AuthorCard.vue @@ -71,9 +71,6 @@ export default { coverHeight() { return this.cardHeight }, - userToken() { - return this.store.getters['user/getToken'] - }, _author() { return this.author || {} }, diff --git a/client/components/cards/BookMatchCard.vue b/client/components/cards/BookMatchCard.vue index 87aa0a711..09b963c50 100644 --- a/client/components/cards/BookMatchCard.vue +++ b/client/components/cards/BookMatchCard.vue @@ -13,9 +13,17 @@{{ book.publishedYear }}
{{ $getString('LabelByAuthor', [book.author]) }}
-{{ $strings.LabelNarrators }}: {{ book.narrator }}
-{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}
+ +{{ $getString('LabelByAuthor', [book.author]) }}
+{{ $strings.LabelNarrators }}: {{ book.narrator }}
+{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}
+
diff --git a/client/components/cards/EpisodeSearchCard.vue b/client/components/cards/EpisodeSearchCard.vue
index e69de29bb..8be6a3a3b 100644
--- a/client/components/cards/EpisodeSearchCard.vue
+++ b/client/components/cards/EpisodeSearchCard.vue
@@ -0,0 +1,60 @@
+
+ {{ episodeTitle }} {{ podcastTitle }}
- Episode #{{ recentEpisodeNumber }} + Episode + #{{ recentEpisodeNumber }}
{{ displayTitle }}
{{ displaySubtitle }}
{{ displayLineTwo || ' ' }}
@@ -198,7 +199,10 @@ export default { return this.store.getters['user/getSizeMultiplier'] }, dateFormat() { - return this.store.state.serverSettings.dateFormat + return this.store.getters['getServerSetting']('dateFormat') + }, + timeFormat() { + return this.store.getters['getServerSetting']('timeFormat') }, _libraryItem() { return this.libraryItem || {} @@ -345,6 +349,18 @@ export default { if (this.mediaMetadata.publishedYear) return this.$getString('LabelPublishedDate', [this.mediaMetadata.publishedYear]) return '\u00A0' } + if (this.orderBy === 'progress') { + if (!this.userProgressLastUpdated) return '\u00A0' + return this.$getString('LabelLastProgressDate', [this.$formatDatetime(this.userProgressLastUpdated, this.dateFormat, this.timeFormat)]) + } + if (this.orderBy === 'progress.createdAt') { + if (!this.userProgressStartedDate) return '\u00A0' + return this.$getString('LabelStartedDate', [this.$formatDatetime(this.userProgressStartedDate, this.dateFormat, this.timeFormat)]) + } + if (this.orderBy === 'progress.finishedAt') { + if (!this.userProgressFinishedDate) return '\u00A0' + return this.$getString('LabelFinishedDate', [this.$formatDatetime(this.userProgressFinishedDate, this.dateFormat, this.timeFormat)]) + } return null }, episodeProgress() { @@ -377,6 +393,18 @@ export default { let progressPercent = this.itemIsFinished ? 1 : this.booksInSeries ? this.seriesProgressPercent : this.useEBookProgress ? this.userProgress?.ebookProgress || 0 : this.userProgress?.progress || 0 return Math.max(Math.min(1, progressPercent), 0) }, + userProgressLastUpdated() { + if (!this.userProgress) return null + return this.userProgress.lastUpdate + }, + userProgressStartedDate() { + if (!this.userProgress) return null + return this.userProgress.startedAt + }, + userProgressFinishedDate() { + if (!this.userProgress) return null + return this.userProgress.finishedAt + }, itemIsFinished() { if (this.booksInSeries) return this.seriesIsFinished return this.userProgress ? !!this.userProgress.isFinished : false @@ -760,11 +788,11 @@ export default { }, showEditModalFiles() { // More menu func - this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'files' }) + this.$emit('edit', this.libraryItem, 'files') }, showEditModalMatch() { // More menu func - this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' }) + this.$emit('edit', this.libraryItem, 'match') }, sendToDevice(deviceName) { // More menu func diff --git a/client/components/cards/LazySeriesCard.vue b/client/components/cards/LazySeriesCard.vue index 3532095b9..34cea7e22 100644 --- a/client/components/cards/LazySeriesCard.vue +++ b/client/components/cards/LazySeriesCard.vue @@ -71,7 +71,7 @@ export default { return this.height * this.sizeMultiplier }, dateFormat() { - return this.store.state.serverSettings.dateFormat + return this.store.getters['getServerSetting']('dateFormat') }, labelFontSize() { if (this.width < 160) return 0.75 diff --git a/client/components/controls/GlobalSearch.vue b/client/components/controls/GlobalSearch.vue index bc9a23681..6f3a819bf 100644 --- a/client/components/controls/GlobalSearch.vue +++ b/client/components/controls/GlobalSearch.vue @@ -39,6 +39,15 @@ +{{ $strings.LabelEpisodes }}
+ +{{ $strings.LabelAuthors }}
{{ title }}
+{{ title }}
+{{ $strings.LabelUser }}
-{{ _session.userId }}
+{{ username }}
{{ $strings.LabelMediaPlayer }}
{{ playMethodName }}
@@ -132,6 +132,9 @@ export default { _session() { return this.session || {} }, + username() { + return this._session.user?.username || this._session.userId || '' + }, deviceInfo() { return this._session.deviceInfo || {} }, @@ -159,10 +162,10 @@ export default { return 'Unknown' }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') }, isOpenSession() { return !!this._session.open diff --git a/client/components/modals/Modal.vue b/client/components/modals/Modal.vue index a7d9c0ae2..31ea1e610 100644 --- a/client/components/modals/Modal.vue +++ b/client/components/modals/Modal.vue @@ -23,7 +23,7 @@ export default { processing: Boolean, persistent: { type: Boolean, - default: true + default: false }, width: { type: [String, Number], @@ -99,7 +99,7 @@ export default { this.preventClickoutside = false return } - if (this.processing && this.persistent) return + if (this.processing || this.persistent) return if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) { this.show = false } diff --git a/client/components/modals/ShareModal.vue b/client/components/modals/ShareModal.vue index 24994b223..bd0c9acfb 100644 --- a/client/components/modals/ShareModal.vue +++ b/client/components/modals/ShareModal.vue @@ -144,7 +144,7 @@ export default { expirationDateString() { if (!this.expireDurationSeconds) return this.$strings.LabelPermanent const dateMs = Date.now() + this.expireDurationSeconds * 1000 - return this.$formatDatetime(dateMs, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat) + return this.$formatDatetime(dateMs, this.$store.getters['getServerSetting']('dateFormat'), this.$store.getters['getServerSetting']('timeFormat')) } }, methods: { diff --git a/client/components/modals/changelog/ViewModal.vue b/client/components/modals/changelog/ViewModal.vue index 1b332a1d5..939ee71d2 100644 --- a/client/components/modals/changelog/ViewModal.vue +++ b/client/components/modals/changelog/ViewModal.vue @@ -40,7 +40,7 @@ export default { } }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, releasesToShow() { return this.versionData?.releasesToShow || [] diff --git a/client/components/modals/collections/AddCreateModal.vue b/client/components/modals/collections/AddCreateModal.vue index 24e8695d6..f0d43c14f 100644 --- a/client/components/modals/collections/AddCreateModal.vue +++ b/client/components/modals/collections/AddCreateModal.vue @@ -227,7 +227,7 @@ export default { .catch((error) => { console.error('Failed to create collection', error) var errMsg = error.response ? error.response.data || '' : '' - this.$toast.error(this.$strings.ToastCollectionCreateFailed + ': ' + errMsg) + this.$toast.error(errMsg) this.processing = false }) } diff --git a/client/components/modals/item/tabs/Cover.vue b/client/components/modals/item/tabs/Cover.vue index 17979f708..be17f9636 100644 --- a/client/components/modals/item/tabs/Cover.vue +++ b/client/components/modals/item/tabs/Cover.vue @@ -51,19 +51,21 @@{{ $strings.MessageNoCoversFound }}
+{{ $strings.MessageLoading }}
+{{ $strings.MessageNoCoversFound }}
- {{ $strings.LabelCurrently }} {{ mediaMetadata.publishedYear }} + {{ $strings.LabelCurrently }} {{ mediaMetadata.publishedYear }}
{{ episode.subtitle }}
-Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}
+Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}
+ +{{ $strings.LabelDuration }}: {{ $elapsedPretty(episode.durationSeconds) }}
+ +{{ $strings.LabelSize }}: {{ $bytesPretty(Number(episode.enclosure.length)) }}
+{{ $getString('MessageConfirmRemoveEpisodes', [episodes.length]) }}
-Note: This does not delete the audio file unless toggling "Hard delete file"
+{{ $strings.MessageConfirmRemoveEpisodeNote }}
{{ title }}
- +{{ $strings.MessageNoDescription }}
@@ -34,6 +34,12 @@ {{ audioFileSize }} +{{ $strings.LabelDuration }}
++ {{ audioFileDuration }} +
+{{ message }}
')
+ .replace(/<\/code>/gi, '')
+ },
callback() {
return this.confirmPromptOptions.callback
},
@@ -103,6 +115,14 @@ export default {
if (this.callback) this.callback(true, this.checkboxValue)
this.show = false
},
+ escapeHtml(value) {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ },
setShow() {
this.checkboxValue = this.checkboxDefaultValue
this.$eventBus.$emit('showing-prompt', true)
diff --git a/client/components/readers/ComicReader.vue b/client/components/readers/ComicReader.vue
index 28d79bf2a..fce269396 100644
--- a/client/components/readers/ComicReader.vue
+++ b/client/components/readers/ComicReader.vue
@@ -104,9 +104,6 @@ export default {
}
},
computed: {
- userToken() {
- return this.$store.getters['user/getToken']
- },
libraryItemId() {
return this.libraryItem?.id
},
@@ -234,10 +231,7 @@ export default {
async extract() {
this.loading = true
var buff = await this.$axios.$get(this.ebookUrl, {
- responseType: 'blob',
- headers: {
- Authorization: `Bearer ${this.userToken}`
- }
+ responseType: 'blob'
})
const archive = await Archive.open(buff)
const originalFilesObject = await archive.getFilesObject()
diff --git a/client/components/readers/EpubReader.vue b/client/components/readers/EpubReader.vue
index 350d8596c..a4c51539f 100644
--- a/client/components/readers/EpubReader.vue
+++ b/client/components/readers/EpubReader.vue
@@ -57,9 +57,6 @@ export default {
}
},
computed: {
- userToken() {
- return this.$store.getters['user/getToken']
- },
/** @returns {string} */
libraryItemId() {
return this.libraryItem?.id
@@ -97,27 +94,37 @@ export default {
},
ebookUrl() {
if (this.fileId) {
- return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook/${this.fileId}`
+ return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
- return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook`
+ return `/api/items/${this.libraryItemId}/ebook`
},
themeRules() {
- const isDark = this.ereaderSettings.theme === 'dark'
- const fontColor = isDark ? '#fff' : '#000'
- const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)'
+ const theme = this.ereaderSettings.theme
+ const isDark = theme === 'dark'
+ const isSepia = theme === 'sepia'
+
+ const fontColor = isDark
+ ? '#fff'
+ : isSepia
+ ? '#5b4636'
+ : '#000'
+
+ const backgroundColor = isDark
+ ? 'rgb(35 35 35)'
+ : isSepia
+ ? 'rgb(244, 236, 216)'
+ : 'rgb(255, 255, 255)'
const lineSpacing = this.ereaderSettings.lineSpacing / 100
-
- const fontScale = this.ereaderSettings.fontScale / 100
-
- const textStroke = this.ereaderSettings.textStroke / 100
+ const fontScale = this.ereaderSettings.fontScale / 100
+ const textStroke = this.ereaderSettings.textStroke / 100
return {
'*': {
color: `${fontColor}!important`,
'background-color': `${backgroundColor}!important`,
- 'line-height': lineSpacing * fontScale + 'rem!important',
- '-webkit-text-stroke': textStroke + 'px ' + fontColor + '!important'
+ 'line-height': `${lineSpacing * fontScale}rem!important`,
+ '-webkit-text-stroke': `${textStroke}px ${fontColor}!important`
},
a: {
color: `${fontColor}!important`
@@ -309,14 +316,24 @@ export default {
/** @type {EpubReader} */
const reader = this
+ // Use axios to make request because we have token refresh logic in interceptor
+ const customRequest = async (url) => {
+ try {
+ return this.$axios.$get(url, {
+ responseType: 'arraybuffer'
+ })
+ } catch (error) {
+ console.error('EpubReader.initEpub customRequest failed:', error)
+ throw error
+ }
+ }
+
/** @type {ePub.Book} */
reader.book = new ePub(reader.ebookUrl, {
width: this.readerWidth,
height: this.readerHeight - 50,
openAs: 'epub',
- requestHeaders: {
- Authorization: `Bearer ${this.userToken}`
- }
+ requestMethod: customRequest
})
/** @type {ePub.Rendition} */
@@ -337,29 +354,33 @@ export default {
this.applyTheme()
})
- reader.book.ready.then(() => {
- // set up event listeners
- reader.rendition.on('relocated', reader.relocated)
- reader.rendition.on('keydown', reader.keyUp)
+ reader.book.ready
+ .then(() => {
+ // set up event listeners
+ reader.rendition.on('relocated', reader.relocated)
+ reader.rendition.on('keydown', reader.keyUp)
- reader.rendition.on('touchstart', (event) => {
- this.$emit('touchstart', event)
- })
- reader.rendition.on('touchend', (event) => {
- this.$emit('touchend', event)
- })
-
- // load ebook cfi locations
- const savedLocations = this.loadLocations()
- if (savedLocations) {
- reader.book.locations.load(savedLocations)
- } else {
- reader.book.locations.generate().then(() => {
- this.checkSaveLocations(reader.book.locations.save())
+ reader.rendition.on('touchstart', (event) => {
+ this.$emit('touchstart', event)
})
- }
- this.getChapters()
- })
+ reader.rendition.on('touchend', (event) => {
+ this.$emit('touchend', event)
+ })
+
+ // load ebook cfi locations
+ const savedLocations = this.loadLocations()
+ if (savedLocations) {
+ reader.book.locations.load(savedLocations)
+ } else {
+ reader.book.locations.generate().then(() => {
+ this.checkSaveLocations(reader.book.locations.save())
+ })
+ }
+ this.getChapters()
+ })
+ .catch((error) => {
+ console.error('EpubReader.initEpub failed:', error)
+ })
},
getChapters() {
// Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759
diff --git a/client/components/readers/MobiReader.vue b/client/components/readers/MobiReader.vue
index 3e784f775..459ae55bf 100644
--- a/client/components/readers/MobiReader.vue
+++ b/client/components/readers/MobiReader.vue
@@ -26,9 +26,6 @@ export default {
return {}
},
computed: {
- userToken() {
- return this.$store.getters['user/getToken']
- },
libraryItemId() {
return this.libraryItem?.id
},
@@ -96,11 +93,8 @@ export default {
},
async initMobi() {
// Fetch mobi file as blob
- var buff = await this.$axios.$get(this.ebookUrl, {
- responseType: 'blob',
- headers: {
- Authorization: `Bearer ${this.userToken}`
- }
+ const buff = await this.$axios.$get(this.ebookUrl, {
+ responseType: 'blob'
})
var reader = new FileReader()
reader.onload = async (event) => {
diff --git a/client/components/readers/PdfReader.vue b/client/components/readers/PdfReader.vue
index c05f459c7..d9459d768 100644
--- a/client/components/readers/PdfReader.vue
+++ b/client/components/readers/PdfReader.vue
@@ -55,7 +55,8 @@ export default {
loadedRatio: 0,
page: 1,
numPages: 0,
- pdfDocInitParams: null
+ pdfDocInitParams: null,
+ isRefreshing: false
}
},
computed: {
@@ -152,7 +153,34 @@ export default {
this.page++
this.updateProgress()
},
- error(err) {
+ async refreshToken() {
+ if (this.isRefreshing) return
+ this.isRefreshing = true
+ const newAccessToken = await this.$store.dispatch('user/refreshToken').catch((error) => {
+ console.error('Failed to refresh token', error)
+ return null
+ })
+ if (!newAccessToken) {
+ // Redirect to login on failed refresh
+ this.$router.push('/login')
+ return
+ }
+
+ // Force Vue to re-render the PDF component by creating a new object
+ this.pdfDocInitParams = {
+ url: this.ebookUrl,
+ httpHeaders: {
+ Authorization: `Bearer ${newAccessToken}`
+ }
+ }
+ this.isRefreshing = false
+ },
+ async error(err) {
+ if (err && err.status === 401) {
+ console.log('Received 401 error, refreshing token')
+ await this.refreshToken()
+ return
+ }
console.error(err)
},
resize() {
diff --git a/client/components/readers/Reader.vue b/client/components/readers/Reader.vue
index c2e5986ec..9063479d1 100644
--- a/client/components/readers/Reader.vue
+++ b/client/components/readers/Reader.vue
@@ -1,5 +1,5 @@
- {{ $strings.HeaderTableOfContents }}
{{ $formatNumber(totalItems) }}
{{ $strings.LabelStatsItemsInLibrary }}
@@ -19,9 +17,7 @@{{ $formatNumber(totalAuthors) }}
{{ $strings.LabelStatsAuthors }}
diff --git a/client/components/tables/ApiKeysTable.vue b/client/components/tables/ApiKeysTable.vue new file mode 100644 index 000000000..feab4e681 --- /dev/null +++ b/client/components/tables/ApiKeysTable.vue @@ -0,0 +1,177 @@ + +| {{ $strings.LabelName }} | +{{ $strings.LabelApiKeyUser }} | +{{ $strings.LabelExpiresAt }} | +{{ $strings.LabelCreatedAt }} | ++ |
|---|---|---|---|---|
|
+
+
+ {{ apiKey.name }} + |
+
+ Error + |
+
+ {{ getExpiresAtText(apiKey) }} +{{ $strings.LabelExpiresNever }} + |
+
+ |
+
+
+
+
+
+
+
+ |
+
{{ $strings.LabelNoApiKeys }}
+{{ $strings.HeaderChapters }}
{{ $strings.HeaderAudioTracks }}
@@ -110,23 +160,19 @@{{ track.metadata.filename }}
-{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}
-{{ track.metadata.filename }}
{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}
+
- {{ $strings.LabelDurationFound }} {{ $secondsToTimestamp(chapterData.runtimeLengthSec) }}
+ {{ $strings.LabelDurationFound }} {{ $secondsToTimestamp(chapterData.runtimeLengthSec) }}
+
{{ chapterData.chapters.length }} {{ $strings.LabelChaptersFound }}
{{ $strings.LabelYourAudiobookDuration }}: {{ $secondsToTimestamp(mediaDurationRounded) }}
@@ -198,17 +249,49 @@
{{ $strings.MessageChapterStartIsAfter }}
{{ $strings.HeaderBulkChapterModal }}
+{{ $strings.MessageBulkChapterPattern }}
+ +{{ $strings.MessageNoChapters }}
{{ $strings.LabelExample }}: {{ dateExample }}
{{ $strings.LabelExample }}: {{ timeExample }}
/metadata/cache/items.|
-
-
- {{ $getString('MessageSelected', [numSelected]) }} - - |
-
-
- {{ $strings.LabelItem }} {{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}
-
- |
-
-
-
-
-
- {{ $strings.LabelTimeListened }}
-
- |
-
-
- {{ $strings.LabelLastTime }}
-
- |
-
-
|---|
|
+
+
+ {{ $getString('MessageSelected', [numSelected]) }} + + |
+
+
+ {{ $strings.LabelItem }} {{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}
+
+ |
+
+
+
+
+
+ {{ $strings.LabelTimeListened }}
+
+ |
+
+
+ {{ $strings.LabelLastTime }}
+
+ |
+
+ |||
|---|---|---|---|---|---|---|
|
- |
-
- {{ session.displayTitle }} -{{ session.displayAuthor }} - |
-
- {{ filteredUserUsername }} -{{ session.user ? session.user.username : 'N/A' }} - |
-
- {{ getPlayMethodName(session.playMethod) }} - |
- - - | -
- {{ $elapsedPretty(session.timeListening) }} - |
-
- {{ $secondsToTimestamp(session.currentTime) }} - |
-
-
{{ session.displayTitle }}
+{{ session.displayAuthor }}
+{{ filteredUserUsername }}
+{{ session.user ? session.user.username : 'N/A' }}
+{{ getPlayMethodName(session.playMethod) }}
+
+
+
{{ line }}
+
+
{{ $elapsedPrettyLocalized(session.timeListening) }}
+{{ $secondsToTimestamp(session.currentTime) }}
+{{ getPlayMethodName(session.playMethod) }}
+
+
{{ line }}
+
+
{{ $elapsedPretty(session.timeListening) }}
@@ -170,7 +180,11 @@{{ getPlayMethodName(session.playMethod) }}
+
+
{{ line }}
+
+
{{ $secondsToTimestamp(session.currentTime) }}
@@ -250,10 +264,10 @@ export default { return user?.username || null }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') }, numSelected() { return this.listeningSessions.filter((s) => s.selected).length @@ -431,16 +445,16 @@ export default { this.selectedSession = session this.showSessionModal = true }, - getDeviceInfoString(deviceInfo) { - if (!deviceInfo) return '' - var lines = [] + getDeviceInfoLines(deviceInfo) { + if (!deviceInfo) return [] + const lines = [] if (deviceInfo.clientName) lines.push(`${deviceInfo.clientName} ${deviceInfo.clientVersion || ''}`) if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`) if (deviceInfo.browserName) lines.push(deviceInfo.browserName) if (deviceInfo.manufacturer && deviceInfo.model) lines.push(`${deviceInfo.manufacturer} ${deviceInfo.model}`) if (deviceInfo.sdkVersion) lines.push(`SDK Version: ${deviceInfo.sdkVersion}`) - return lines.join('| {{ $strings.LabelItem }} | - - -{{ $strings.LabelTimeListened }} | -{{ $strings.LabelLastTime }} | - -||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
- {{ session.displayTitle }} -{{ session.displayAuthor }} +
+
+ |
+ {{ $elapsedPrettyLocalized(session.timeListening) }} +
+ |
+
+ {{ $secondsToTimestamp(session.currentTime) }} + |
{{ $getString('LabelPaginationPageXOfY', [currentPage + 1, numPages]) }}
@@ -98,10 +104,10 @@ export default { return this.$store.getters['users/getIsUserOnline'](this.user.id) }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') } }, methods: { @@ -191,16 +197,16 @@ export default { this.selectedSession = session this.showSessionModal = true }, - getDeviceInfoString(deviceInfo) { - if (!deviceInfo) return '' - var lines = [] + getDeviceInfoLines(deviceInfo) { + if (!deviceInfo) return [] + const lines = [] if (deviceInfo.clientName) lines.push(`${deviceInfo.clientName} ${deviceInfo.clientVersion || ''}`) if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`) if (deviceInfo.browserName) lines.push(deviceInfo.browserName) if (deviceInfo.manufacturer && deviceInfo.model) lines.push(`${deviceInfo.manufacturer} ${deviceInfo.model}`) if (deviceInfo.sdkVersion) lines.push(`SDK Version: ${deviceInfo.sdkVersion}`) - return lines.join('{{ narrator.name }}
+{{ error }}
+{{ $strings.MessageAuthenticationSecurityMessage }}
+ {{ $strings.LabelMoreInfo }} +