diff --git a/.github/workflows/component-tests.yml b/.github/workflows/component-tests.yml new file mode 100644 index 000000000..fcc2c2138 --- /dev/null +++ b/.github/workflows/component-tests.yml @@ -0,0 +1,48 @@ +name: Run Component Tests + +on: + workflow_dispatch: + inputs: + ref: + description: 'Branch/Tag/SHA to test' + required: true + pull_request: + paths: + - 'client/**' + - '.github/workflows/component-tests.yml' + push: + paths: + - 'client/**' + - '.github/workflows/component-tests.yml' + +jobs: + run-component-tests: + name: Run Component Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout (push/pull request) + uses: actions/checkout@v4 + if: github.event_name != 'workflow_dispatch' + + - name: Checkout (workflow_dispatch) + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + if: github.event_name == 'workflow_dispatch' + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: | + cd client + npm ci + + - name: Run tests + run: | + cd client + npm test diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index f93853f94..fdb57fbc5 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -23,7 +23,7 @@ on: jobs: build: if: ${{ !contains(github.event.head_commit.message, 'skip ci') && github.repository == 'advplyr/audiobookshelf' }} - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Check out diff --git a/client/components/app/MediaPlayerContainer.vue b/client/components/app/MediaPlayerContainer.vue index ee47165aa..1a2b1d30a 100644 --- a/client/components/app/MediaPlayerContainer.vue +++ b/client/components/app/MediaPlayerContainer.vue @@ -156,7 +156,7 @@ export default { return this.mediaMetadata.authors || [] }, libraryId() { - return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null + return this.streamLibraryItem?.libraryId || null }, totalDurationPretty() { // Adjusted by playback rate diff --git a/client/components/cards/ItemUploadCard.vue b/client/components/cards/ItemUploadCard.vue index a0188b5a5..40836b8e4 100644 --- a/client/components/cards/ItemUploadCard.vue +++ b/client/components/cards/ItemUploadCard.vue @@ -20,10 +20,10 @@
- -
+ +
+
diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index 4f37764b2..ab38be8e7 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -223,8 +223,7 @@ export default { return this.mediaMetadata.explicit || false }, placeholderUrl() { - const config = this.$config || this.$nuxt.$config - return `${config.routerBasePath}/book_placeholder.jpg` + return this.store.getters['globals/getPlaceholderCoverSrc'] }, bookCoverSrc() { return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl) diff --git a/client/components/content/LibraryItemDetails.vue b/client/components/content/LibraryItemDetails.vue index 268fd6975..f746fd221 100644 --- a/client/components/content/LibraryItemDetails.vue +++ b/client/components/content/LibraryItemDetails.vue @@ -1,7 +1,7 @@ @@ -65,11 +65,12 @@ export default { return 0.8 * this.sizeMultiplier }, resolution() { + if (!this.naturalWidth || !this.naturalHeight) return null return `${this.naturalWidth}×${this.naturalHeight}px` }, placeholderUrl() { - const config = this.$config || this.$nuxt.$config - return `${config.routerBasePath}/book_placeholder.jpg` + const store = this.$store || this.$nuxt.$store + return store.getters['globals/getPlaceholderCoverSrc'] } }, methods: { diff --git a/client/components/modals/item/tabs/Cover.vue b/client/components/modals/item/tabs/Cover.vue index fd258760a..17979f708 100644 --- a/client/components/modals/item/tabs/Cover.vue +++ b/client/components/modals/item/tabs/Cover.vue @@ -2,7 +2,7 @@
- +
@@ -157,6 +157,12 @@ export default { coverPath() { return this.media.coverPath }, + coverUrl() { + if (!this.coverPath) { + return this.$store.getters['globals/getPlaceholderCoverSrc'] + } + return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.libraryItemId, this.libraryItemUpdatedAt, true) + }, mediaMetadata() { return this.media.metadata || {} }, diff --git a/client/components/modals/player/QueueItemRow.vue b/client/components/modals/player/QueueItemRow.vue index 2eb1bc3b6..9ac01a167 100644 --- a/client/components/modals/player/QueueItemRow.vue +++ b/client/components/modals/player/QueueItemRow.vue @@ -55,7 +55,7 @@ export default { return this.item.coverPath }, coverUrl() { - if (!this.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg` + if (!this.coverPath) return this.$store.getters['globals/getPlaceholderCoverSrc'] return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.libraryItemId) }, bookCoverAspectRatio() { diff --git a/client/components/modals/podcast/EpisodeFeed.vue b/client/components/modals/podcast/EpisodeFeed.vue index 2648c5223..08f2f38c8 100644 --- a/client/components/modals/podcast/EpisodeFeed.vue +++ b/client/components/modals/podcast/EpisodeFeed.vue @@ -10,6 +10,12 @@
+ + {{ $strings.LabelSortPubDate }} + + {{ sortDescending ? 'expand_more' : 'expand_less' }} + +
@@ -73,7 +79,8 @@ export default { searchTimeout: null, searchText: null, downloadedEpisodeGuidMap: {}, - downloadedEpisodeUrlMap: {} + downloadedEpisodeUrlMap: {}, + sortDescending: true } }, watch: { @@ -141,6 +148,17 @@ export default { } }, methods: { + toggleSort() { + this.sortDescending = !this.sortDescending + this.episodesCleaned = this.episodesCleaned.toSorted((a, b) => { + if (this.sortDescending) { + return a.publishedAt < b.publishedAt ? 1 : -1 + } + return a.publishedAt > b.publishedAt ? 1 : -1 + }) + this.selectedEpisodes = {} + this.selectAll = false + }, getIsEpisodeDownloaded(episode) { if (episode.guid && !!this.downloadedEpisodeGuidMap[episode.guid]) { return true diff --git a/client/components/player/PlayerTrackBar.vue b/client/components/player/PlayerTrackBar.vue index cc581e254..6c96a6bfb 100644 --- a/client/components/player/PlayerTrackBar.vue +++ b/client/components/player/PlayerTrackBar.vue @@ -74,6 +74,9 @@ export default { currentChapterStart() { if (!this.currentChapter) return 0 return this.currentChapter.start + }, + isMobile() { + return this.$store.state.globals.isMobile } }, methods: { @@ -145,6 +148,9 @@ export default { }) }, mousemoveTrack(e) { + if (this.isMobile) { + return + } const offsetX = e.offsetX const baseTime = this.useChapterTrack ? this.currentChapterStart : 0 @@ -198,6 +204,7 @@ export default { setTrackWidth() { if (this.$refs.track) { this.trackWidth = this.$refs.track.clientWidth + this.trackOffsetLeft = this.$refs.track.getBoundingClientRect().left } else { console.error('Track not loaded', this.$refs) } diff --git a/client/components/stats/YearInReviewBanner.vue b/client/components/stats/YearInReviewBanner.vue index e36775382..62776bc6b 100644 --- a/client/components/stats/YearInReviewBanner.vue +++ b/client/components/stats/YearInReviewBanner.vue @@ -164,14 +164,15 @@ export default { beforeMount() { this.yearInReviewYear = new Date().getFullYear() - // When not December show previous year - if (new Date().getMonth() < 11) { + this.availableYears = this.getAvailableYears() + const availableYearValues = this.availableYears.map((y) => y.value) + + // When not December show previous year if data is available + if (new Date().getMonth() < 11 && availableYearValues.includes(this.yearInReviewYear - 1)) { this.yearInReviewYear-- } }, mounted() { - this.availableYears = this.getAvailableYears() - if (typeof navigator.share !== 'undefined' && navigator.share) { this.showShareButton = true } else { diff --git a/client/components/tables/BackupsTable.vue b/client/components/tables/BackupsTable.vue index 69772732c..769c8d25c 100644 --- a/client/components/tables/BackupsTable.vue +++ b/client/components/tables/BackupsTable.vue @@ -26,9 +26,9 @@ error_outline - + - +
diff --git a/client/cypress/tests/components/cards/AuthorCard.cy.js b/client/cypress/tests/components/cards/AuthorCard.cy.js index 21c638e18..4c4a1cb88 100644 --- a/client/cypress/tests/components/cards/AuthorCard.cy.js +++ b/client/cypress/tests/components/cards/AuthorCard.cy.js @@ -19,7 +19,9 @@ describe('AuthorCard', () => { const mocks = { $strings: { LabelBooks: 'Books', - ButtonQuickMatch: 'Quick Match' + ButtonQuickMatch: 'Quick Match', + ToastAuthorUpdateSuccess: 'Author updated', + ToastAuthorUpdateSuccessNoImageFound: 'Author updated (no image found)' }, $store: { getters: { @@ -167,7 +169,7 @@ describe('AuthorCard', () => { cy.get('&match').click() cy.get('&spinner').should('be.hidden') - cy.get('@success').should('have.been.calledOnceWithExactly', 'Author John Doe was updated (no image found)') + cy.get('@success').should('have.been.calledOnceWithExactly', 'Author updated (no image found)') cy.get('@error').should('not.have.been.called') cy.get('@info').should('not.have.been.called') }) @@ -189,7 +191,7 @@ describe('AuthorCard', () => { cy.get('&match').click() cy.get('&spinner').should('be.hidden') - cy.get('@success').should('have.been.calledOnceWithExactly', 'Author John Doe was updated') + cy.get('@success').should('have.been.calledOnceWithExactly', 'Author updated') cy.get('@error').should('not.have.been.called') cy.get('@info').should('not.have.been.called') }) diff --git a/client/cypress/tests/components/cards/LazyBookCard.cy.js b/client/cypress/tests/components/cards/LazyBookCard.cy.js index c39c03023..ab685b0d1 100644 --- a/client/cypress/tests/components/cards/LazyBookCard.cy.js +++ b/client/cypress/tests/components/cards/LazyBookCard.cy.js @@ -49,6 +49,7 @@ function createMountOptions() { 'libraries/getLibraryProvider': () => 'audible.us', 'libraries/getBookCoverAspectRatio': 1, 'globals/getLibraryItemCoverSrc': () => 'https://my.server.com/book_placeholder.jpg', + 'globals/getPlaceholderCoverSrc': 'https://my.server.com/book_placeholder.jpg', getLibraryItemsStreaming: () => null, getIsMediaQueued: () => false, getIsStreamingFromDifferentLibrary: () => false @@ -172,6 +173,7 @@ describe('LazyBookCard', () => { }) it('shows titleImageNotReady and sets opacity 0 on coverImage when image not ready', () => { + mountOptions.mocks.$store.getters['globals/getLibraryItemCoverSrc'] = () => 'https://my.server.com/notfound.jpg' cy.mount(LazyBookCard, mountOptions) cy.get('&titleImageNotReady').should('be.visible') @@ -257,7 +259,7 @@ describe('LazyBookCard', () => { cy.get('#book-card-0').trigger('mouseover') cy.get('&titleImageNotReady').should('be.hidden') - cy.get('&seriesNameOverlay').should('be.visible').and('have.text', 'Middle Earth Chronicles') + cy.get('&seriesNameOverlay').should('be.visible').and('have.text', 'The Lord of the Rings') }) it('shows the seriesSequenceList when collapsed series has a sequence list', () => { diff --git a/client/cypress/tests/components/cards/LazySeriesCard.cy.js b/client/cypress/tests/components/cards/LazySeriesCard.cy.js index c637c604e..346259d27 100644 --- a/client/cypress/tests/components/cards/LazySeriesCard.cy.js +++ b/client/cypress/tests/components/cards/LazySeriesCard.cy.js @@ -30,6 +30,14 @@ describe('LazySeriesCard', () => { } const mocks = { + $getString: (id, args) => { + switch (id) { + case 'LabelAddedDate': + return `Added ${args[0]}` + default: + return null + } + }, $store: { getters: { 'user/getUserCanUpdate': true, diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 9121561e4..33e7aa15b 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -183,7 +183,7 @@ export default { this.$store.commit('libraries/updateFilterDataWithItem', libraryItem) }, libraryItemUpdated(libraryItem) { - if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) { + if (this.$store.state.selectedLibraryItem?.id === libraryItem.id) { this.$store.commit('setSelectedLibraryItem', libraryItem) if (this.$store.state.globals.selectedEpisode && libraryItem.mediaType === 'podcast') { const episode = libraryItem.media.episodes.find((ep) => ep.id === this.$store.state.globals.selectedEpisode.id) @@ -192,6 +192,9 @@ export default { } } } + if (this.$store.state.streamLibraryItem?.id === libraryItem.id) { + this.$store.commit('updateStreamLibraryItem', libraryItem) + } this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem) this.$store.commit('libraries/updateFilterDataWithItem', libraryItem) }, diff --git a/client/package-lock.json b/client/package-lock.json index d0bf36b7d..733dc4aff 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.20.0", + "version": "2.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.20.0", + "version": "2.21.0", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index baf8013d7..ac6edb8f0 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.20.0", + "version": "2.21.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/client/pages/audiobook/_id/chapters.vue b/client/pages/audiobook/_id/chapters.vue index 71abef33b..55f74b5c2 100644 --- a/client/pages/audiobook/_id/chapters.vue +++ b/client/pages/audiobook/_id/chapters.vue @@ -141,10 +141,21 @@
-
- - - {{ $strings.ButtonSearch }} +
+
+
+ + + {{ $strings.ButtonSearch }} +
+ +
+

{{ asinError }}

+

{{ $strings.MessageAsinCheck }}

+
+ + +
@@ -221,6 +232,11 @@ export default { return redirect('/') } + // Fetch and set library if this items library does not match the current + if (store.state.libraries.currentLibraryId !== libraryItem.libraryId || !store.state.libraries.filterData) { + await store.dispatch('libraries/fetch', libraryItem.libraryId) + } + var previousRoute = from ? from.fullPath : null if (from && from.path === '/login') previousRoute = null return { @@ -244,6 +260,7 @@ export default { findingChapters: false, showFindChaptersModal: false, chapterData: null, + asinError: null, showSecondInputs: false, audibleRegions: ['US', 'CA', 'UK', 'AU', 'FR', 'DE', 'JP', 'IT', 'IN', 'ES'], hasChanges: false @@ -541,14 +558,14 @@ export default { this.findingChapters = true this.chapterData = null + this.asinError = null // used to show warning about audible vs amazon ASIN this.$axios .$get(`/api/search/chapters?asin=${this.asinInput}®ion=${this.regionInput}`) .then((data) => { this.findingChapters = false if (data.error) { - this.$toast.error(data.error) - this.showFindChaptersModal = false + this.asinError = this.$getString(data.stringKey) } else { console.log('Chapter data', data) this.chapterData = data diff --git a/client/pages/audiobook/_id/edit.vue b/client/pages/audiobook/_id/edit.vue index e29ab9e32..3aad3f800 100644 --- a/client/pages/audiobook/_id/edit.vue +++ b/client/pages/audiobook/_id/edit.vue @@ -103,6 +103,12 @@ export default { console.error('No need to edit library item that is 1 file...') return redirect('/') } + + // Fetch and set library if this items library does not match the current + if (store.state.libraries.currentLibraryId !== libraryItem.libraryId || !store.state.libraries.filterData) { + await store.dispatch('libraries/fetch', libraryItem.libraryId) + } + return { libraryItem, files: libraryItem.media.audioFiles ? libraryItem.media.audioFiles.map((af) => ({ ...af, include: !af.exclude })) : [] diff --git a/client/pages/audiobook/_id/manage.vue b/client/pages/audiobook/_id/manage.vue index 30e615bbf..3d9ac0512 100644 --- a/client/pages/audiobook/_id/manage.vue +++ b/client/pages/audiobook/_id/manage.vue @@ -195,10 +195,15 @@ export default { return redirect('/?error=invalid media type') } if (!libraryItem.media.audioFiles.length) { - cnosole.error('No audio files') + console.error('No audio files') return redirect('/?error=no audio files') } + // Fetch and set library if this items library does not match the current + if (store.state.libraries.currentLibraryId !== libraryItem.libraryId || !store.state.libraries.filterData) { + await store.dispatch('libraries/fetch', libraryItem.libraryId) + } + return { libraryItem } diff --git a/client/pages/config/rss-feeds.vue b/client/pages/config/rss-feeds.vue index a1e059c02..568239201 100644 --- a/client/pages/config/rss-feeds.vue +++ b/client/pages/config/rss-feeds.vue @@ -32,7 +32,7 @@

{{ feed.meta.title }}

- +

{{ feed.slug }}

@@ -57,7 +57,7 @@ - + diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 6ce34eccf..3a585f3e7 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -91,15 +91,15 @@ {{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }} - - - - {{ $strings.ButtonRead }} + + + + diff --git a/client/pages/library/_library/podcast/latest.vue b/client/pages/library/_library/podcast/latest.vue index b24a2098d..7c78d62d8 100644 --- a/client/pages/library/_library/podcast/latest.vue +++ b/client/pages/library/_library/podcast/latest.vue @@ -250,7 +250,7 @@ export default { }, async loadRecentEpisodes(page = 0) { this.processing = true - const episodePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/recent-episodes?limit=25&page=${page}`).catch((error) => { + const episodePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/recent-episodes?limit=50&page=${page}`).catch((error) => { console.error('Failed to get recent episodes', error) this.$toast.error(this.$strings.ToastFailedToLoadData) return null diff --git a/client/pages/library/_library/stats.vue b/client/pages/library/_library/stats.vue index 575c4676d..0fcc2a414 100644 --- a/client/pages/library/_library/stats.vue +++ b/client/pages/library/_library/stats.vue @@ -89,14 +89,16 @@