diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 7d2cae50..766273e7 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,15 @@ -FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16 -RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ - && apt-get install ffmpeg gnupg2 -y +# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster +ARG VARIANT=16 +FROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} as base + +# Setup the node environment ENV NODE_ENV=development + +# Install additional OS packages. +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \ + curl tzdata ffmpeg && \ + rm -rf /var/lib/apt/lists/* + +# Move tone executable to appropriate directory +COPY --from=sandreas/tone:v0.1.2 /usr/local/bin/tone /usr/local/bin/ diff --git a/.devcontainer/dev.js b/.devcontainer/dev.js new file mode 100644 index 00000000..0d113a3e --- /dev/null +++ b/.devcontainer/dev.js @@ -0,0 +1,9 @@ +// Using port 3333 is important when running the client web app separately +const Path = require('path') +module.exports.config = { + Port: 3333, + ConfigPath: Path.resolve('config'), + MetadataPath: Path.resolve('metadata'), + FFmpegPath: '/usr/bin/ffmpeg', + FFProbePath: '/usr/bin/ffprobe' +} \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 61ee8e9c..1341b2c8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,12 +1,40 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node { - "build": { "dockerfile": "Dockerfile" }, - "mounts": [ - "source=abs-node-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume" - ], - "features": { - "fish": "latest" + "name": "Audiobookshelf", + "build": { + "dockerfile": "Dockerfile", + // Update 'VARIANT' to pick a Node version: 18, 16, 14. + // Append -bullseye or -buster to pin to an OS version. + // Use -bullseye variants on local arm64/Apple Silicon. + "args": { + "VARIANT": "16" + } }, - "extensions": [ - "eamodio.gitlens" - ] + "mounts": [ + "source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume", + "source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume" + ], + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + "forwardPorts": [ + 3000, + 3333 + ], + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "sh .devcontainer/post-create.sh", + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "dbaeumer.vscode-eslint", + "octref.vetur" + ] + } + } + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" } \ No newline at end of file diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100644 index 00000000..bd1e3873 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,29 @@ +#!/bin/sh + +# Mark the working directory as safe for use with git +git config --global --add safe.directory $PWD + +# If there is no dev.js file, create it +if [ ! -f dev.js ]; then + cp .devcontainer/dev.js . +fi + +# Update permissions for node_modules folders +# https://code.visualstudio.com/remote/advancedcontainers/improve-performance#_use-a-targeted-named-volume +if [ -d node_modules ]; then + sudo chown $(id -u):$(id -g) node_modules +fi + +if [ -d client/node_modules ]; then + sudo chown $(id -u):$(id -g) client/node_modules +fi + +# Install packages for the server +if [ -f package.json ]; then + npm ci +fi + +# Install packages and build the client +if [ -f client/package.json ]; then + (cd client; npm ci; npm run generate) +fi diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..fe61167a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Set the default behavior, in case people don't have core.autocrlf set. +* text=auto + +# Declare files that will always have CRLF line endings on checkout. +.devcontainer/post-create.sh text eol=lf diff --git a/.gitignore b/.gitignore index 8df68f62..25a8a774 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ .env -dev.js -node_modules/ +/dev.js +**/node_modules/ /config/ /audiobooks/ /audiobooks2/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..20706b26 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,44 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug server", + "runtimeExecutable": "npm", + "args": [ + "run", + "dev" + ], + "skipFiles": [ + "/**" + ] + }, + { + "type": "node", + "request": "launch", + "name": "Debug client (nuxt)", + "runtimeExecutable": "npm", + "args": [ + "run", + "dev" + ], + "cwd": "${workspaceFolder}/client", + "skipFiles": [ + "${workspaceFolder}//**" + ] + } + ], + "compounds": [ + { + "name": "Debug server and client (nuxt)", + "configurations": [ + "Debug server", + "Debug client (nuxt)" + ] + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..e041741f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,40 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "path": "client", + "type": "npm", + "script": "generate", + "detail": "nuxt generate", + "label": "Build client", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "dependsOn": [ + "Build client" + ], + "type": "npm", + "script": "dev", + "detail": "nodemon --watch server index.js", + "label": "Run server", + "group": { + "kind": "test", + "isDefault": true + } + }, + { + "path": "client", + "type": "npm", + "script": "dev", + "detail": "nuxt", + "label": "Run Live-reload client", + "group": { + "kind": "test", + "isDefault": false + } + } + ] +} \ No newline at end of file diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index 5f22febe..382f5cf3 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -58,9 +58,6 @@ play_arrow {{ $strings.ButtonPlay }} - - - @@ -75,8 +72,11 @@ - - close + + + + + close @@ -160,9 +160,59 @@ export default { }, isHttps() { return location.protocol === 'https:' || process.env.NODE_ENV === 'development' + }, + contextMenuItems() { + if (!this.userIsAdminOrUp) return [] + + const options = [ + { + text: this.$strings.ButtonQuickMatch, + action: 'quick-match' + } + ] + + if (!this.isPodcastLibrary && this.selectedMediaItemsArePlayable) { + options.push({ + text: 'Quick Embed Metadata', + action: 'quick-embed' + }) + } + + return options } }, methods: { + requestBatchQuickEmbed() { + const payload = { + message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files.

Would you like to continue?', + callback: (confirmed) => { + if (confirmed) { + this.$axios + .$post(`/api/tools/batch/embed-metadata`, { + libraryItemIds: this.selectedMediaItems.map((i) => i.id) + }) + .then(() => { + console.log('Audio metadata embed started') + this.cancelSelectionMode() + }) + .catch((error) => { + console.error('Audio metadata embed failed', error) + const errorMsg = error.response.data || 'Failed to embed metadata' + this.$toast.error(errorMsg) + }) + } + }, + type: 'yesNo' + } + this.$store.commit('globals/setConfirmPrompt', payload) + }, + contextMenuAction(action) { + if (action === 'quick-embed') { + this.requestBatchQuickEmbed() + } else if (action === 'quick-match') { + this.batchAutoMatchClick() + } + }, async playSelectedItems() { this.$store.commit('setProcessingBatch', true) diff --git a/client/components/app/BookShelfToolbar.vue b/client/components/app/BookShelfToolbar.vue index d512bc1b..a3879279 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' 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/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index d54ab791..c4d3e967 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -325,8 +325,13 @@ export default { if (this.episodeProgress) return this.episodeProgress return this.store.getters['user/getUserMediaProgress'](this.libraryItemId) }, + useEBookProgress() { + if (!this.userProgress || this.userProgress.progress) return false + return this.userProgress.ebookProgress > 0 + }, userProgressPercent() { - return this.userProgress ? this.userProgress.progress || 0 : 0 + if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0) + return this.userProgress ? Math.max(Math.min(1, this.userProgress.progress), 0) || 0 : 0 }, itemIsFinished() { return this.userProgress ? !!this.userProgress.isFinished : false 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/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 @@