diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index 382f5cf3..d625dac7 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -287,26 +287,37 @@ export default { }) }, batchDeleteClick() { - const audiobookText = this.numMediaItemsSelected > 1 ? `these ${this.numMediaItemsSelected} items` : 'this item' - const confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf` - if (confirm(confirmMsg)) { - this.$store.commit('setProcessingBatch', true) - this.$axios - .$post(`/api/items/batch/delete`, { - libraryItemIds: this.selectedMediaItems.map((i) => i.id) - }) - .then(() => { - this.$toast.success('Batch delete success!') - this.$store.commit('setProcessingBatch', false) - this.$store.commit('globals/resetSelectedMediaItems', []) - this.$eventBus.$emit('bookshelf_clear_selection') - }) - .catch((error) => { - this.$toast.error('Batch delete failed') - console.error('Failed to batch delete', error) - this.$store.commit('setProcessingBatch', false) - }) + const payload = { + message: `This will delete ${this.numMediaItemsSelected} library items from the database and your file system. Are you sure?`, + checkboxLabel: 'Delete from file system. Uncheck to only remove from database.', + yesButtonText: this.$strings.ButtonDelete, + yesButtonColor: 'error', + checkboxDefaultValue: true, + callback: (confirmed, hardDelete) => { + if (confirmed) { + this.$store.commit('setProcessingBatch', true) + + this.$axios + .$post(`/api/items/batch/delete?hard=${hardDelete ? 1 : 0}`, { + libraryItemIds: this.selectedMediaItems.map((i) => i.id) + }) + .then(() => { + this.$toast.success('Batch delete success') + this.$store.commit('globals/resetSelectedMediaItems', []) + this.$eventBus.$emit('bookshelf_clear_selection') + }) + .catch((error) => { + console.error('Batch delete failed', error) + this.$toast.error('Batch delete failed') + }) + .finally(() => { + this.$store.commit('setProcessingBatch', false) + }) + } + }, + type: 'yesNo' } + this.$store.commit('globals/setConfirmPrompt', payload) }, batchEditClick() { this.$router.push('/batch') diff --git a/client/components/app/BookShelfToolbar.vue b/client/components/app/BookShelfToolbar.vue index d512bc1b..57599877 100644 --- a/client/components/app/BookShelfToolbar.vue +++ b/client/components/app/BookShelfToolbar.vue @@ -163,6 +163,14 @@ export default { text: this.$strings.LabelAddedAt, value: 'addedAt' }, + { + text: this.$strings.LabelLastBookAdded, + value: 'lastBookAdded' + }, + { + text: this.$strings.LabelLastBookUpdated, + value: 'lastBookUpdated' + }, { text: this.$strings.LabelTotalDuration, value: 'totalDuration' @@ -181,6 +189,9 @@ export default { currentLibraryId() { return this.$store.state.libraries.currentLibraryId }, + libraryProvider() { + return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google' + }, currentLibraryMediaType() { return this.$store.getters['libraries/getCurrentLibraryMediaType'] }, @@ -315,7 +326,11 @@ export default { const payload = {} if (author.asin) payload.asin = author.asin else payload.q = author.name - console.log('Payload', payload, 'author', author) + + payload.region = 'us' + if (this.libraryProvider.startsWith('audible.')) { + payload.region = this.libraryProvider.split('.').pop() || 'us' + } this.$eventBus.$emit(`searching-author-${author.id}`, true) diff --git a/client/components/app/StreamContainer.vue b/client/components/app/StreamContainer.vue index 470106ba..cd2bd1cf 100644 --- a/client/components/app/StreamContainer.vue +++ b/client/components/app/StreamContainer.vue @@ -81,7 +81,7 @@ export default { sleepTimerRemaining: 0, sleepTimer: null, displayTitle: null, - initialPlaybackRate: 1, + currentPlaybackRate: 1, syncFailedToast: null } }, @@ -120,17 +120,22 @@ export default { streamLibraryItem() { return this.$store.state.streamLibraryItem }, + streamEpisode() { + if (!this.$store.state.streamEpisodeId) return null + const episodes = this.streamLibraryItem.media.episodes || [] + return episodes.find((ep) => ep.id === this.$store.state.streamEpisodeId) + }, libraryItemId() { - return this.streamLibraryItem ? this.streamLibraryItem.id : null + return this.streamLibraryItem?.id || null }, media() { - return this.streamLibraryItem ? this.streamLibraryItem.media || {} : {} + return this.streamLibraryItem?.media || {} }, isPodcast() { - return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false + return this.streamLibraryItem?.mediaType === 'podcast' }, isMusic() { - return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'music' : false + return this.streamLibraryItem?.mediaType === 'music' }, isExplicit() { return this.mediaMetadata.explicit || false @@ -139,6 +144,7 @@ export default { return this.media.metadata || {} }, chapters() { + if (this.streamEpisode) return this.streamEpisode.chapters || [] return this.media.chapters || [] }, title() { @@ -152,7 +158,8 @@ export default { return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null }, totalDurationPretty() { - return this.$secondsToTimestamp(this.totalDuration) + // Adjusted by playback rate + return this.$secondsToTimestamp(this.totalDuration / this.currentPlaybackRate) }, podcastAuthor() { if (!this.isPodcast) return null @@ -255,7 +262,7 @@ export default { this.playerHandler.setVolume(volume) }, setPlaybackRate(playbackRate) { - this.initialPlaybackRate = playbackRate + this.currentPlaybackRate = playbackRate this.playerHandler.setPlaybackRate(playbackRate) }, seek(time) { @@ -384,7 +391,7 @@ export default { libraryItem: session.libraryItem, episodeId: session.episodeId }) - this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate) + this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate) }, streamOpen(session) { console.log(`[StreamContainer] Stream session open`, session) @@ -451,7 +458,7 @@ export default { if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack() }) - this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate, payload.startTime) + this.playerHandler.load(libraryItem, episodeId, true, this.currentPlaybackRate, payload.startTime) }, pauseItem() { this.playerHandler.pause() @@ -459,6 +466,13 @@ export default { showFailedProgressSyncs() { if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast) this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' }) + }, + sessionClosedEvent(sessionId) { + if (this.playerHandler.currentSessionId === sessionId) { + console.log('sessionClosedEvent closing current session', sessionId) + this.playerHandler.resetPlayer() // Closes player without reporting to server + this.$store.commit('setMediaPlaying', null) + } } }, mounted() { diff --git a/client/components/cards/AuthorCard.vue b/client/components/cards/AuthorCard.vue index ce4f0d3b..db4e7e9a 100644 --- a/client/components/cards/AuthorCard.vue +++ b/client/components/cards/AuthorCard.vue @@ -77,6 +77,12 @@ export default { }, userCanUpdate() { return this.$store.getters['user/getUserCanUpdate'] + }, + currentLibraryId() { + return this.$store.state.libraries.currentLibraryId + }, + libraryProvider() { + return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google' } }, methods: { @@ -92,6 +98,11 @@ export default { if (this.asin) payload.asin = this.asin else payload.q = this.name + payload.region = 'us' + if (this.libraryProvider.startsWith('audible.')) { + payload.region = this.libraryProvider.split('.').pop() || 'us' + } + var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => { console.error('Failed', error) return null diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index c4d3e967..42a3be4e 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -526,6 +526,14 @@ export default { } } } + + if (this.userCanDelete) { + items.push({ + func: 'deleteLibraryItem', + text: this.$strings.ButtonDelete + }) + } + return items }, _socket() { @@ -777,6 +785,35 @@ export default { this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }]) this.store.commit('globals/setShowPlaylistsModal', true) }, + deleteLibraryItem() { + const payload = { + message: 'This will delete the library item from the database and your file system. Are you sure?', + checkboxLabel: 'Delete from file system. Uncheck to only remove from database.', + yesButtonText: this.$strings.ButtonDelete, + yesButtonColor: 'error', + checkboxDefaultValue: true, + callback: (confirmed, hardDelete) => { + if (confirmed) { + this.processing = true + const axios = this.$axios || this.$nuxt.$axios + axios + .$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`) + .then(() => { + this.$toast.success('Item deleted') + }) + .catch((error) => { + console.error('Failed to delete item', error) + this.$toast.error('Failed to delete item') + }) + .finally(() => { + this.processing = false + }) + } + }, + type: 'yesNo' + } + this.store.commit('globals/setConfirmPrompt', payload) + }, createMoreMenu() { if (!this.$refs.moreIcon) return diff --git a/client/components/cards/LazySeriesCard.vue b/client/components/cards/LazySeriesCard.vue index db5e3ec5..313530b6 100644 --- a/client/components/cards/LazySeriesCard.vue +++ b/client/components/cards/LazySeriesCard.vue @@ -81,13 +81,20 @@ export default { return this.title }, displaySortLine() { - if (this.orderBy === 'addedAt') { - // return this.addedAt - return 'Added ' + this.$formatDate(this.addedAt, this.dateFormat) - } else if (this.orderBy === 'totalDuration') { - return 'Duration: ' + this.$elapsedPrettyExtended(this.totalDuration, false) + switch (this.orderBy) { + case 'addedAt': + return `${this.$strings.LabelAdded} ${this.$formatDate(this.addedAt, this.dateFormat)}` + case 'totalDuration': + return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}` + case 'lastBookUpdated': + const lastUpdated = Math.max(...(this.books).map(x => x.updatedAt), 0) + return `${this.$strings.LabelLastBookUpdated} ${this.$formatDate(lastUpdated, this.dateFormat)}` + case 'lastBookAdded': + const lastBookAdded = Math.max(...(this.books).map(x => x.addedAt), 0) + return `${this.$strings.LabelLastBookAdded} ${this.$formatDate(lastBookAdded, this.dateFormat)}` + default: + return null } - return null }, books() { return this.series ? this.series.books || [] : [] diff --git a/client/components/modals/AudioFileDataModal.vue b/client/components/modals/AudioFileDataModal.vue new file mode 100644 index 00000000..fae88056 --- /dev/null +++ b/client/components/modals/AudioFileDataModal.vue @@ -0,0 +1,118 @@ + + + diff --git a/client/components/modals/ChaptersModal.vue b/client/components/modals/ChaptersModal.vue index 2a3631db..2ace9891 100644 --- a/client/components/modals/ChaptersModal.vue +++ b/client/components/modals/ChaptersModal.vue @@ -2,13 +2,13 @@
@@ -92,6 +92,11 @@ export default { useChapterTrack: false } }, + watch: { + playbackRate() { + this.updateTimestamp() + } + }, computed: { sleepTimerRemainingString() { var rounded = Math.round(this.sleepTimerRemaining) @@ -213,18 +218,14 @@ export default { } }, increasePlaybackRate() { - var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3] - var currentRateIndex = rates.findIndex((r) => r === this.playbackRate) - if (currentRateIndex >= rates.length - 1) return - this.playbackRate = rates[currentRateIndex + 1] || 1 - this.playbackRateChanged(this.playbackRate) + if (this.playbackRate >= 10) return + this.playbackRate = Number((this.playbackRate + 0.1).toFixed(1)) + this.setPlaybackRate(this.playbackRate) }, decreasePlaybackRate() { - var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3] - var currentRateIndex = rates.findIndex((r) => r === this.playbackRate) - if (currentRateIndex <= 0) return - this.playbackRate = rates[currentRateIndex - 1] || 1 - this.playbackRateChanged(this.playbackRate) + if (this.playbackRate <= 0.5) return + this.playbackRate = Number((this.playbackRate - 0.1).toFixed(1)) + this.setPlaybackRate(this.playbackRate) }, setPlaybackRate(playbackRate) { this.$emit('setPlaybackRate', playbackRate) @@ -289,14 +290,13 @@ export default { if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady) }, updateTimestamp() { - var ts = this.$refs.currentTimestamp + const ts = this.$refs.currentTimestamp if (!ts) { console.error('No timestamp el') return } const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime - var currTimeClean = this.$secondsToTimestamp(time) - ts.innerText = currTimeClean + ts.innerText = this.$secondsToTimestamp(time / this.playbackRate) }, setBufferTime(bufferTime) { if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime) @@ -312,7 +312,7 @@ export default { this.useChapterTrack = this.chapters.length ? _useChapterTrack : false if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack) - this.$emit('setPlaybackRate', this.playbackRate) + this.setPlaybackRate(this.playbackRate) }, settingsUpdated(settings) { if (settings.playbackRate && this.playbackRate !== settings.playbackRate) { diff --git a/client/components/prompt/Confirm.vue b/client/components/prompt/Confirm.vue index 00e38e08..b09bf55d 100644 --- a/client/components/prompt/Confirm.vue +++ b/client/components/prompt/Confirm.vue @@ -3,11 +3,14 @@
-

+

+ + +

{{ $strings.ButtonCancel }}
- {{ $strings.ButtonYes }} + {{ yesButtonText }} {{ $strings.ButtonOk }}
@@ -21,7 +24,8 @@ export default { data() { return { el: null, - content: null + content: null, + checkboxValue: false } }, watch: { @@ -57,6 +61,18 @@ export default { persistent() { return !!this.confirmPromptOptions.persistent }, + checkboxLabel() { + return this.confirmPromptOptions.checkboxLabel + }, + yesButtonText() { + return this.confirmPromptOptions.yesButtonText || this.$strings.ButtonYes + }, + yesButtonColor() { + return this.confirmPromptOptions.yesButtonColor || 'success' + }, + checkboxDefaultValue() { + return !!this.confirmPromptOptions.checkboxDefaultValue + }, isYesNo() { return this.type === 'yesNo' }, @@ -84,10 +100,11 @@ export default { this.show = false }, confirm() { - if (this.callback) this.callback(true) + if (this.callback) this.callback(true, this.checkboxValue) this.show = false }, setShow() { + this.checkboxValue = this.checkboxDefaultValue this.$eventBus.$emit('showing-prompt', true) document.body.appendChild(this.el) setTimeout(() => { diff --git a/client/components/tables/AudioTracksTableRow.vue b/client/components/tables/AudioTracksTableRow.vue new file mode 100644 index 00000000..837aaa1a --- /dev/null +++ b/client/components/tables/AudioTracksTableRow.vue @@ -0,0 +1,123 @@ + + + \ No newline at end of file diff --git a/client/components/tables/LibraryFilesTable.vue b/client/components/tables/LibraryFilesTable.vue index 1e3c7dc6..12ad86e5 100644 --- a/client/components/tables/LibraryFilesTable.vue +++ b/client/components/tables/LibraryFilesTable.vue @@ -6,7 +6,7 @@ {{ files.length }}
- +
expand_more
@@ -18,60 +18,76 @@ {{ $strings.LabelPath }} {{ $strings.LabelSize }} {{ $strings.LabelType }} - {{ $strings.LabelDownload }} + - \ No newline at end of file diff --git a/client/components/tables/TracksTable.vue b/client/components/tables/TracksTable.vue index 05f7ea17..22944453 100644 --- a/client/components/tables/TracksTable.vue +++ b/client/components/tables/TracksTable.vue @@ -5,9 +5,8 @@
{{ tracks.length }}
-
- + {{ $strings.ButtonManageTracks }} @@ -21,41 +20,20 @@ # {{ $strings.LabelFilename }} - {{ $strings.LabelSize }} - {{ $strings.LabelDuration }} - {{ $strings.LabelDownload }} - -
-

Tone

- - information - -
- + {{ $strings.LabelCodec }} + {{ $strings.LabelBitrate }} + {{ $strings.LabelSize }} + {{ $strings.LabelDuration }} +
+ +
@@ -77,47 +55,31 @@ export default { return { showTracks: false, showFullPath: false, - toneProbing: false + selectedAudioFile: null, + showAudioFileDataModal: false } }, computed: { - userToken() { - return this.$store.getters['user/getToken'] - }, userCanDownload() { return this.$store.getters['user/getUserCanDownload'] }, userCanUpdate() { return this.$store.getters['user/getUserCanUpdate'] }, - showExperimentalFeatures() { - return this.$store.state.showExperimentalFeatures + userCanDelete() { + return this.$store.getters['user/getUserCanDelete'] + }, + userIsAdmin() { + return this.$store.getters['user/getIsAdminOrUp'] } }, methods: { clickBar() { this.showTracks = !this.showTracks }, - toneProbe(index) { - this.toneProbing = true - - this.$axios - .$post(`/api/items/${this.libraryItemId}/tone-scan/${index}`) - .then((data) => { - console.log('Tone probe data', data) - if (data.error) { - this.$toast.error('Tone probe error: ' + data.error) - } else { - this.$toast.success('Tone probe successful! Check browser console') - } - }) - .catch((error) => { - console.error('Failed to tone probe', error) - this.$toast.error('Tone probe failed') - }) - .finally(() => { - this.toneProbing = false - }) + showMore(audioFile) { + this.selectedAudioFile = audioFile + this.showAudioFileDataModal = true } }, mounted() {} diff --git a/client/components/tables/podcast/EpisodeTableRow.vue b/client/components/tables/podcast/EpisodeTableRow.vue index 3012faf4..a5b85e6e 100644 --- a/client/components/tables/podcast/EpisodeTableRow.vue +++ b/client/components/tables/podcast/EpisodeTableRow.vue @@ -12,6 +12,7 @@

Season #{{ episode.season }}

Episode #{{ episode.episode }}

+

{{ episode.chapters.length }} Chapters

Published {{ $formatDate(publishedAt, dateFormat) }}

diff --git a/client/components/tables/podcast/EpisodesTable.vue b/client/components/tables/podcast/EpisodesTable.vue index a5af04cd..feec71eb 100644 --- a/client/components/tables/podcast/EpisodesTable.vue +++ b/client/components/tables/podcast/EpisodesTable.vue @@ -54,7 +54,7 @@ export default { quickMatchingEpisodes: false, search: null, searchTimeout: null, - searchText: null, + searchText: null } }, watch: { @@ -139,19 +139,25 @@ export default { return episodeProgress && !episodeProgress.isFinished }) .sort((a, b) => { - if (this.sortDesc) { - return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' }) + let aValue = a[this.sortKey] + let bValue = b[this.sortKey] + + // Sort episodes with no pub date as the oldest + if (this.sortKey === 'publishedAt') { + if (!aValue) aValue = Number.MAX_VALUE + if (!bValue) bValue = Number.MAX_VALUE } - return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' }) + + if (this.sortDesc) { + return String(bValue).localeCompare(String(aValue), undefined, { numeric: true, sensitivity: 'base' }) + } + return String(aValue).localeCompare(String(bValue), undefined, { numeric: true, sensitivity: 'base' }) }) }, episodesList() { return this.episodesSorted.filter((episode) => { if (!this.searchText) return true - return ( - (episode.title && episode.title.toLowerCase().includes(this.searchText)) || - (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText)) - ) + return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText)) }) }, selectedIsFinished() { diff --git a/client/components/ui/ContextMenuDropdown.vue b/client/components/ui/ContextMenuDropdown.vue index 018bf34b..6f486d5d 100644 --- a/client/components/ui/ContextMenuDropdown.vue +++ b/client/components/ui/ContextMenuDropdown.vue @@ -1,11 +1,13 @@