From 03312390cb94ddc8df78a40fb861fb7d318f4749 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 23 Mar 2022 17:59:14 -0500 Subject: [PATCH 01/29] New data model updates for bookshelf, covers, cards --- components/app/AudioPlayerContainer.vue | 27 +- components/bookshelf/LazyBookshelf.vue | 87 ++--- components/bookshelf/Shelf.vue | 2 +- components/cards/LazyBookCard.vue | 278 ++++++++++++--- components/covers/BookCover.vue | 255 +------------- components/covers/CollectionCover.vue | 4 +- components/covers/GroupCover.vue | 74 ++-- components/widgets/LoadingSpinner.vue | 241 +++++++++++++ layouts/default.vue | 63 ++-- mixins/bookshelfCardsHelpers.js | 17 +- pages/bookshelf/index.vue | 3 +- pages/connect.vue | 6 +- pages/item/_id.vue | 445 ++++++++++++++++++++++++ store/globals.js | 32 ++ store/index.js | 10 +- store/user.js | 4 + 16 files changed, 1094 insertions(+), 454 deletions(-) create mode 100644 components/widgets/LoadingSpinner.vue create mode 100644 pages/item/_id.vue create mode 100644 store/globals.js diff --git a/components/app/AudioPlayerContainer.vue b/components/app/AudioPlayerContainer.vue index e4d8fa66..4da0eb85 100644 --- a/components/app/AudioPlayerContainer.vue +++ b/components/app/AudioPlayerContainer.vue @@ -450,18 +450,6 @@ export default { this.$refs.audioPlayer.setPlaybackSpeed(this.playbackSpeed) } }, - streamUpdated(type, data) { - if (type === 'download') { - if (data) { - this.download = { ...data } - if (this.audioPlayerReady) { - this.playDownload() - } - } else if (this.download) { - this.cancelStream() - } - } - }, setListeners() { if (!this.$server.socket) { console.error('Invalid server socket not set') @@ -481,6 +469,16 @@ export default { this.$refs.audioPlayer.terminateStream() } } + }, + async playLibraryItem(libraryItemId) { + var libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => { + console.error('Failed to fetch full item', error) + return null + }) + if (!libraryItem) return + this.$store.commit('setLibraryItemStream', libraryItem) + + // TODO: Call load library item in native } }, mounted() { @@ -491,9 +489,9 @@ export default { console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`) this.setListeners() + this.$eventBus.$on('play-item', this.playLibraryItem) this.$eventBus.$on('close_stream', this.closeStreamOnly) this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated }) - this.$store.commit('setStreamListener', this.streamUpdated) }, beforeDestroy() { if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove() @@ -506,10 +504,9 @@ export default { this.$server.socket.off('stream_ready', this.streamReady) this.$server.socket.off('stream_reset', this.streamReset) } - + this.$eventBus.$off('play-item', this.playLibraryItem) this.$eventBus.$off('close_stream', this.closeStreamOnly) this.$store.commit('user/removeSettingsListener', 'streamContainer') - this.$store.commit('removeStreamListener') } } \ No newline at end of file diff --git a/components/bookshelf/LazyBookshelf.vue b/components/bookshelf/LazyBookshelf.vue index 803a22d3..0874849b 100644 --- a/components/bookshelf/LazyBookshelf.vue +++ b/components/bookshelf/LazyBookshelf.vue @@ -144,19 +144,29 @@ export default { if (!this.initialized) { this.currentSFQueryString = this.buildSearchParams() } - var entityPath = this.entityName === 'books' ? `books/all` : this.entityName + // var entityPath = this.entityName === 'books' ? `books/all` : this.entityName + // var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : '' + // var queryString = `?${sfQueryString}&limit=${this.booksPerFetch}&page=${page}` + + // if (this.entityName === 'series-books') { + // entityPath = `series/${this.seriesId}` + // queryString = '' + // } + + // var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${queryString}`).catch((error) => { + // console.error('failed to fetch books', error) + // return null + // }) + + var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : '' - var queryString = `?${sfQueryString}&limit=${this.booksPerFetch}&page=${page}` + var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1` - if (this.entityName === 'series-books') { - entityPath = `series/${this.seriesId}` - queryString = '' - } - - var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${queryString}`).catch((error) => { + var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => { console.error('failed to fetch books', error) return null }) + this.isFetchingEntities = false if (this.pendingReset) { this.pendingReset = false @@ -390,42 +400,42 @@ export default { this.resetEntities() } }, - audiobookAdded(audiobook) { - console.log('Audiobook added', audiobook) - // TODO: Check if audiobook would be on this shelf + libraryItemAdded(libraryItem) { + console.log('libraryItem added', libraryItem) + // TODO: Check if item would be on this shelf this.resetEntities() }, - audiobookUpdated(audiobook) { - console.log('Audiobook updated', audiobook) + libraryItemUpdated(libraryItem) { + console.log('Item updated', libraryItem) if (this.entityName === 'books' || this.entityName === 'series-books') { - var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id) + var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id) if (indexOf >= 0) { - this.entities[indexOf] = audiobook + this.entities[indexOf] = libraryItem if (this.entityComponentRefs[indexOf]) { - this.entityComponentRefs[indexOf].setEntity(audiobook) + this.entityComponentRefs[indexOf].setEntity(libraryItem) } } } }, - audiobookRemoved(audiobook) { + libraryItemRemoved(libraryItem) { if (this.entityName === 'books' || this.entityName === 'series-books') { - var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id) + var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id) if (indexOf >= 0) { - this.entities = this.entities.filter((ent) => ent.id !== audiobook.id) + this.entities = this.entities.filter((ent) => ent.id !== libraryItem.id) this.totalEntities = this.entities.length this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities) - this.remountEntities() + this.executeRebuild() } } }, - audiobooksAdded(audiobooks) { - console.log('audiobooks added', audiobooks) - // TODO: Check if audiobook would be on this shelf + libraryItemsAdded(libraryItems) { + console.log('items added', libraryItems) + // TODO: Check if item would be on this shelf this.resetEntities() }, - audiobooksUpdated(audiobooks) { - audiobooks.forEach((ab) => { - this.audiobookUpdated(ab) + libraryItemsUpdated(libraryItems) { + libraryItems.forEach((ab) => { + this.libraryItemUpdated(ab) }) }, initListeners() { @@ -433,19 +443,17 @@ export default { if (bookshelf) { bookshelf.addEventListener('scroll', this.scroll) } - // this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities) - // this.$eventBus.$on('bookshelf-select-all', this.selectAllEntities) - // this.$eventBus.$on('bookshelf-keyword-filter', this.updateKeywordFilter) + this.$eventBus.$on('library-changed', this.libraryChanged) this.$eventBus.$on('downloads-loaded', this.downloadsLoaded) this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated }) if (this.$server.socket) { - this.$server.socket.on('audiobook_updated', this.audiobookUpdated) - this.$server.socket.on('audiobook_added', this.audiobookAdded) - this.$server.socket.on('audiobook_removed', this.audiobookRemoved) - this.$server.socket.on('audiobooks_updated', this.audiobooksUpdated) - this.$server.socket.on('audiobooks_added', this.audiobooksAdded) + this.$server.socket.on('item_updated', this.libraryItemUpdated) + this.$server.socket.on('item_added', this.libraryItemAdded) + this.$server.socket.on('item_removed', this.libraryItemRemoved) + this.$server.socket.on('items_updated', this.libraryItemsUpdated) + this.$server.socket.on('items_added', this.libraryItemsAdded) } else { console.error('Bookshelf - Socket not initialized') } @@ -455,16 +463,17 @@ export default { if (bookshelf) { bookshelf.removeEventListener('scroll', this.scroll) } + this.$eventBus.$off('library-changed', this.libraryChanged) this.$eventBus.$off('downloads-loaded', this.downloadsLoaded) this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf') if (this.$server.socket) { - this.$server.socket.off('audiobook_updated', this.audiobookUpdated) - this.$server.socket.off('audiobook_added', this.audiobookAdded) - this.$server.socket.off('audiobook_removed', this.audiobookRemoved) - this.$server.socket.off('audiobooks_updated', this.audiobooksUpdated) - this.$server.socket.off('audiobooks_added', this.audiobooksAdded) + this.$server.socket.off('item_updated', this.libraryItemUpdated) + this.$server.socket.off('item_added', this.libraryItemAdded) + this.$server.socket.off('item_removed', this.libraryItemRemoved) + this.$server.socket.off('items_updated', this.libraryItemsUpdated) + this.$server.socket.off('items_added', this.libraryItemsAdded) } else { console.error('Bookshelf - Socket not initialized') } diff --git a/components/bookshelf/Shelf.vue b/components/bookshelf/Shelf.vue index 49abc225..2cd57b20 100644 --- a/components/bookshelf/Shelf.vue +++ b/components/bookshelf/Shelf.vue @@ -2,7 +2,7 @@
diff --git a/components/cards/LazyBookCard.vue b/components/cards/LazyBookCard.vue index 72ccc822..68b525d9 100644 --- a/components/cards/LazyBookCard.vue +++ b/components/cards/LazyBookCard.vue @@ -1,16 +1,27 @@ - \ No newline at end of file diff --git a/components/covers/CollectionCover.vue b/components/covers/CollectionCover.vue index 91eb1db7..403024eb 100644 --- a/components/covers/CollectionCover.vue +++ b/components/covers/CollectionCover.vue @@ -9,8 +9,8 @@
- - + +
diff --git a/components/covers/GroupCover.vue b/components/covers/GroupCover.vue index 20fd1439..8af50fd0 100644 --- a/components/covers/GroupCover.vue +++ b/components/covers/GroupCover.vue @@ -17,6 +17,7 @@ export default { }, width: Number, height: Number, + groupTo: String, bookCoverAspectRatio: Number }, data() { @@ -31,7 +32,6 @@ export default { isFannedOut: false, isDetached: false, isAttaching: false, - windowWidth: 0, isInit: false } }, @@ -48,8 +48,11 @@ export default { }, computed: { sizeMultiplier() { - if (this.bookCoverAspectRatio === 1) return this.width / (100 * 1.6 * 2) - return this.width / 200 + if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2) + return this.width / 240 + }, + showExperimentalFeatures() { + return this.store.state.showExperimentalFeatures }, store() { return this.$store || this.$nuxt.$store @@ -59,44 +62,8 @@ export default { } }, methods: { - detchCoverWrapper() { - if (!this.coverWrapperEl || !this.$refs.wrapper || this.isDetached) return - - this.coverWrapperEl.remove() - - this.isDetached = true - document.body.appendChild(this.coverWrapperEl) - this.coverWrapperEl.addEventListener('mouseleave', this.mouseleaveCover) - - this.coverWrapperEl.style.position = 'absolute' - this.coverWrapperEl.style.zIndex = 40 - - this.updatePosition() - }, - attachCoverWrapper() { - if (!this.coverWrapperEl || !this.$refs.wrapper || !this.isDetached) return - - this.coverWrapperEl.remove() - this.coverWrapperEl.style.position = 'relative' - this.coverWrapperEl.style.left = 'unset' - this.coverWrapperEl.style.top = 'unset' - this.coverWrapperEl.style.width = this.$refs.wrapper.clientWidth + 'px' - - this.$refs.wrapper.appendChild(this.coverWrapperEl) - - this.isDetached = false - }, - updatePosition() { - var rect = this.$refs.wrapper.getBoundingClientRect() - this.coverWrapperEl.style.top = rect.top + window.scrollY + 'px' - - this.coverWrapperEl.style.left = rect.left + window.scrollX + 4 + 'px' - - this.coverWrapperEl.style.height = rect.height + 'px' - this.coverWrapperEl.style.width = rect.width + 'px' - }, getCoverUrl(book) { - return this.store.getters['audiobooks/getBookCoverSrc'](book, '') + return this.store.getters['globals/getLibraryItemCoverSrc'](book, '') }, async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) { var src = coverData.coverUrl @@ -156,6 +123,22 @@ export default { imgdiv.appendChild(img) return imgdiv }, + createSeriesNameCover(offsetLeft) { + var imgdiv = document.createElement('div') + imgdiv.style.height = this.height + 'px' + imgdiv.style.width = this.height / this.bookCoverAspectRatio + 'px' + imgdiv.style.left = offsetLeft + 'px' + imgdiv.className = 'absolute top-0 box-shadow-book transition-transform flex items-center justify-center' + imgdiv.style.boxShadow = '4px 0px 4px #11111166' + imgdiv.style.backgroundColor = '#111' + + var innerP = document.createElement('p') + innerP.textContent = this.name + innerP.className = 'text-sm font-book text-white' + imgdiv.appendChild(innerP) + + return imgdiv + }, async init() { if (this.isInit) return this.isInit = true @@ -168,7 +151,6 @@ export default { .map((bookItem) => { return { id: bookItem.id, - volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null, coverUrl: this.getCoverUrl(bookItem) } }) @@ -179,6 +161,8 @@ export default { } this.noValidCovers = false + validCovers = validCovers.slice(0, 10) + var coverWidth = this.width var widthPer = this.width if (validCovers.length > 1) { @@ -189,7 +173,7 @@ export default { this.offsetIncrement = widthPer var outerdiv = document.createElement('div') - outerdiv.id = `group-cover-${this.id || this.$encode(this.name)}` + outerdiv.id = `group-cover-${this.id}` this.coverWrapperEl = outerdiv outerdiv.className = 'w-full h-full relative box-shadow-book' @@ -211,9 +195,7 @@ export default { } } }, - mounted() { - this.windowWidth = window.innerWidth - }, + mounted() {}, beforeDestroy() { if (this.coverWrapperEl) this.coverWrapperEl.remove() if (this.coverImageEls && this.coverImageEls.length) { @@ -222,4 +204,4 @@ export default { if (this.coverDiv) this.coverDiv.remove() } } - \ No newline at end of file + diff --git a/components/widgets/LoadingSpinner.vue b/components/widgets/LoadingSpinner.vue new file mode 100644 index 00000000..07fdc83e --- /dev/null +++ b/components/widgets/LoadingSpinner.vue @@ -0,0 +1,241 @@ + + + + + \ No newline at end of file diff --git a/layouts/default.vue b/layouts/default.vue index 65cae499..0fdb58ce 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -51,7 +51,7 @@ export default { async connected(isConnected) { if (isConnected) { console.log('[Default] Connected socket sync user ab data') - this.$store.dispatch('user/syncUserAudiobookData') + // this.$store.dispatch('user/syncUserAudiobookData') this.initSocketListeners() @@ -326,51 +326,49 @@ export default { } } }, - audiobookAdded(audiobook) { - this.$store.commit('libraries/updateFilterDataWithAudiobook', audiobook) - }, - audiobookUpdated(audiobook) { - this.$store.commit('libraries/updateFilterDataWithAudiobook', audiobook) - }, - audiobookRemoved(audiobook) { - if (this.$route.name.startsWith('audiobook')) { - if (this.$route.params.id === audiobook.id) { + // audiobookAdded(audiobook) { + // this.$store.commit('libraries/updateFilterDataWithAudiobook', audiobook) + // }, + // audiobookUpdated(audiobook) { + // this.$store.commit('libraries/updateFilterDataWithAudiobook', audiobook) + // }, + itemRemoved(libraryItem) { + if (this.$route.name.startsWith('item')) { + if (this.$route.params.id === libraryItem.id) { this.$router.replace(`/bookshelf`) } } }, - audiobooksAdded(audiobooks) { - audiobooks.forEach((ab) => { - this.audiobookAdded(ab) - }) - }, - audiobooksUpdated(audiobooks) { - audiobooks.forEach((ab) => { - this.audiobookUpdated(ab) - }) - }, + // audiobooksAdded(audiobooks) { + // audiobooks.forEach((ab) => { + // this.audiobookAdded(ab) + // }) + // }, + // audiobooksUpdated(audiobooks) { + // audiobooks.forEach((ab) => { + // this.audiobookUpdated(ab) + // }) + // }, userLoggedOut() { // Only cancels stream if streamining not playing downloaded this.$eventBus.$emit('close_stream') }, initSocketListeners() { if (this.$server.socket) { - // Audiobook Listeners - this.$server.socket.on('audiobook_updated', this.audiobookUpdated) - this.$server.socket.on('audiobook_added', this.audiobookAdded) - this.$server.socket.on('audiobook_removed', this.audiobookRemoved) - this.$server.socket.on('audiobooks_updated', this.audiobooksUpdated) - this.$server.socket.on('audiobooks_added', this.audiobooksAdded) + // this.$server.socket.on('audiobook_updated', this.audiobookUpdated) + // this.$server.socket.on('audiobook_added', this.audiobookAdded) + this.$server.socket.on('item_removed', this.itemRemoved) + // this.$server.socket.on('audiobooks_updated', this.audiobooksUpdated) + // this.$server.socket.on('audiobooks_added', this.audiobooksAdded) } }, removeSocketListeners() { if (this.$server.socket) { - // Audiobook Listeners - this.$server.socket.off('audiobook_updated', this.audiobookUpdated) - this.$server.socket.off('audiobook_added', this.audiobookAdded) - this.$server.socket.off('audiobook_removed', this.audiobookRemoved) - this.$server.socket.off('audiobooks_updated', this.audiobooksUpdated) - this.$server.socket.off('audiobooks_added', this.audiobooksAdded) + // this.$server.socket.off('audiobook_updated', this.audiobookUpdated) + // this.$server.socket.off('audiobook_added', this.audiobookAdded) + this.$server.socket.off('item_removed', this.itemRemoved) + // this.$server.socket.off('audiobooks_updated', this.audiobooksUpdated) + // this.$server.socket.off('audiobooks_added', this.audiobooksAdded) } } }, @@ -382,6 +380,7 @@ export default { console.log('Syncing on default mount') this.connected(true) } + this.$server.on('logout', this.userLoggedOut) this.$server.on('connected', this.connected) this.$server.on('connectionFailed', this.socketConnectionFailed) diff --git a/mixins/bookshelfCardsHelpers.js b/mixins/bookshelfCardsHelpers.js index 0a66cc7a..811a6153 100644 --- a/mixins/bookshelfCardsHelpers.js +++ b/mixins/bookshelfCardsHelpers.js @@ -28,16 +28,7 @@ export default { if (this.entityComponentRefs[index]) { var bookComponent = this.entityComponentRefs[index] shelfEl.appendChild(bookComponent.$el) - if (this.isSelectionMode) { - bookComponent.setSelectionMode(true) - if (this.selectedAudiobooks.includes(bookComponent.audiobookId) || this.isSelectAll) { - bookComponent.selected = true - } else { - bookComponent.selected = false - } - } else { - bookComponent.setSelectionMode(false) - } + bookComponent.setSelectionMode(false) bookComponent.isHovering = false return } @@ -78,12 +69,6 @@ export default { if (this.entities[index]) { instance.setEntity(this.entities[index]) } - if (this.isSelectionMode) { - instance.setSelectionMode(true) - if (instance.audiobookId && this.selectedAudiobooks.includes(instance.audiobookId) || this.isSelectAll) { - instance.selected = true - } - } }, } } \ No newline at end of file diff --git a/pages/bookshelf/index.vue b/pages/bookshelf/index.vue index 3cf91544..3f2d6a59 100644 --- a/pages/bookshelf/index.vue +++ b/pages/bookshelf/index.vue @@ -122,7 +122,7 @@ export default { methods: { async fetchCategories() { var categories = await this.$axios - .$get(`/api/libraries/${this.currentLibraryId}/categories`) + .$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`) .then((data) => { return data }) @@ -131,6 +131,7 @@ export default { return [] }) this.shelves = categories + console.log('Shelves', this.shelves) }, async socketInit(isConnected) { if (isConnected && this.currentLibraryId) { diff --git a/pages/connect.vue b/pages/connect.vue index 89be45a9..94e7b209 100644 --- a/pages/connect.vue +++ b/pages/connect.vue @@ -6,9 +6,9 @@
-

Audiobookshelf

+

audiobookshelf

- +

Important! This app requires that you are running your own server and does not provide any content.

@@ -18,7 +18,7 @@

Connecting socket..

-

Enter an Audiobookshelf
server address:

+

Server address

diff --git a/pages/item/_id.vue b/pages/item/_id.vue new file mode 100644 index 00000000..a2728566 --- /dev/null +++ b/pages/item/_id.vue @@ -0,0 +1,445 @@ + + + \ No newline at end of file diff --git a/store/globals.js b/store/globals.js new file mode 100644 index 00000000..0e8b924e --- /dev/null +++ b/store/globals.js @@ -0,0 +1,32 @@ +export const state = () => ({ + +}) + +export const getters = { + getLibraryItemCoverSrc: (state, getters, rootState, rootGetters) => (libraryItem, placeholder = '/book_placeholder.jpg') => { + if (!libraryItem) return placeholder + var media = libraryItem.media + if (!media || !media.coverPath || media.coverPath === placeholder) return placeholder + + // Absolute URL covers (should no longer be used) + if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath + + var userToken = rootGetters['user/getToken'] + var lastUpdate = libraryItem.updatedAt || Date.now() + + if (process.env.NODE_ENV !== 'production') { // Testing + // return `http://localhost:3333/api/items/${libraryItem.id}/cover?token=${userToken}&ts=${lastUpdate}` + } + + var url = new URL(`/api/items/${libraryItem.id}/cover`, rootState.serverUrl) + return `${url}?token=${userToken}&ts=${lastUpdate}` + } +} + +export const actions = { + +} + +export const mutations = { + +} \ No newline at end of file diff --git a/store/index.js b/store/index.js index 310efab1..0eba01d5 100644 --- a/store/index.js +++ b/store/index.js @@ -2,6 +2,7 @@ import Vue from 'vue' import { Network } from '@capacitor/network' export const state = () => ({ + streamLibraryItem: null, streamAudiobook: null, playingDownload: null, playOnLoad: false, @@ -80,6 +81,9 @@ export const mutations = { setPlayOnLoad(state, val) { state.playOnLoad = val }, + setLibraryItemStream(state, libraryItem) { + state.streamLibraryItem = libraryItem + }, setStreamAudiobook(state, audiobook) { if (audiobook) { state.playingDownload = null @@ -111,12 +115,6 @@ export const mutations = { state.networkConnected = val.connected state.networkConnectionType = val.connectionType }, - setStreamListener(state, val) { - state.streamListener = val - }, - removeStreamListener(state) { - state.streamListener = null - }, openReader(state, audiobook) { state.selectedBook = audiobook state.showReader = true diff --git a/store/user.js b/store/user.js index 01430f86..d632e288 100644 --- a/store/user.js +++ b/store/user.js @@ -20,6 +20,10 @@ export const getters = { getToken: (state) => { return state.user ? state.user.token : null }, + getUserLibraryItemProgress: (state) => (libraryItemId) => { + if (!state.user.libraryItemProgress) return null + return state.user.libraryItemProgress.find(li => li.id == libraryItemId) + }, getUserAudiobookData: (state, getters) => (audiobookId) => { return getters.getUserAudiobook(audiobookId) }, From 461733854a87fc91984992dd453c1eb16e2357dc Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 24 Mar 2022 19:28:26 -0500 Subject: [PATCH 02/29] New data model in android: Adding jackson for JSON deserialization, adding data classes, setting up API caller --- android/app/build.gradle | 3 + .../com/audiobookshelf/app/MyNativeAudio.kt | 35 +++ .../app/PlayerNotificationService.kt | 31 ++- .../audiobookshelf/app/data/DataClasses.kt | 211 ++++++++++++++++++ .../audiobookshelf/app/server/ApiHandler.kt | 124 ++++++++++ components/app/AudioPlayerContainer.vue | 6 + pages/account.vue | 12 + 7 files changed, 416 insertions(+), 6 deletions(-) create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt diff --git a/android/app/build.gradle b/android/app/build.gradle index c4fd591e..1531b342 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -85,6 +85,9 @@ dependencies { // OK HTTP implementation 'com.squareup.okhttp3:okhttp:4.9.2' + + // Jackson for JSON + implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.1' } apply from: 'capacitor.build.gradle' diff --git a/android/app/src/main/java/com/audiobookshelf/app/MyNativeAudio.kt b/android/app/src/main/java/com/audiobookshelf/app/MyNativeAudio.kt index 2da64dbd..8eb7fd2a 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/MyNativeAudio.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/MyNativeAudio.kt @@ -5,7 +5,9 @@ import android.os.Handler import android.os.Looper import android.util.Log import androidx.core.content.ContextCompat +import com.audiobookshelf.app.server.ApiHandler import com.capacitorjs.plugins.app.AppPlugin +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.getcapacitor.* import com.getcapacitor.annotation.CapacitorPlugin import org.json.JSONObject @@ -15,10 +17,12 @@ class MyNativeAudio : Plugin() { private val tag = "MyNativeAudio" lateinit var mainActivity:MainActivity + lateinit var apiHandler:ApiHandler lateinit var playerNotificationService: PlayerNotificationService override fun load() { mainActivity = (activity as MainActivity) + apiHandler = ApiHandler(mainActivity) var foregroundServiceReady : () -> Unit = { playerNotificationService = mainActivity.foregroundService @@ -59,6 +63,37 @@ class MyNativeAudio : Plugin() { notifyListeners(evtName, ret) } + @PluginMethod + fun prepareLibraryItem(call: PluginCall) { + var libraryItemId = call.getString("libraryItemId", "").toString() + var mediaEntityId = call.getString("mediaEntityId", "").toString() + + apiHandler.playLibraryItem(libraryItemId) { + + Handler(Looper.getMainLooper()).post() { + Log.d(tag, "Preparing Player TEST ${jacksonObjectMapper().writeValueAsString(it)}") + playerNotificationService.preparePlayer(it) + } + + call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(it))) + } + } + + @PluginMethod + fun getLibraryItems(call: PluginCall) { + var libraryId = call.getString("libraryId", "").toString() + apiHandler.getLibraryItems(libraryId) { + val mapper = jacksonObjectMapper() + var jsobj = JSObject() + var libarray = JSArray() + it.map { + libarray.put(JSObject(mapper.writeValueAsString(it))) + } + jsobj.put("value", libarray) + call.resolve(jsobj) + } + } + @PluginMethod fun initPlayer(call: PluginCall) { if (!PlayerNotificationService.isStarted) { diff --git a/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt index 4b83dc56..c4b590b0 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt @@ -24,6 +24,7 @@ import androidx.media.MediaBrowserServiceCompat import androidx.media.utils.MediaConstants import com.anggrayudi.storage.file.isExternalStorageDocument import com.audiobookshelf.app.data.DbManager +import com.audiobookshelf.app.data.PlaybackSession import com.getcapacitor.Bridge import com.getcapacitor.JSObject import com.google.android.exoplayer2.* @@ -85,6 +86,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private var channelName = "Audiobookshelf Channel" private var currentAudiobookStreamData:AudiobookStreamData? = null + private var currentPlaybackSession:PlaybackSession? = null private var mediaButtonClickCount: Int = 0 var mediaButtonClickTimeout: Long = 1000 //ms @@ -376,11 +378,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) { override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat { var builder = MediaDescriptionCompat.Builder() - .setMediaId(currentAudiobookStreamData!!.id) - .setTitle(currentAudiobookStreamData!!.title) - .setSubtitle(currentAudiobookStreamData!!.author) - .setMediaUri(currentAudiobookStreamData!!.playlistUri) - .setIconUri(currentAudiobookStreamData!!.coverUri) + .setMediaId(currentPlaybackSession!!.id) + .setTitle(currentPlaybackSession!!.getTitle()) + .setSubtitle(currentPlaybackSession!!.getAuthor()) +// .setMediaUri(currentPlaybackSession!!.getContentUri()) +// .setIconUri(currentAudiobookStreamData!!.) return builder.build() } } @@ -600,7 +602,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { if (currentPlayer.playbackState == Player.STATE_READY) { Log.d(tag, "STATE_READY : " + mPlayer.duration.toString()) - currentAudiobookStreamData!!.hasPlayerLoaded = true +// currentAudiobookStreamData!!.hasPlayerLoaded = true if (lastPauseTime == 0L) { sendClientMetadata("ready_no_sync") lastPauseTime = -1; @@ -664,6 +666,23 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { /* User callable methods */ + fun preparePlayer(playbackSession: PlaybackSession) { + currentPlaybackSession = playbackSession + var metadata = playbackSession.getMediaMetadataCompat() + mediaSession.setMetadata(metadata) + var mediaMetadata = playbackSession.getMediaMetadata() + var mediaUrl = playbackSession.getContentUri() + var mimeType = playbackSession.getMimeType() + Log.d(tag, "Media URL $mediaUrl") + var mediaUri = Uri.parse(mediaUrl) + var mediaItem = MediaItem.Builder().setUri(mediaUri).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build() + var dataSourceFactory = DefaultDataSourceFactory(ctx, channelId) + var mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) + mPlayer.setMediaSource(mediaSource, 0L) + mPlayer.prepare() + mPlayer.playWhenReady = true + } + fun initPlayer(audiobookStreamData: AudiobookStreamData) { currentAudiobookStreamData = audiobookStreamData diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt new file mode 100644 index 00000000..db9c643b --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt @@ -0,0 +1,211 @@ +package com.audiobookshelf.app.data + +import android.net.Uri +import android.support.v4.media.MediaMetadataCompat +import com.fasterxml.jackson.annotation.* +import com.google.android.exoplayer2.MediaMetadata + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryItem( + var id:String, + var ino:String, + var libraryId:String, + var folderId:String, + var path:String, + var relPath:String, + var mtimeMs:Long, + var ctimeMs:Long, + var birthtimeMs:Long, + var addedAt:Long, + var updatedAt:Long, + var lastScan:Long?, + var scanVersion:String?, + var isMissing:Boolean, + var isInvalid:Boolean, + var mediaType:String, + var media:MediaEntity, + var libraryFiles:MutableList +) + +// This auto-detects whether it is a Book or Podcast +@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION) +@JsonSubTypes( + JsonSubTypes.Type(Book::class), + JsonSubTypes.Type(Podcast::class) +) +open class MediaEntity {} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Podcast( + var metadata:PodcastMetadata, + var coverPath:String?, + var tags:MutableList, + var episodes:MutableList, + var autoDownloadEpisodes:Boolean +) : MediaEntity() + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Book( + var metadata:BookMetadata, + var coverPath:String?, + var tags:MutableList, + var audiobooks:MutableList +) : MediaEntity() + +// This auto-detects whether it is a Book or Podcast +@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION) +@JsonSubTypes( + JsonSubTypes.Type(BookMetadata::class), + JsonSubTypes.Type(PodcastMetadata::class) +) +open class MediaEntityMetadata {} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class BookMetadata( + var title:String, + var subtitle:String?, + var authors:MutableList +) : MediaEntityMetadata() + +@JsonIgnoreProperties(ignoreUnknown = true) +data class PodcastMetadata( + var title:String, + var author:String?, + var feedUrl:String, + var genres:MutableList +) : MediaEntityMetadata() + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Author( + var id:String, + var name:String, + var coverPath:String? +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Audiobook( + var id:String, + var index:Int, + var name:String, + var audioFiles:MutableList +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class PodcastEpisode( + var id:String, + var index:Int, + var episode:String?, + var episodeType:String?, + var title:String?, + var subtitle:String?, + var description:String?, + var audioFile:AudioFile +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryFile( + var ino:String, + var metadata:FileMetadata +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class FileMetadata( + var filename:String, + var ext:String, + var path:String, + var relPath:String +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AudioFile( + var index:Int, + var ino:String, + var metadata:FileMetadata +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Library( + var id:String, + var name:String, + var folders:MutableList, + var icon:String, + var mediaType:String +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Folder( + var id:String, + var fullPath:String +) + +@JsonIgnoreProperties(ignoreUnknown = true) +class PlaybackSession( + var id:String, + var userId:String, + var libraryItemId:String, + var mediaEntityId:String, + var mediaType:String, + var mediaMetadata:MediaEntityMetadata, + var duration:Double, + var playMethod:Int, + var audioTracks:MutableList, + var currentTime:Double, + var serverUrl:String, + var token:String +) { + fun getTitle():String { + var metadata = mediaMetadata as BookMetadata + return metadata.title + } + fun getAuthor():String { + var metadata = mediaMetadata as BookMetadata + return metadata.authors.joinToString(",") { it.name } + } + fun getContentUri():String { + // TODO: Using Uri.parse here is throwing error with jackson + var audioTrack = audioTracks[0] + return "$serverUrl${audioTrack.contentUrl}?token=$token" + } + fun getMimeType():String { + var audioTrack = audioTracks[0] + return audioTrack.mimeType + } + fun getMediaMetadataCompat(): MediaMetadataCompat { + var metadata = mediaMetadata as BookMetadata + + var metadataBuilder = MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, metadata.title) + .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, metadata.title) + .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, metadata.authors.joinToString(",") { it.name }) + .putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, metadata.authors.joinToString(",") { it.name }) + .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, metadata.authors.joinToString(",") { it.name }) + .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "series") + .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) + return metadataBuilder.build() + } + fun getMediaMetadata(): MediaMetadata { + var metadata = mediaMetadata as BookMetadata + var authorName = metadata.authors.joinToString(",") { it.name } + var metadataBuilder = MediaMetadata.Builder() + .setTitle(metadata.title) + .setDisplayTitle(metadata.title) + .setArtist(authorName) + .setAlbumArtist(authorName) + .setSubtitle(authorName) + +// var contentUri = this.getContentUri() +// metadataBuilder.setMediaUri(contentUri) + + return metadataBuilder.build() + } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AudioTrack( + var index:Int, + var startOffset:Double, + var duration:Double, + var title:String, + var contentUrl:String, + var mimeType:String +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt new file mode 100644 index 00000000..94c2d8e2 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -0,0 +1,124 @@ +package com.audiobookshelf.app.server + +import android.app.Activity +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import com.audiobookshelf.app.data.Library +import com.audiobookshelf.app.data.LibraryItem +import com.audiobookshelf.app.data.PlaybackSession +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException + +class ApiHandler { + val tag = "ApiHandler" + private var client = OkHttpClient() + var ctx: Context + var serverUrl = "" + var token = "" + var storageSharedPreferences: SharedPreferences? = null + + constructor(_ctx: Context) { + ctx = _ctx + init() + } + + fun init() { + storageSharedPreferences = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE) + serverUrl = storageSharedPreferences?.getString("serverUrl", "").toString() + Log.d(tag, "SHARED PREF SERVERURL $serverUrl") + token = storageSharedPreferences?.getString("token", "").toString() + Log.d(tag, "SHARED PREF TOKEN $token") + } + + fun getRequest(endpoint:String, cb: (JSObject) -> Unit) { + val request = Request.Builder() + .url("$serverUrl$endpoint").addHeader("Authorization", "Bearer $token") + .build() + makeRequest(request, cb) + } + + fun postRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) { + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = payload.toString().toRequestBody(mediaType) + val request = Request.Builder().post(requestBody) + .url("$serverUrl$endpoint").addHeader("Authorization", "Bearer $token") + .build() + makeRequest(request, cb) + } + + fun makeRequest(request:Request, cb: (JSObject) -> Unit) { + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.d(tag, "FAILURE TO CONNECT") + e.printStackTrace() + cb(JSObject()) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (!response.isSuccessful) throw IOException("Unexpected code $response") + var bodyString = response.body!!.string() + var jsonObj = JSObject() + if (bodyString.startsWith("[")) { + var array = JSArray(bodyString) + jsonObj.put("value", array) + } else { + jsonObj = JSObject(bodyString) + } + cb(jsonObj) + } + } + }) + } + + fun getLibraries(cb: (List) -> Unit) { + val mapper = jacksonObjectMapper() + getRequest("/api/libraries") { + val libraries = mutableListOf() + if (it.has("value")) { + var array = it.getJSONArray("value")!! + for (i in 0 until array.length()) { + val library = mapper.readValue(array.get(i).toString()) + libraries.add(library) + } + } + cb(libraries) + } + } + + fun getLibraryItems(libraryId:String, cb: (List) -> Unit) { + val mapper = jacksonObjectMapper() + getRequest("/api/libraries/$libraryId/items") { + val items = mutableListOf() + if (it.has("results")) { + var array = it.getJSONArray("results") + for (i in 0 until array.length()) { + val item = mapper.readValue(array.get(i).toString()) + items.add(item) + } + } + cb(items) + } + } + + fun playLibraryItem(libraryItemId:String, cb: (PlaybackSession) -> Unit) { + val mapper = jacksonObjectMapper() + var payload = JSObject() + payload.put("mediaPlayer", "exo-player") + payload.put("forceDirectPlay", true) + + postRequest("/api/items/$libraryItemId/play", payload) { + it.put("serverUrl", serverUrl) + it.put("token", token) + val playbackSession = mapper.readValue(it.toString()) + cb(playbackSession) + } + } +} diff --git a/components/app/AudioPlayerContainer.vue b/components/app/AudioPlayerContainer.vue index 4da0eb85..f4f0565c 100644 --- a/components/app/AudioPlayerContainer.vue +++ b/components/app/AudioPlayerContainer.vue @@ -479,6 +479,12 @@ export default { this.$store.commit('setLibraryItemStream', libraryItem) // TODO: Call load library item in native + console.log('TEST prepare library item', libraryItemId) + MyNativeAudio.prepareLibraryItem({ libraryItemId }).then((data) => { + console.log('TEST library item play response', JSON.stringify(data)) + }).catch((error) => { + console.error('TEST failed', error) + }) } }, mounted() { diff --git a/pages/account.vue b/pages/account.vue index 25e5d011..89e41382 100644 --- a/pages/account.vue +++ b/pages/account.vue @@ -26,12 +26,16 @@ Open app store + + Test Call +

UA: {{ updateAvailability }} | Avail: {{ availableVersion }} | Curr: {{ currentVersion }} | ImmedAllowed: {{ immediateUpdateAllowed }}

\ No newline at end of file diff --git a/pages/localMedia/folders/index.vue b/pages/localMedia/folders/index.vue new file mode 100644 index 00000000..28c973e2 --- /dev/null +++ b/pages/localMedia/folders/index.vue @@ -0,0 +1,278 @@ + + + \ No newline at end of file diff --git a/plugins/db.js b/plugins/db.js index cc7c0c30..05dc3a48 100644 --- a/plugins/db.js +++ b/plugins/db.js @@ -22,6 +22,29 @@ class DbService { return null }) } + + loadFolders() { + return DbManager.localFoldersFromWebView().then((data) => { + console.log('Loaded local folders', JSON.stringify(data)) + if (data.folders && typeof data.folders == 'string') { + return JSON.parse(data.folders) + } + return data.folders + }).catch((error) => { + console.error('Failed to load', error) + return null + }) + } + + loadLocalMediaItemsInFolder(folderId) { + return DbManager.loadMediaItemsInFolder({ folderId }).then((data) => { + console.log('Loaded local media items in folder', JSON.stringify(data)) + if (data.localMediaItems && typeof data.localMediaItems == 'string') { + return JSON.parse(data.localMediaItems) + } + return data.localMediaItems + }) + } } export default ({ app, store }, inject) => { From 94b9dbb8b3fef8b12d2aab4bdb48b36ddfb47e36 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 1 Apr 2022 18:33:40 -0500 Subject: [PATCH 06/29] Media folder management page, android media folder scanner --- .../com/audiobookshelf/app/StorageManager.kt | 77 ++----- .../audiobookshelf/app/data/DataClasses.kt | 1 + .../com/audiobookshelf/app/data/DbManager.kt | 83 +++++-- .../app/data/FolderScanResult.kt | 11 +- .../app/device/DeviceManager.kt | 5 + .../app/device/FolderScanner.kt | 160 +++++++++++--- components/ui/Checkbox.vue | 71 ++++++ components/ui/Dropdown.vue | 5 +- components/ui/IconBtn.vue | 80 +++++++ pages/localMedia/folders/_id.vue | 83 +++++-- pages/localMedia/folders/index.vue | 206 +----------------- plugins/db.js | 13 +- 12 files changed, 456 insertions(+), 339 deletions(-) create mode 100644 components/ui/Checkbox.vue create mode 100644 components/ui/IconBtn.vue diff --git a/android/app/src/main/java/com/audiobookshelf/app/StorageManager.kt b/android/app/src/main/java/com/audiobookshelf/app/StorageManager.kt index 0f6d5cbc..7fa4d2b4 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/StorageManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/StorageManager.kt @@ -65,8 +65,9 @@ class StorageManager : Plugin() { var absolutePath = folder.getAbsolutePath(activity) var storageType = folder.getStorageType(activity) var simplePath = folder.getSimplePath(activity) + var folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT) - var localFolder = LocalFolder(folder.id, folder.name, folder.uri.toString(),absolutePath, simplePath, storageType.toString(), mediaType) + var localFolder = LocalFolder(folderId, folder.name, folder.uri.toString(),absolutePath, simplePath, storageType.toString(), mediaType) DeviceManager.dbManager.saveLocalFolder(localFolder) call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(localFolder))) @@ -127,13 +128,15 @@ class StorageManager : Plugin() { } @PluginMethod - fun searchFolder(call: PluginCall) { + fun scanFolder(call: PluginCall) { var folderId = call.data.getString("folderId", "").toString() - var folder: LocalFolder? = DeviceManager.dbManager.loadLocalFolder(folderId) + var forceAudioProbe = call.data.getBoolean("forceAudioProbe") + Log.d(TAG, "Scan Folder $folderId | Force Audio Probe $forceAudioProbe") + + var folder: LocalFolder? = DeviceManager.dbManager.getLocalFolder(folderId) folder?.let { - Log.d(TAG, "Searching folder ${it.contentUrl}") var folderScanner = FolderScanner(context) - var folderScanResult = folderScanner.scanForMediaItems(it.contentUrl, it.mediaType) + var folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe) if (folderScanResult == null) { Log.d(TAG, "NO Scan DATA") return call.resolve(JSObject()) @@ -141,65 +144,15 @@ class StorageManager : Plugin() { Log.d(TAG, "Scan DATA ${jacksonObjectMapper().writeValueAsString(folderScanResult)}") return call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(folderScanResult))) } - } - Log.d(TAG, "Folder not found $folderId") - call.resolve(JSObject()) - -// -// var df: DocumentFile? = DocumentFileCompat.fromUri(context, Uri.parse(folderUrl)) -// -// if (df == null) { -// Log.e(TAG, "Folder Doc File Invalid $folderUrl") -// var jsobj = JSObject() -// jsobj.put("folders", JSArray()) -// jsobj.put("files", JSArray()) -// call.resolve(jsobj) -// return -// } -// -// Log.d(TAG, "Folder as DF ${df.isDirectory} | ${df.getSimplePath(context)} | ${df.getBasePath(context)} | ${df.name}") -// -// var mediaFolders = mutableListOf() -// var foldersFound = df.search(false, DocumentFileType.FOLDER) -// -// foldersFound.forEach { -// Log.d(TAG, "Iterating over Folder Found ${it.name} | ${it.getSimplePath(context)} | URI: ${it.uri}") -// var folderName = it.name ?: "" -// var mediaFiles = mutableListOf() -// -// var filesInFolder = it.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) -// filesInFolder.forEach { it2 -> -// var mimeType = it2?.mimeType ?: "" -// var filename = it2?.name ?: "" -// var isAudio = mimeType.startsWith("audio") -// Log.d(TAG, "Found $mimeType file $filename in folder $folderName") -// var imageFile = MediaFile(it2.uri, filename, it2.getSimplePath(context), it2.length(), mimeType, isAudio) -// mediaFiles.add(imageFile) -// } -// if (mediaFiles.size > 0) { -// mediaFolders.add(MediaFolder(it.uri, folderName, it.getSimplePath(context), mediaFiles)) -// } -// } -// -// // Files in root dir -// var rootMediaFiles = mutableListOf() -// var mediaFilesFound:List = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) -// mediaFilesFound.forEach { -// Log.d(TAG, "Folder Root File Found ${it.name} | ${it.getSimplePath(context)} | URI: ${it.uri} | ${it.mimeType}") -// var mimeType = it?.mimeType ?: "" -// var filename = it?.name ?: "" -// var isAudio = mimeType.startsWith("audio") -// Log.d(TAG, "Found $mimeType file $filename in root folder") -// var imageFile = MediaFile(it.uri, filename, it.getSimplePath(context), it.length(), mimeType, isAudio) -// rootMediaFiles.add(imageFile) -// } -// -// var jsobj = JSObject() -// jsobj.put("folders", mediaFolders.map{ it.toJSObject() }) -// jsobj.put("files", rootMediaFiles.map{ it.toJSObject() }) -// call.resolve(jsobj) + } ?: call.resolve(JSObject()) } + @PluginMethod + fun removeFolder(call: PluginCall) { + var folderId = call.data.getString("folderId", "").toString() + DeviceManager.dbManager.removeLocalFolder(folderId) + call.resolve() + } @PluginMethod fun delete(call: PluginCall) { diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt index fc8593f3..d871c107 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt @@ -151,5 +151,6 @@ data class AudioTrack( var contentUrl:String, var mimeType:String, var isLocal:Boolean, + var localFileId:String?, var audioProbeResult:AudioProbeResult? ) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt index fd04bf07..181320bc 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt @@ -8,6 +8,9 @@ import com.getcapacitor.PluginCall import com.getcapacitor.PluginMethod import com.getcapacitor.annotation.CapacitorPlugin import io.paperdb.Paper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import org.json.JSONObject @CapacitorPlugin(name = "DbManager") @@ -33,13 +36,26 @@ class DbManager : Plugin() { return localMediaItems } + fun getLocalMediaItemsInFolder(folderId:String):List { + var localMediaItems = loadLocalMediaItems() + return localMediaItems.filter { + it.folderId == folderId + } + } + fun loadLocalMediaItem(localMediaItemId:String):LocalMediaItem? { return Paper.book("localMediaItems").read(localMediaItemId) } + fun removeLocalMediaItem(localMediaItemId:String) { + Paper.book("localMediaItems").delete(localMediaItemId) + } + fun saveLocalMediaItems(localMediaItems:List) { - localMediaItems.map { - Paper.book("localMediaItems").write(it.id, it) + GlobalScope.launch(Dispatchers.IO) { + localMediaItems.map { + Paper.book("localMediaItems").write(it.id, it) + } } } @@ -47,7 +63,7 @@ class DbManager : Plugin() { Paper.book("localFolders").write(localFolder.id,localFolder) } - fun loadLocalFolder(folderId:String):LocalFolder? { + fun getLocalFolder(folderId:String):LocalFolder? { return Paper.book("localFolders").read(folderId) } @@ -62,6 +78,14 @@ class DbManager : Plugin() { return localFolders } + fun removeLocalFolder(folderId:String) { + var localMediaItems = getLocalMediaItemsInFolder(folderId) + localMediaItems.forEach { + Paper.book("localMediaItems").delete(it.id) + } + Paper.book("localFolders").delete(folderId) + } + fun saveObject(db:String, key:String, value:JSONObject) { Log.d(tag, "Saving Object $key ${value.toString()}") Paper.book(db).write(key, value) @@ -78,13 +102,16 @@ class DbManager : Plugin() { var db = call.getString("db", "").toString() var key = call.getString("key", "").toString() var value = call.getObject("value") - if (db == "" || key == "" || value == null) { - Log.d(tag, "saveFromWebview Invalid key/value") - } else { - var json = value as JSONObject - saveObject(db, key, json) + + GlobalScope.launch(Dispatchers.IO) { + if (db == "" || key == "" || value == null) { + Log.d(tag, "saveFromWebview Invalid key/value") + } else { + var json = value as JSONObject + saveObject(db, key, json) + } + call.resolve() } - call.resolve() } @PluginMethod @@ -102,24 +129,36 @@ class DbManager : Plugin() { } @PluginMethod - fun localFoldersFromWebView(call:PluginCall) { - var folders = getAllLocalFolders() - var folderObjArray = jacksonObjectMapper().writeValueAsString(folders) - var jsobj = JSObject() - jsobj.put("folders", folderObjArray) - call.resolve(jsobj) + fun getLocalFolders_WV(call:PluginCall) { + GlobalScope.launch(Dispatchers.IO) { + var folders = getAllLocalFolders() + var folderObjArray = jacksonObjectMapper().writeValueAsString(folders) + var jsobj = JSObject() + jsobj.put("folders", folderObjArray) + call.resolve(jsobj) + } } @PluginMethod - fun loadMediaItemsInFolder(call:PluginCall) { + fun getLocalFolder_WV(call:PluginCall) { var folderId = call.getString("folderId", "").toString() - var localMediaItems = loadLocalMediaItems().filter { - it.folderId == folderId + GlobalScope.launch(Dispatchers.IO) { + getLocalFolder(folderId)?.let { + var folderObj = jacksonObjectMapper().writeValueAsString(it) + call.resolve(JSObject(folderObj)) + } ?: call.resolve() } + } - var mediaItemsArray = jacksonObjectMapper().writeValueAsString(localMediaItems) - var jsobj = JSObject() - jsobj.put("localMediaItems", mediaItemsArray) - call.resolve(jsobj) + @PluginMethod + fun getLocalMediaItemsInFolder_WV(call:PluginCall) { + var folderId = call.getString("folderId", "").toString() + GlobalScope.launch(Dispatchers.IO) { + var localMediaItems = getLocalMediaItemsInFolder(folderId) + var mediaItemsArray = jacksonObjectMapper().writeValueAsString(localMediaItems) + var jsobj = JSObject() + jsobj.put("localMediaItems", mediaItemsArray) + call.resolve(jsobj) + } } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/FolderScanResult.kt b/android/app/src/main/java/com/audiobookshelf/app/data/FolderScanResult.kt index 927f74cb..94f9e0a2 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/FolderScanResult.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/FolderScanResult.kt @@ -1,9 +1,10 @@ package com.audiobookshelf.app.data data class FolderScanResult( - val name:String?, - val absolutePath:String, - val mediaType:String, - val contentUrl:String, - val localMediaItems:MutableList, + var itemsAdded:Int, + var itemsUpdated:Int, + var itemsRemoved:Int, + var itemsUpToDate:Int, + val localFolder:LocalFolder, + val localMediaItems:List, ) diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt index cb240568..0dad8609 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt @@ -1,6 +1,7 @@ package com.audiobookshelf.app.device import android.util.Log +import com.anggrayudi.storage.file.id import com.audiobookshelf.app.data.DbManager import com.audiobookshelf.app.data.DeviceData import com.audiobookshelf.app.data.ServerConfig @@ -17,4 +18,8 @@ object DeviceManager { init { Log.d(tag, "Device Manager Singleton invoked") } + + fun getBase64Id(id:String):String { + return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.DEFAULT) + } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt index e5ffea44..5aea2ff3 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt @@ -5,7 +5,9 @@ import android.net.Uri import android.util.Log import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.file.* +import com.arthenica.ffmpegkit.FFmpegKitConfig import com.arthenica.ffmpegkit.FFprobeKit +import com.arthenica.ffmpegkit.Level import com.audiobookshelf.app.data.* import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue @@ -13,25 +15,55 @@ import com.fasterxml.jackson.module.kotlin.readValue class FolderScanner(var ctx: Context) { private val tag = "FolderScanner" - fun scanForMediaItems(folderUrl: String, mediaType:String):FolderScanResult? { - var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(folderUrl)) + // TODO: CLEAN this monster! Divide into bite-size methods + fun scanForMediaItems(localFolder:LocalFolder, forceAudioProbe:Boolean):FolderScanResult? { + FFmpegKitConfig.enableLogCallback { log -> + if (log.level != Level.AV_LOG_STDERR) { // STDERR is filled with junk + Log.d(tag, "FFmpeg-Kit Log: (${log.level}) ${log.message}") + } + } + + var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localFolder.contentUrl)) if (df == null) { - Log.e(tag, "Folder Doc File Invalid $folderUrl") + Log.e(tag, "Folder Doc File Invalid $localFolder.contentUrl") return null } - var folderName = df.name ?: "" - var folderPath = df.getAbsolutePath(ctx) - var folderUrl = df.uri.toString() - var folderId = df.id + var mediaItemsUpdated = 0 + var mediaItemsAdded = 0 + var mediaItemsRemoved = 0 + var mediaItemsUpToDate = 0 + + // Search for files in media item folder var foldersFound = df.search(false, DocumentFileType.FOLDER) + // Match folders found with media items already saved in db + var existingMediaItems = DeviceManager.dbManager.getLocalMediaItemsInFolder(localFolder.id) + + // Remove existing items no longer there + existingMediaItems = existingMediaItems.filter { lmi -> + var fileFound = foldersFound.find { f -> lmi.id == DeviceManager.getBase64Id(f.id) } + if (fileFound == null) { + Log.d(tag, "Existing media item is no longer in file system ${lmi.name}") + DeviceManager.dbManager.removeLocalMediaItem(lmi.id) + mediaItemsRemoved++ + } + fileFound != null + } + var mediaItems = mutableListOf() foldersFound.forEach { Log.d(tag, "Iterating over Folder Found ${it.name} | ${it.getSimplePath(ctx)} | URI: ${it.uri}") + var itemFolderName = it.name ?: "" + var itemId = DeviceManager.getBase64Id(it.id) + + var existingMediaItem = existingMediaItems.find { emi -> emi.id == itemId } + var existingLocalFiles = existingMediaItem?.localFiles ?: mutableListOf() + var existingAudioTracks = existingMediaItem?.audioTracks ?: mutableListOf() + var isNewOrUpdated = existingMediaItem == null var audioTracks = mutableListOf() var localFiles = mutableListOf() @@ -40,53 +72,119 @@ class FolderScanner(var ctx: Context) { var coverPath:String? = null var filesInFolder = it.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) - filesInFolder.forEach { it2 -> - var mimeType = it2?.mimeType ?: "" - var filename = it2?.name ?: "" + + var existingLocalFilesRemoved = existingLocalFiles.filter { elf -> + filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder + } + if (existingLocalFilesRemoved.isNotEmpty()) { + Log.d(tag, "${existingLocalFilesRemoved.size} Local files were removed from local media item ${existingMediaItem?.name}") + isNewOrUpdated = true + } + + filesInFolder.forEach { file -> + var mimeType = file?.mimeType ?: "" + var filename = file?.name ?: "" var isAudio = mimeType.startsWith("audio") Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName") - var localFile = LocalFile(it2.id,it2.name,it2.uri.toString(),it2.getAbsolutePath(ctx),it2.getSimplePath(ctx),it2.mimeType,it2.length()) + var localFileId = DeviceManager.getBase64Id(file.id) + + var localFile = LocalFile(localFileId,filename,file.uri.toString(),file.getAbsolutePath(ctx),file.getSimplePath(ctx),mimeType,file.length()) localFiles.add(localFile) - Log.d(tag, "File attributes Id:${it2.id}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${it2.isDownloadsDocument}") + Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}") if (isAudio) { - Log.d(tag, "Scanning Audio File Path ${localFile.absolutePath}") + var audioTrackToAdd:AudioTrack? = null - // TODO: Make asynchronous - var session = FFprobeKit.execute("-i \"${localFile.absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet") - var sessionData = session.output - Log.d(tag, "AFTER FFPROBE STRING $sessionData") + var existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId } + if (existingAudioTrack != null) { // Update existing audio track + if (existingAudioTrack.index != index) { + Log.d(tag, "Updating Audio track index from ${existingAudioTrack.index} to $index") + existingAudioTrack.index = index + isNewOrUpdated = true + } + if (existingAudioTrack.startOffset != startOffset) { + Log.d(tag, "Updating Audio track startOffset ${existingAudioTrack.startOffset} to $startOffset") + existingAudioTrack.startOffset = startOffset + isNewOrUpdated = true + } + } - val mapper = jacksonObjectMapper() - val audioProbeResult = mapper.readValue(sessionData) - Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}") + if (existingAudioTrack == null || forceAudioProbe) { + Log.d(tag, "Scanning Audio File Path ${localFile.absolutePath}") - var track = AudioTrack(index, startOffset, audioProbeResult.duration, filename, localFile.contentUrl, mimeType, true, audioProbeResult) - audioTracks.add(track) - startOffset += audioProbeResult.duration + // TODO: Make asynchronous + var session = FFprobeKit.execute("-i \"${localFile.absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet") + + val audioProbeResult = jacksonObjectMapper().readValue(session.output) + Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}") + + if (existingAudioTrack != null) { + // Update audio probe data on existing audio track + existingAudioTrack.audioProbeResult = audioProbeResult + audioTrackToAdd = existingAudioTrack + } else { + // Create new audio track + var track = AudioTrack(index, startOffset, audioProbeResult.duration, filename, localFile.contentUrl, mimeType, true, localFileId, audioProbeResult) + audioTrackToAdd = track + } + + startOffset += audioProbeResult.duration + index++ + isNewOrUpdated = true + } else { + audioTrackToAdd = existingAudioTrack + } + + startOffset += audioTrackToAdd.duration + index++ + audioTracks.add(audioTrackToAdd) } else { + var existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId } + + if (existingLocalFile == null) { + isNewOrUpdated = true + } + if (existingMediaItem != null && existingMediaItem.coverPath == null) { + // Existing media item did not have a cover - cover found on scan + isNewOrUpdated = true + } + // First image file use as cover path if (coverPath == null) { coverPath = localFile.absolutePath } } } - if (audioTracks.size > 0) { - Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks") - var localMediaItem = LocalMediaItem(it.id, itemFolderName, mediaType, folderId, it.uri.toString(), it.getSimplePath(ctx), it.getAbsolutePath(ctx),audioTracks,localFiles,coverPath) + + if (existingMediaItem != null && audioTracks.isEmpty()) { + Log.d(tag, "Local media item ${existingMediaItem.name} no longer has audio tracks - removing item") + DeviceManager.dbManager.removeLocalMediaItem(existingMediaItem.id) + mediaItemsRemoved++ + } else if (existingMediaItem != null && !isNewOrUpdated) { + Log.d(tag, "Local media item ${existingMediaItem.name} has no updates") + mediaItemsUpToDate++ + } else if (audioTracks.isNotEmpty()) { + if (existingMediaItem != null) mediaItemsUpdated++ + else mediaItemsAdded++ + + Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files") + var localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, it.uri.toString(), it.getSimplePath(ctx), it.getAbsolutePath(ctx),audioTracks,localFiles,coverPath) mediaItems.add(localMediaItem) } } - return if (mediaItems.size > 0) { - Log.d(tag, "Found ${mediaItems.size} Media Items") + Log.d(tag, "Folder $${localFolder.name} scan Results: $mediaItemsAdded Added | $mediaItemsUpdated Updated | $mediaItemsRemoved Removed | $mediaItemsUpToDate Up-to-date") + + return if (mediaItems.isNotEmpty()) { DeviceManager.dbManager.saveLocalMediaItems(mediaItems) - FolderScanResult(folderName, folderPath, mediaType, folderUrl, mediaItems) + + var folderMediaItems = DeviceManager.dbManager.getLocalMediaItemsInFolder(localFolder.id) // Get all local media items + FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, folderMediaItems) } else { - Log.d(tag, "No Media Items Found") - null + Log.d(tag, "No Media Items to save") + FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, mutableListOf()) } } } diff --git a/components/ui/Checkbox.vue b/components/ui/Checkbox.vue new file mode 100644 index 00000000..f2717041 --- /dev/null +++ b/components/ui/Checkbox.vue @@ -0,0 +1,71 @@ + + + \ No newline at end of file diff --git a/components/ui/Dropdown.vue b/components/ui/Dropdown.vue index c351ab42..431c9f4a 100644 --- a/components/ui/Dropdown.vue +++ b/components/ui/Dropdown.vue @@ -3,7 +3,7 @@

{{ label }}