mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Merge branch 'advplyr:master' into master
This commit is contained in:
		
						commit
						c0a4ec23d7
					
				| @ -158,7 +158,7 @@ export default { | ||||
|         } | ||||
|       }) | ||||
|       this.$axios | ||||
|         .patch(`/api/user/audiobooks`, updateProgressPayloads) | ||||
|         .patch(`/api/me/audiobook/batch/update`, updateProgressPayloads) | ||||
|         .then(() => { | ||||
|           this.$toast.success('Batch update success!') | ||||
|           this.$store.commit('setProcessingBatch', false) | ||||
| @ -177,7 +177,7 @@ export default { | ||||
|         this.processingBatchDelete = true | ||||
|         this.$store.commit('setProcessingBatch', true) | ||||
|         this.$axios | ||||
|           .$post(`/api/audiobooks/delete`, { | ||||
|           .$post(`/api/books/batch/delete`, { | ||||
|             audiobookIds: this.selectedAudiobooks | ||||
|           }) | ||||
|           .then(() => { | ||||
|  | ||||
| @ -162,7 +162,7 @@ export default { | ||||
|       } | ||||
|       this.isProcessingReadUpdate = true | ||||
|       this.$axios | ||||
|         .$patch(`/api/user/audiobook/${this.audiobookId}`, updatePayload) | ||||
|         .$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload) | ||||
|         .then(() => { | ||||
|           this.isProcessingReadUpdate = false | ||||
|           this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`) | ||||
|  | ||||
| @ -326,7 +326,7 @@ export default { | ||||
|       } | ||||
|       this.isProcessingReadUpdate = true | ||||
|       this.$axios | ||||
|         .$patch(`/api/user/audiobook/${this.audiobookId}`, updatePayload) | ||||
|         .$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload) | ||||
|         .then(() => { | ||||
|           this.isProcessingReadUpdate = false | ||||
|           this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`) | ||||
|  | ||||
| @ -131,7 +131,7 @@ export default { | ||||
|       } | ||||
|       this.isFetching = true | ||||
| 
 | ||||
|       var searchResults = await this.$axios.$get(`/api/library/${this.currentLibraryId}/search?q=${value}`).catch((error) => { | ||||
|       var searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}`).catch((error) => { | ||||
|         console.error('Search error', error) | ||||
|         return [] | ||||
|       }) | ||||
|  | ||||
| @ -171,7 +171,7 @@ export default { | ||||
|       this.processing = true | ||||
|       console.log('Calling update', account) | ||||
|       this.$axios | ||||
|         .$patch(`/api/user/${this.account.id}`, account) | ||||
|         .$patch(`/api/users/${this.account.id}`, account) | ||||
|         .then((data) => { | ||||
|           this.processing = false | ||||
|           if (data.error) { | ||||
| @ -198,7 +198,7 @@ export default { | ||||
|       var account = { ...this.newUser } | ||||
|       this.processing = true | ||||
|       this.$axios | ||||
|         .$post('/api/user', account) | ||||
|         .$post('/api/users', account) | ||||
|         .then((data) => { | ||||
|           this.processing = false | ||||
|           if (data.error) { | ||||
|  | ||||
| @ -94,7 +94,7 @@ export default { | ||||
|         this.processing = true | ||||
|         var collectionName = this.collectionName | ||||
|         this.$axios | ||||
|           .$delete(`/api/collection/${this.collection.id}`) | ||||
|           .$delete(`/api/collections/${this.collection.id}`) | ||||
|           .then(() => { | ||||
|             this.processing = false | ||||
|             this.show = false | ||||
| @ -122,7 +122,7 @@ export default { | ||||
|         description: this.newCollectionDescription || null | ||||
|       } | ||||
|       this.$axios | ||||
|         .$patch(`/api/collection/${this.collection.id}`, collectionUpdate) | ||||
|         .$patch(`/api/collections/${this.collection.id}`, collectionUpdate) | ||||
|         .then((collection) => { | ||||
|           console.log('Collection Updated', collection) | ||||
|           this.processing = false | ||||
|  | ||||
| @ -220,7 +220,7 @@ export default { | ||||
|     async fetchFull() { | ||||
|       try { | ||||
|         this.processing = true | ||||
|         this.audiobook = await this.$axios.$get(`/api/audiobook/${this.selectedAudiobookId}`) | ||||
|         this.audiobook = await this.$axios.$get(`/api/books/${this.selectedAudiobookId}`) | ||||
|         this.processing = false | ||||
|       } catch (error) { | ||||
|         console.error('Failed to fetch audiobook', this.selectedAudiobookId, error) | ||||
|  | ||||
| @ -96,7 +96,7 @@ export default { | ||||
|       this.processing = true | ||||
| 
 | ||||
|       this.$axios | ||||
|         .$delete(`/api/collection/${collection.id}/book/${this.selectedAudiobookId}`) | ||||
|         .$delete(`/api/collections/${collection.id}/book/${this.selectedAudiobookId}`) | ||||
|         .then((updatedCollection) => { | ||||
|           console.log(`Book removed from collection`, updatedCollection) | ||||
|           this.$toast.success('Book removed from collection') | ||||
| @ -114,7 +114,7 @@ export default { | ||||
|       this.processing = true | ||||
| 
 | ||||
|       this.$axios | ||||
|         .$post(`/api/collection/${collection.id}/book`, { id: this.selectedAudiobookId }) | ||||
|         .$post(`/api/collections/${collection.id}/book`, { id: this.selectedAudiobookId }) | ||||
|         .then((updatedCollection) => { | ||||
|           console.log(`Book added to collection`, updatedCollection) | ||||
|           this.$toast.success('Book added to collection') | ||||
|  | ||||
| @ -154,7 +154,7 @@ export default { | ||||
|         var coverPayload = { | ||||
|           url: updatePayload.cover | ||||
|         } | ||||
|         var success = await this.$axios.$post(`/api/audiobook/${this.audiobook.id}/cover`, coverPayload).catch((error) => { | ||||
|         var success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, coverPayload).catch((error) => { | ||||
|           console.error('Failed to update', error) | ||||
|           return false | ||||
|         }) | ||||
| @ -171,7 +171,7 @@ export default { | ||||
|         var bookUpdatePayload = { | ||||
|           book: updatePayload | ||||
|         } | ||||
|         var success = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, bookUpdatePayload).catch((error) => { | ||||
|         var success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, bookUpdatePayload).catch((error) => { | ||||
|           console.error('Failed to update', error) | ||||
|           return false | ||||
|         }) | ||||
|  | ||||
| @ -155,7 +155,7 @@ export default { | ||||
|       form.set('cover', this.selectedFile) | ||||
| 
 | ||||
|       this.$axios | ||||
|         .$post(`/api/audiobook/${this.audiobook.id}/cover`, form) | ||||
|         .$post(`/api/books/${this.audiobook.id}/cover`, form) | ||||
|         .then((data) => { | ||||
|           if (data.error) { | ||||
|             this.$toast.error(data.error) | ||||
| @ -217,7 +217,7 @@ export default { | ||||
| 
 | ||||
|       // Download cover from url and use | ||||
|       if (cover.startsWith('http:') || cover.startsWith('https:')) { | ||||
|         success = await this.$axios.$post(`/api/audiobook/${this.audiobook.id}/cover`, { url: cover }).catch((error) => { | ||||
|         success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, { url: cover }).catch((error) => { | ||||
|           console.error('Failed to download cover from url', error) | ||||
|           if (error.response && error.response.data) { | ||||
|             this.$toast.error(error.response.data) | ||||
| @ -231,7 +231,7 @@ export default { | ||||
|             cover: cover | ||||
|           } | ||||
|         } | ||||
|         success = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => { | ||||
|         success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, updatePayload).catch((error) => { | ||||
|           console.error('Failed to update', error) | ||||
|           if (error.response && error.response.data) { | ||||
|             this.$toast.error(error.response.data) | ||||
| @ -266,7 +266,7 @@ export default { | ||||
|     setCover(coverFile) { | ||||
|       this.isProcessing = true | ||||
|       this.$axios | ||||
|         .$patch(`/api/audiobook/${this.audiobook.id}/coverfile`, coverFile) | ||||
|         .$patch(`/api/books/${this.audiobook.id}/coverfile`, coverFile) | ||||
|         .then((data) => { | ||||
|           console.log('response data', data) | ||||
|           if (data && typeof data === 'string') { | ||||
|  | ||||
| @ -195,7 +195,7 @@ export default { | ||||
|         tags: this.newTags | ||||
|       } | ||||
| 
 | ||||
|       var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => { | ||||
|       var updatedAudiobook = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, updatePayload).catch((error) => { | ||||
|         console.error('Failed to update', error) | ||||
|         return false | ||||
|       }) | ||||
| @ -220,27 +220,11 @@ export default { | ||||
| 
 | ||||
|       this.newTags = this.audiobook.tags || [] | ||||
|     }, | ||||
|     resetProgress() { | ||||
|       if (confirm(`Are you sure you want to reset your progress?`)) { | ||||
|         this.resettingProgress = true | ||||
|         this.$axios | ||||
|           .$delete(`/api/user/audiobook/${this.audiobookId}`) | ||||
|           .then(() => { | ||||
|             console.log('Progress reset complete') | ||||
|             this.$toast.success(`Your progress was reset`) | ||||
|             this.resettingProgress = false | ||||
|           }) | ||||
|           .catch((error) => { | ||||
|             console.error('Progress reset failed', error) | ||||
|             this.resettingProgress = false | ||||
|           }) | ||||
|       } | ||||
|     }, | ||||
|     deleteAudiobook() { | ||||
|       if (confirm(`Are you sure you want to remove this audiobook?\n\n*Does not delete your files, only removes the audiobook from AudioBookshelf`)) { | ||||
|         this.isProcessing = true | ||||
|         this.$axios | ||||
|           .$delete(`/api/audiobook/${this.audiobookId}`) | ||||
|           .$delete(`/api/books/${this.audiobookId}`) | ||||
|           .then(() => { | ||||
|             console.log('Audiobook removed') | ||||
|             this.$toast.success('Audiobook Removed') | ||||
|  | ||||
| @ -133,7 +133,7 @@ export default { | ||||
|         publisher: true, | ||||
|         publishYear: true, | ||||
|         series: true, | ||||
|         volumeNumber: true, | ||||
|         volumeNumber: true | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
| @ -198,7 +198,7 @@ export default { | ||||
|         publisher: true, | ||||
|         publishYear: true, | ||||
|         series: true, | ||||
|         volumeNumber: true, | ||||
|         volumeNumber: true | ||||
|       } | ||||
| 
 | ||||
|       if (this.audiobook.id !== this.audiobookId) { | ||||
| @ -238,7 +238,7 @@ export default { | ||||
|         var coverPayload = { | ||||
|           url: updatePayload.cover | ||||
|         } | ||||
|         var success = await this.$axios.$post(`/api/audiobook/${this.audiobook.id}/cover`, coverPayload).catch((error) => { | ||||
|         var success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, coverPayload).catch((error) => { | ||||
|           console.error('Failed to update', error) | ||||
|           return false | ||||
|         }) | ||||
| @ -255,7 +255,7 @@ export default { | ||||
|         var bookUpdatePayload = { | ||||
|           book: updatePayload | ||||
|         } | ||||
|         var success = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, bookUpdatePayload).catch((error) => { | ||||
|         var success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, bookUpdatePayload).catch((error) => { | ||||
|           console.error('Failed to update', error) | ||||
|           return false | ||||
|         }) | ||||
|  | ||||
| @ -105,7 +105,7 @@ export default { | ||||
| 
 | ||||
|       this.$emit('update:processing', true) | ||||
|       this.$axios | ||||
|         .$patch(`/api/library/${this.library.id}`, newLibraryPayload) | ||||
|         .$patch(`/api/libraries/${this.library.id}`, newLibraryPayload) | ||||
|         .then((res) => { | ||||
|           this.$emit('update:processing', false) | ||||
|           this.$emit('close') | ||||
| @ -137,7 +137,7 @@ export default { | ||||
| 
 | ||||
|       this.$emit('update:processing', true) | ||||
|       this.$axios | ||||
|         .$post('/api/library', newLibraryPayload) | ||||
|         .$post('/api/libraries', newLibraryPayload) | ||||
|         .then((res) => { | ||||
|           this.$emit('update:processing', false) | ||||
|           this.$emit('close') | ||||
|  | ||||
| @ -72,7 +72,7 @@ export default { | ||||
|       if (confirm(`Are you sure you want to permanently delete library "${this.library.name}"?`)) { | ||||
|         this.isDeleting = true | ||||
|         this.$axios | ||||
|           .$delete(`/api/library/${this.library.id}`) | ||||
|           .$delete(`/api/libraries/${this.library.id}`) | ||||
|           .then((data) => { | ||||
|             this.isDeleting = false | ||||
|             if (data.error) { | ||||
|  | ||||
| @ -68,7 +68,7 @@ export default { | ||||
|         books: this.booksCopy.map((b) => b.id) | ||||
|       } | ||||
|       this.$axios | ||||
|         .$patch(`/api/collection/${this.collectionId}`, collectionUpdate) | ||||
|         .$patch(`/api/collections/${this.collectionId}`, collectionUpdate) | ||||
|         .then((collection) => { | ||||
|           console.log('Collection updated', collection) | ||||
|         }) | ||||
|  | ||||
| @ -101,7 +101,7 @@ export default { | ||||
|       if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) { | ||||
|         this.isDeletingUser = true | ||||
|         this.$axios | ||||
|           .$delete(`/api/user/${user.id}`) | ||||
|           .$delete(`/api/users/${user.id}`) | ||||
|           .then((data) => { | ||||
|             this.isDeletingUser = false | ||||
|             if (data.error) { | ||||
|  | ||||
| @ -140,7 +140,7 @@ export default { | ||||
|       } | ||||
|       this.isProcessingReadUpdate = true | ||||
|       this.$axios | ||||
|         .$patch(`/api/user/audiobook/${this.book.id}`, updatePayload) | ||||
|         .$patch(`/api/me/audiobook/${this.book.id}`, updatePayload) | ||||
|         .then(() => { | ||||
|           this.isProcessingReadUpdate = false | ||||
|           this.$toast.success(`"${this.bookTitle}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`) | ||||
| @ -155,7 +155,7 @@ export default { | ||||
|       this.processingRemove = true | ||||
| 
 | ||||
|       this.$axios | ||||
|         .$delete(`/api/collection/${this.collectionId}/book/${this.book.id}`) | ||||
|         .$delete(`/api/collections/${this.collectionId}/book/${this.book.id}`) | ||||
|         .then((updatedCollection) => { | ||||
|           console.log(`Book removed from collection`, updatedCollection) | ||||
|           this.$toast.success('Book removed from collection') | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "audiobookshelf-client", | ||||
|   "version": "1.6.23", | ||||
|   "version": "1.6.26", | ||||
|   "description": "Audiobook manager and player", | ||||
|   "main": "index.js", | ||||
|   "scripts": { | ||||
|  | ||||
| @ -90,7 +90,7 @@ export default { | ||||
|       } | ||||
|       this.changingPassword = true | ||||
|       this.$axios | ||||
|         .$patch('/api/user/password', { | ||||
|         .$patch('/api/me/password', { | ||||
|           password: this.password, | ||||
|           newPassword: this.newPassword | ||||
|         }) | ||||
|  | ||||
| @ -115,7 +115,7 @@ export default { | ||||
|     if (!store.getters['user/getUserCanUpdate']) { | ||||
|       return redirect('/?error=unauthorized') | ||||
|     } | ||||
|     var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => { | ||||
|     var audiobook = await app.$axios.$get(`/api/books/${params.id}`).catch((error) => { | ||||
|       console.error('Failed', error) | ||||
|       return false | ||||
|     }) | ||||
| @ -291,7 +291,7 @@ export default { | ||||
| 
 | ||||
|       this.saving = true | ||||
|       this.$axios | ||||
|         .$patch(`/api/audiobook/${this.audiobook.id}/tracks`, { orderedFileData }) | ||||
|         .$patch(`/api/books/${this.audiobook.id}/tracks`, { orderedFileData }) | ||||
|         .then((data) => { | ||||
|           console.log('Finished patching files', data) | ||||
|           this.saving = false | ||||
|  | ||||
| @ -161,7 +161,7 @@ export default { | ||||
|     if (!store.state.user.user) { | ||||
|       return redirect(`/login?redirect=${route.path}`) | ||||
|     } | ||||
|     var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => { | ||||
|     var audiobook = await app.$axios.$get(`/api/books/${params.id}`).catch((error) => { | ||||
|       console.error('Failed', error) | ||||
|       return false | ||||
|     }) | ||||
| @ -383,7 +383,7 @@ export default { | ||||
|       } | ||||
|       this.isProcessingReadUpdate = true | ||||
|       this.$axios | ||||
|         .$patch(`/api/user/audiobook/${this.audiobookId}`, updatePayload) | ||||
|         .$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload) | ||||
|         .then(() => { | ||||
|           this.isProcessingReadUpdate = false | ||||
|           this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`) | ||||
| @ -417,7 +417,7 @@ export default { | ||||
|     audiobookUpdated() { | ||||
|       console.log('Audiobook Updated - Fetch full audiobook') | ||||
|       this.$axios | ||||
|         .$get(`/api/audiobook/${this.audiobookId}`) | ||||
|         .$get(`/api/books/${this.audiobookId}`) | ||||
|         .then((audiobook) => { | ||||
|           console.log('Updated audiobook', audiobook) | ||||
|           this.audiobook = audiobook | ||||
| @ -430,7 +430,7 @@ export default { | ||||
|       if (confirm(`Are you sure you want to reset your progress?`)) { | ||||
|         this.resettingProgress = true | ||||
|         this.$axios | ||||
|           .$patch(`/api/user/audiobook/${this.audiobookId}/reset-progress`) | ||||
|           .$patch(`/api/me/audiobook/${this.audiobookId}/reset-progress`) | ||||
|           .then(() => { | ||||
|             console.log('Progress reset complete') | ||||
|             this.$toast.success(`Your progress was reset`) | ||||
|  | ||||
| @ -169,7 +169,7 @@ export default { | ||||
|       this.isProcessing = true | ||||
| 
 | ||||
|       this.$axios | ||||
|         .$post('/api/audiobooks/update', this.audiobookCopies) | ||||
|         .$post('/api/books/batch/update', this.audiobookCopies) | ||||
|         .then((data) => { | ||||
|           this.isProcessing = false | ||||
|           if (data.updates) { | ||||
|  | ||||
| @ -44,7 +44,7 @@ export default { | ||||
|     if (!store.state.user.user) { | ||||
|       return redirect(`/login?redirect=${route.path}`) | ||||
|     } | ||||
|     var collection = await app.$axios.$get(`/api/collection/${params.id}`).catch((error) => { | ||||
|     var collection = await app.$axios.$get(`/api/collections/${params.id}`).catch((error) => { | ||||
|       console.error('Failed', error) | ||||
|       return false | ||||
|     }) | ||||
| @ -105,7 +105,7 @@ export default { | ||||
|         this.processingRemove = true | ||||
|         var collectionName = this.collectionName | ||||
|         this.$axios | ||||
|           .$delete(`/api/collection/${this.collection.id}`) | ||||
|           .$delete(`/api/collections/${this.collection.id}`) | ||||
|           .then(() => { | ||||
|             this.processingRemove = false | ||||
|             this.$toast.success(`Collection "${collectionName}" Removed`) | ||||
|  | ||||
| @ -150,7 +150,7 @@ export default { | ||||
|       if (confirm('WARNING! This action will remove all audiobooks from the database including any updates or matches you have made. This does not do anything to your actual files. Shall we continue?')) { | ||||
|         this.isResettingAudiobooks = true | ||||
|         this.$axios | ||||
|           .$delete('/api/audiobooks') | ||||
|           .$delete('/api/books/all') | ||||
|           .then(() => { | ||||
|             this.isResettingAudiobooks = false | ||||
|             this.$toast.success('Successfully reset audiobooks') | ||||
|  | ||||
| @ -97,7 +97,7 @@ export default { | ||||
|   }, | ||||
|   methods: { | ||||
|     async init() { | ||||
|       this.listeningStats = await this.$axios.$get(`/api/user/${this.user.id}/listeningStats`).catch((err) => { | ||||
|       this.listeningStats = await this.$axios.$get(`/api/me/listening-stats`).catch((err) => { | ||||
|         console.error('Failed to load listening sesions', err) | ||||
|         return [] | ||||
|       }) | ||||
|  | ||||
| @ -71,7 +71,7 @@ | ||||
| <script> | ||||
| export default { | ||||
|   async asyncData({ params, redirect, app }) { | ||||
|     var user = await app.$axios.$get(`/api/user/${params.id}`).catch((error) => { | ||||
|     var user = await app.$axios.$get(`/api/users/${params.id}`).catch((error) => { | ||||
|       console.error('Failed to get user', error) | ||||
|       return null | ||||
|     }) | ||||
| @ -115,11 +115,11 @@ export default { | ||||
|   }, | ||||
|   methods: { | ||||
|     async init() { | ||||
|       this.listeningSessions = await this.$axios.$get(`/api/user/${this.user.id}/listeningSessions`).catch((err) => { | ||||
|       this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions`).catch((err) => { | ||||
|         console.error('Failed to load listening sesions', err) | ||||
|         return [] | ||||
|       }) | ||||
|       this.listeningStats = await this.$axios.$get(`/api/user/${this.user.id}/listeningStats`).catch((err) => { | ||||
|       this.listeningStats = await this.$axios.$get(`/api/users/${this.user.id}/listening-stats`).catch((err) => { | ||||
|         console.error('Failed to load listening sesions', err) | ||||
|         return [] | ||||
|       }) | ||||
|  | ||||
| @ -31,7 +31,7 @@ export default { | ||||
|     if (params.id === 'search' && query.query) { | ||||
|       searchQuery = query.query | ||||
| 
 | ||||
|       searchResults = await app.$axios.$get(`/api/library/${libraryId}/search?q=${searchQuery}`).catch((error) => { | ||||
|       searchResults = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${searchQuery}`).catch((error) => { | ||||
|         console.error('Search error', error) | ||||
|         return {} | ||||
|       }) | ||||
| @ -92,7 +92,7 @@ export default { | ||||
|   methods: { | ||||
|     async newQuery() { | ||||
|       var query = this.$route.query.query | ||||
|       this.searchResults = await this.$axios.$get(`/api/library/${this.libraryId}/search?q=${query}`).catch((error) => { | ||||
|       this.searchResults = await this.$axios.$get(`/api/libraries/${this.libraryId}/search?q=${query}`).catch((error) => { | ||||
|         console.error('Search error', error) | ||||
|         return {} | ||||
|       }) | ||||
|  | ||||
| @ -211,7 +211,7 @@ export const actions = { | ||||
|     commit('setLoadedLibrary', currentLibraryId) | ||||
| 
 | ||||
|     this.$axios | ||||
|       .$get(`/api/library/${currentLibraryId}/audiobooks`) | ||||
|       .$get(`/api/libraries/${currentLibraryId}/books`) | ||||
|       .then((data) => { | ||||
|         commit('set', data) | ||||
|         commit('setLastLoad') | ||||
|  | ||||
| @ -60,7 +60,7 @@ export const actions = { | ||||
|     } | ||||
| 
 | ||||
|     return this.$axios | ||||
|       .$get(`/api/library/${libraryId}`) | ||||
|       .$get(`/api/libraries/${libraryId}`) | ||||
|       .then((data) => { | ||||
|         commit('addUpdate', data) | ||||
|         commit('setCurrentLibrary', libraryId) | ||||
|  | ||||
| @ -64,7 +64,7 @@ export const actions = { | ||||
|     } | ||||
|     // Immediately update
 | ||||
|     commit('setSettings', updatePayload) | ||||
|     return this.$axios.$patch('/api/user/settings', updatePayload).then((result) => { | ||||
|     return this.$axios.$patch('/api/me/settings', updatePayload).then((result) => { | ||||
|       if (result.success) { | ||||
|         commit('setSettings', result.settings) | ||||
|         return true | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "audiobookshelf", | ||||
|   "version": "1.6.23", | ||||
|   "version": "1.6.26", | ||||
|   "description": "Self-hosted audiobook server for managing and playing audiobooks", | ||||
|   "main": "index.js", | ||||
|   "scripts": { | ||||
| @ -50,4 +50,4 @@ | ||||
|     "xml2js": "^0.4.23" | ||||
|   }, | ||||
|   "devDependencies": {} | ||||
| } | ||||
| } | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -355,9 +355,6 @@ class Scanner { | ||||
|       Logger.info(`[Scanner] Updated Audiobook "${existingAudiobook.title}" library and folder to "${libraryId}" "${folderId}"`) | ||||
|     } | ||||
| 
 | ||||
|     // var audiobooksInLibrary = this.audiobooks.filter(ab => ab.libraryId === libraryId)
 | ||||
|     // var existingAudiobook = audiobooksInLibrary.find(a => a.ino === audiobookData.ino)
 | ||||
| 
 | ||||
|     // inode value may change when using shared drives, update inode if matching path is found
 | ||||
|     // Note: inode will not change on rename
 | ||||
|     var hasUpdatedIno = false | ||||
| @ -457,7 +454,6 @@ class Scanner { | ||||
|     var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === libraryId) | ||||
| 
 | ||||
|     // TODO: This temporary fix from pre-release should be removed soon, "checkUpdateInos"
 | ||||
|     // TEMP - update ino for each audiobook
 | ||||
|     if (audiobooksInLibrary.length) { | ||||
|       for (let i = 0; i < audiobooksInLibrary.length; i++) { | ||||
|         var ab = audiobooksInLibrary[i] | ||||
| @ -466,7 +462,7 @@ class Scanner { | ||||
|         if (shouldUpdateIno) { | ||||
|           var filesWithMissingIno = ab.getFilesWithMissingIno() | ||||
| 
 | ||||
|           Logger.debug(`\n\Updating inos for "${ab.title}"`) | ||||
|           Logger.debug(`\nUpdating inos for "${ab.title}"`) | ||||
|           Logger.debug(`In Scan, Files with missing inode`, filesWithMissingIno) | ||||
| 
 | ||||
|           var hasUpdates = await ab.checkUpdateInos() | ||||
| @ -507,7 +503,7 @@ class Scanner { | ||||
|     // Check for removed audiobooks
 | ||||
|     for (let i = 0; i < audiobooksInLibrary.length; i++) { | ||||
|       var audiobook = audiobooksInLibrary[i] | ||||
|       var dataFound = audiobookDataFound.find(abd => abd.ino === audiobook.ino) | ||||
|       var dataFound = audiobookDataFound.find(abd => abd.ino === audiobook.ino || comparePaths(abd.path, audiobook.path)) | ||||
|       if (!dataFound) { | ||||
|         Logger.info(`[Scanner] Audiobook "${audiobook.title}" is missing`) | ||||
|         audiobook.isMissing = true | ||||
|  | ||||
							
								
								
									
										31
									
								
								server/controllers/BackupController.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								server/controllers/BackupController.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,31 @@ | ||||
| const Logger = require('../Logger') | ||||
| 
 | ||||
| class BackupController { | ||||
|   constructor() { } | ||||
| 
 | ||||
|   async delete(req, res) { | ||||
|     if (!req.user.isRoot) { | ||||
|       Logger.error(`[ApiController] Non-Root user attempting to delete backup`, req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|     var backup = this.backupManager.backups.find(b => b.id === req.params.id) | ||||
|     if (!backup) { | ||||
|       return res.sendStatus(404) | ||||
|     } | ||||
|     await this.backupManager.removeBackup(backup) | ||||
|     res.json(this.backupManager.backups.map(b => b.toJSON())) | ||||
|   } | ||||
| 
 | ||||
|   async upload(req, res) { | ||||
|     if (!req.user.isRoot) { | ||||
|       Logger.error(`[ApiController] Non-Root user attempting to upload backup`, req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|     if (!req.files.file) { | ||||
|       Logger.error('[ApiController] Upload backup invalid') | ||||
|       return res.sendStatus(500) | ||||
|     } | ||||
|     this.backupManager.uploadBackup(req, res) | ||||
|   } | ||||
| } | ||||
| module.exports = new BackupController() | ||||
							
								
								
									
										220
									
								
								server/controllers/BookController.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										220
									
								
								server/controllers/BookController.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,220 @@ | ||||
| const Logger = require('../Logger') | ||||
| 
 | ||||
| class BookController { | ||||
|   constructor(db, emitter, clientEmitter, streamManager, coverController) { | ||||
|     this.db = db | ||||
|     this.emitter = emitter | ||||
|     this.clientEmitter = clientEmitter | ||||
|     this.streamManager = streamManager | ||||
|     this.coverController = coverController | ||||
|   } | ||||
| 
 | ||||
|   findAll(req, res) { | ||||
|     var audiobooks = [] | ||||
|     if (req.query.q) { | ||||
|       audiobooks = this.db.audiobooks.filter(ab => { | ||||
|         return ab.isSearchMatch(req.query.q) | ||||
|       }).map(ab => ab.toJSONMinified()) | ||||
|     } else { | ||||
|       audiobooks = this.db.audiobooks.map(ab => ab.toJSONMinified()) | ||||
|     } | ||||
|     res.json(audiobooks) | ||||
|   } | ||||
| 
 | ||||
|   findOne(req, res) { | ||||
|     if (!req.user) { | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|     var audiobook = this.db.audiobooks.find(a => a.id === req.params.id) | ||||
|     if (!audiobook) return res.sendStatus(404) | ||||
| 
 | ||||
|     // Check user can access this audiobooks library
 | ||||
|     if (!req.user.checkCanAccessLibrary(audiobook.libraryId)) { | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
| 
 | ||||
|     res.json(audiobook.toJSONExpanded()) | ||||
|   } | ||||
| 
 | ||||
|   async update(req, res) { | ||||
|     if (!req.user.canUpdate) { | ||||
|       Logger.warn('User attempted to update without permission', req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|     var audiobook = this.db.audiobooks.find(a => a.id === req.params.id) | ||||
|     if (!audiobook) return res.sendStatus(404) | ||||
|     var hasUpdates = audiobook.update(req.body) | ||||
|     if (hasUpdates) { | ||||
|       await this.db.updateAudiobook(audiobook) | ||||
|     } | ||||
|     this.emitter('audiobook_updated', audiobook.toJSONMinified()) | ||||
|     res.json(audiobook.toJSON()) | ||||
|   } | ||||
| 
 | ||||
|   async delete(req, res) { | ||||
|     if (!req.user.canDelete) { | ||||
|       Logger.warn('User attempted to delete without permission', req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|     var audiobook = this.db.audiobooks.find(a => a.id === req.params.id) | ||||
|     if (!audiobook) return res.sendStatus(404) | ||||
| 
 | ||||
|     await this.handleDeleteAudiobook(audiobook) | ||||
|     res.sendStatus(200) | ||||
|   } | ||||
| 
 | ||||
|   // DELETE: api/books/all
 | ||||
|   async deleteAll(req, res) { | ||||
|     if (!req.user.isRoot) { | ||||
|       Logger.warn('User other than root attempted to delete all audiobooks', req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|     Logger.info('Removing all Audiobooks') | ||||
|     var success = await this.db.recreateAudiobookDb() | ||||
|     if (success) res.sendStatus(200) | ||||
|     else res.sendStatus(500) | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
|   // POST: api/books/batch/delete
 | ||||
|   async batchDelete(req, res) { | ||||
|     if (!req.user.canDelete) { | ||||
|       Logger.warn('User attempted to delete without permission', req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|     var { audiobookIds } = req.body | ||||
|     if (!audiobookIds || !audiobookIds.length) { | ||||
|       return res.sendStatus(500) | ||||
|     } | ||||
| 
 | ||||
|     var audiobooksToDelete = this.db.audiobooks.filter(ab => audiobookIds.includes(ab.id)) | ||||
|     if (!audiobooksToDelete.length) { | ||||
|       return res.sendStatus(404) | ||||
|     } | ||||
|     for (let i = 0; i < audiobooksToDelete.length; i++) { | ||||
|       Logger.info(`[ApiController] Deleting Audiobook "${audiobooksToDelete[i].title}"`) | ||||
|       await this.handleDeleteAudiobook(audiobooksToDelete[i]) | ||||
|     } | ||||
|     res.sendStatus(200) | ||||
|   } | ||||
| 
 | ||||
|   // POST: api/books/batch/update
 | ||||
|   async batchUpdate(req, res) { | ||||
|     if (!req.user.canUpdate) { | ||||
|       Logger.warn('User attempted to batch update without permission', req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|     var audiobooks = req.body | ||||
|     if (!audiobooks || !audiobooks.length) { | ||||
|       return res.sendStatus(500) | ||||
|     } | ||||
| 
 | ||||
|     var audiobooksUpdated = 0 | ||||
|     audiobooks = audiobooks.map((ab) => { | ||||
|       var _ab = this.db.audiobooks.find(__ab => __ab.id === ab.id) | ||||
|       if (!_ab) return null | ||||
|       var hasUpdated = _ab.update(ab) | ||||
|       if (!hasUpdated) return null | ||||
|       audiobooksUpdated++ | ||||
|       return _ab | ||||
|     }).filter(ab => ab) | ||||
| 
 | ||||
|     if (audiobooksUpdated) { | ||||
|       Logger.info(`[ApiController] ${audiobooksUpdated} Audiobooks have updates`) | ||||
|       for (let i = 0; i < audiobooks.length; i++) { | ||||
|         await this.db.updateAudiobook(audiobooks[i]) | ||||
|         this.emitter('audiobook_updated', audiobooks[i].toJSONMinified()) | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     res.json({ | ||||
|       success: true, | ||||
|       updates: audiobooksUpdated | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   // PATCH: api/books/:id/tracks
 | ||||
|   async updateTracks(req, res) { | ||||
|     if (!req.user.canUpdate) { | ||||
|       Logger.warn('User attempted to update audiotracks without permission', req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|     var audiobook = this.db.audiobooks.find(a => a.id === req.params.id) | ||||
|     if (!audiobook) return res.sendStatus(404) | ||||
|     var orderedFileData = req.body.orderedFileData | ||||
|     Logger.info(`Updating audiobook tracks called ${audiobook.id}`) | ||||
|     audiobook.updateAudioTracks(orderedFileData) | ||||
|     await this.db.updateAudiobook(audiobook) | ||||
|     this.emitter('audiobook_updated', audiobook.toJSONMinified()) | ||||
|     res.json(audiobook.toJSON()) | ||||
|   } | ||||
| 
 | ||||
|   // GET: api/books/:id/stream
 | ||||
|   openStream(req, res) { | ||||
|     var audiobook = this.db.audiobooks.find(a => a.id === req.params.id) | ||||
|     if (!audiobook) return res.sendStatus(404) | ||||
| 
 | ||||
|     this.streamManager.openStreamApiRequest(res, req.user, audiobook) | ||||
|   } | ||||
| 
 | ||||
|   // POST: api/books/:id/cover
 | ||||
|   async uploadCover(req, res) { | ||||
|     if (!req.user.canUpload || !req.user.canUpdate) { | ||||
|       Logger.warn('User attempted to upload a cover without permission', req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
| 
 | ||||
|     var audiobookId = req.params.id | ||||
|     var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId) | ||||
|     if (!audiobook) { | ||||
|       return res.status(404).send('Audiobook not found') | ||||
|     } | ||||
| 
 | ||||
|     var result = null | ||||
|     if (req.body && req.body.url) { | ||||
|       Logger.debug(`[ApiController] Requesting download cover from url "${req.body.url}"`) | ||||
|       result = await this.coverController.downloadCoverFromUrl(audiobook, req.body.url) | ||||
|     } else if (req.files && req.files.cover) { | ||||
|       Logger.debug(`[ApiController] Handling uploaded cover`) | ||||
|       var coverFile = req.files.cover | ||||
|       result = await this.coverController.uploadCover(audiobook, coverFile) | ||||
|     } else { | ||||
|       return res.status(400).send('Invalid request no file or url') | ||||
|     } | ||||
| 
 | ||||
|     if (result && result.error) { | ||||
|       return res.status(400).send(result.error) | ||||
|     } else if (!result || !result.cover) { | ||||
|       return res.status(500).send('Unknown error occurred') | ||||
|     } | ||||
| 
 | ||||
|     await this.db.updateAudiobook(audiobook) | ||||
|     this.emitter('audiobook_updated', audiobook.toJSONMinified()) | ||||
|     res.json({ | ||||
|       success: true, | ||||
|       cover: result.cover | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   // PATCH api/books/:id/coverfile
 | ||||
|   async updateCoverFromFile(req, res) { | ||||
|     if (!req.user.canUpdate) { | ||||
|       Logger.warn('User attempted to update without permission', req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|     var audiobook = this.db.audiobooks.find(a => a.id === req.params.id) | ||||
|     if (!audiobook) return res.sendStatus(404) | ||||
| 
 | ||||
|     var coverFile = req.body | ||||
|     var updated = await audiobook.setCoverFromFile(coverFile) | ||||
| 
 | ||||
|     if (updated) { | ||||
|       await this.db.updateAudiobook(audiobook) | ||||
|       this.emitter('audiobook_updated', audiobook.toJSONMinified()) | ||||
|     } | ||||
| 
 | ||||
|     if (updated) res.status(200).send('Cover updated successfully') | ||||
|     else res.status(200).send('No update was made to cover') | ||||
|   } | ||||
| } | ||||
| module.exports = new BookController() | ||||
							
								
								
									
										97
									
								
								server/controllers/CollectionController.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								server/controllers/CollectionController.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,97 @@ | ||||
| const Logger = require('../Logger') | ||||
| const UserCollection = require('../objects/UserCollection') | ||||
| 
 | ||||
| class CollectionController { | ||||
|   constructor() { } | ||||
| 
 | ||||
|   async create(req, res) { | ||||
|     var newCollection = new UserCollection() | ||||
|     req.body.userId = req.user.id | ||||
|     var success = newCollection.setData(req.body) | ||||
|     if (!success) { | ||||
|       return res.status(500).send('Invalid collection data') | ||||
|     } | ||||
|     var jsonExpanded = newCollection.toJSONExpanded(this.db.audiobooks) | ||||
|     await this.db.insertEntity('collection', newCollection) | ||||
|     this.clientEmitter(req.user.id, 'collection_added', jsonExpanded) | ||||
|     res.json(jsonExpanded) | ||||
|   } | ||||
| 
 | ||||
|   findAll(req, res) { | ||||
|     var collections = this.db.collections.filter(c => c.userId === req.user.id) | ||||
|     var expandedCollections = collections.map(c => c.toJSONExpanded(this.db.audiobooks)) | ||||
|     res.json(expandedCollections) | ||||
|   } | ||||
| 
 | ||||
|   findOne(req, res) { | ||||
|     var collection = this.db.collections.find(c => c.id === req.params.id) | ||||
|     if (!collection) { | ||||
|       return res.status(404).send('Collection not found') | ||||
|     } | ||||
|     res.json(collection.toJSONExpanded(this.db.audiobooks)) | ||||
|   } | ||||
| 
 | ||||
|   async update(req, res) { | ||||
|     var collection = this.db.collections.find(c => c.id === req.params.id) | ||||
|     if (!collection) { | ||||
|       return res.status(404).send('Collection not found') | ||||
|     } | ||||
|     var wasUpdated = collection.update(req.body) | ||||
|     var jsonExpanded = collection.toJSONExpanded(this.db.audiobooks) | ||||
|     if (wasUpdated) { | ||||
|       await this.db.updateEntity('collection', collection) | ||||
|       this.clientEmitter(req.user.id, 'collection_updated', jsonExpanded) | ||||
|     } | ||||
|     res.json(jsonExpanded) | ||||
|   } | ||||
| 
 | ||||
|   async delete(req, res) { | ||||
|     var collection = this.db.collections.find(c => c.id === req.params.id) | ||||
|     if (!collection) { | ||||
|       return res.status(404).send('Collection not found') | ||||
|     } | ||||
|     var jsonExpanded = collection.toJSONExpanded(this.db.audiobooks) | ||||
|     await this.db.removeEntity('collection', collection.id) | ||||
|     this.clientEmitter(req.user.id, 'collection_removed', jsonExpanded) | ||||
|     res.sendStatus(200) | ||||
|   } | ||||
| 
 | ||||
|   async addBook(req, res) { | ||||
|     var collection = this.db.collections.find(c => c.id === req.params.id) | ||||
|     if (!collection) { | ||||
|       return res.status(404).send('Collection not found') | ||||
|     } | ||||
|     var audiobook = this.db.audiobooks.find(ab => ab.id === req.body.id) | ||||
|     if (!audiobook) { | ||||
|       return res.status(500).send('Book not found') | ||||
|     } | ||||
|     if (audiobook.libraryId !== collection.libraryId) { | ||||
|       return res.status(500).send('Book in different library') | ||||
|     } | ||||
|     if (collection.books.includes(req.body.id)) { | ||||
|       return res.status(500).send('Book already in collection') | ||||
|     } | ||||
|     collection.addBook(req.body.id) | ||||
|     var jsonExpanded = collection.toJSONExpanded(this.db.audiobooks) | ||||
|     await this.db.updateEntity('collection', collection) | ||||
|     this.clientEmitter(req.user.id, 'collection_updated', jsonExpanded) | ||||
|     res.json(jsonExpanded) | ||||
|   } | ||||
| 
 | ||||
|   // DELETE: api/collections/:id/book/:bookId
 | ||||
|   async removeBook(req, res) { | ||||
|     var collection = this.db.collections.find(c => c.id === req.params.id) | ||||
|     if (!collection) { | ||||
|       return res.status(404).send('Collection not found') | ||||
|     } | ||||
| 
 | ||||
|     if (collection.books.includes(req.params.bookId)) { | ||||
|       collection.removeBook(req.params.bookId) | ||||
|       var jsonExpanded = collection.toJSONExpanded(this.db.audiobooks) | ||||
|       await this.db.updateEntity('collection', collection) | ||||
|       this.clientEmitter(req.user.id, 'collection_updated', jsonExpanded) | ||||
|     } | ||||
|     res.json(collection.toJSONExpanded(this.db.audiobooks)) | ||||
|   } | ||||
| } | ||||
| module.exports = new CollectionController() | ||||
							
								
								
									
										205
									
								
								server/controllers/LibraryController.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										205
									
								
								server/controllers/LibraryController.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,205 @@ | ||||
| const Logger = require('../Logger') | ||||
| const Library = require('../objects/Library') | ||||
| 
 | ||||
| class LibraryController { | ||||
|   constructor() { } | ||||
| 
 | ||||
|   async create(req, res) { | ||||
|     var newLibraryPayload = { | ||||
|       ...req.body | ||||
|     } | ||||
|     if (!newLibraryPayload.name || !newLibraryPayload.folders || !newLibraryPayload.folders.length) { | ||||
|       return res.status(500).send('Invalid request') | ||||
|     } | ||||
| 
 | ||||
|     var library = new Library() | ||||
|     newLibraryPayload.displayOrder = this.db.libraries.length + 1 | ||||
|     library.setData(newLibraryPayload) | ||||
|     await this.db.insertEntity('library', library) | ||||
|     this.emitter('library_added', library.toJSON()) | ||||
| 
 | ||||
|     // Add library watcher
 | ||||
|     this.watcher.addLibrary(library) | ||||
| 
 | ||||
|     res.json(library) | ||||
|   } | ||||
| 
 | ||||
|   findAll(req, res) { | ||||
|     res.json(this.db.libraries.map(lib => lib.toJSON())) | ||||
|   } | ||||
| 
 | ||||
|   findOne(req, res) { | ||||
|     if (!req.params.id) return res.status(500).send('Invalid id parameter') | ||||
| 
 | ||||
|     var library = this.db.libraries.find(lib => lib.id === req.params.id) | ||||
|     if (!library) { | ||||
|       return res.status(404).send('Library not found') | ||||
|     } | ||||
|     return res.json(library.toJSON()) | ||||
|   } | ||||
| 
 | ||||
|   async update(req, res) { | ||||
|     var library = this.db.libraries.find(lib => lib.id === req.params.id) | ||||
|     if (!library) { | ||||
|       return res.status(404).send('Library not found') | ||||
|     } | ||||
|     var hasUpdates = library.update(req.body) | ||||
|     if (hasUpdates) { | ||||
|       // Update watcher
 | ||||
|       this.watcher.updateLibrary(library) | ||||
| 
 | ||||
|       // Remove audiobooks no longer in library
 | ||||
|       var audiobooksToRemove = this.db.audiobooks.filter(ab => !library.checkFullPathInLibrary(ab.fullPath)) | ||||
|       if (audiobooksToRemove.length) { | ||||
|         Logger.info(`[Scanner] Updating library, removing ${audiobooksToRemove.length} audiobooks`) | ||||
|         for (let i = 0; i < audiobooksToRemove.length; i++) { | ||||
|           await this.handleDeleteAudiobook(audiobooksToRemove[i]) | ||||
|         } | ||||
|       } | ||||
|       await this.db.updateEntity('library', library) | ||||
|       this.emitter('library_updated', library.toJSON()) | ||||
|     } | ||||
|     return res.json(library.toJSON()) | ||||
|   } | ||||
| 
 | ||||
|   async delete(req, res) { | ||||
|     var library = this.db.libraries.find(lib => lib.id === req.params.id) | ||||
|     if (!library) { | ||||
|       return res.status(404).send('Library not found') | ||||
|     } | ||||
| 
 | ||||
|     // Remove library watcher
 | ||||
|     this.watcher.removeLibrary(library) | ||||
| 
 | ||||
|     // Remove audiobooks in this library
 | ||||
|     var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === library.id) | ||||
|     Logger.info(`[Server] deleting library "${library.name}" with ${audiobooks.length} audiobooks"`) | ||||
|     for (let i = 0; i < audiobooks.length; i++) { | ||||
|       await this.handleDeleteAudiobook(audiobooks[i]) | ||||
|     } | ||||
| 
 | ||||
|     var libraryJson = library.toJSON() | ||||
|     await this.db.removeEntity('library', library.id) | ||||
|     this.emitter('library_removed', libraryJson) | ||||
|     return res.json(libraryJson) | ||||
|   } | ||||
| 
 | ||||
|   // api/libraries/:id/books
 | ||||
|   getBooksForLibrary(req, res) { | ||||
|     var libraryId = req.params.id | ||||
|     var library = this.db.libraries.find(lib => lib.id === libraryId) | ||||
|     if (!library) { | ||||
|       return res.status(400).send('Library does not exist') | ||||
|     } | ||||
| 
 | ||||
|     var audiobooks = [] | ||||
|     if (req.query.q) { | ||||
|       audiobooks = this.db.audiobooks.filter(ab => { | ||||
|         return ab.libraryId === libraryId && ab.isSearchMatch(req.query.q) | ||||
|       }).map(ab => ab.toJSONMinified()) | ||||
|     } else { | ||||
|       audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId).map(ab => ab.toJSONMinified()) | ||||
|     } | ||||
|     res.json(audiobooks) | ||||
|   } | ||||
| 
 | ||||
|   // PATCH: Change the order of libraries
 | ||||
|   async reorder(req, res) { | ||||
|     if (!req.user.isRoot) { | ||||
|       Logger.error('[ApiController] ReorderLibraries invalid user', req.user) | ||||
|       return res.sendStatus(401) | ||||
|     } | ||||
| 
 | ||||
|     var orderdata = req.body | ||||
|     var hasUpdates = false | ||||
|     for (let i = 0; i < orderdata.length; i++) { | ||||
|       var library = this.db.libraries.find(lib => lib.id === orderdata[i].id) | ||||
|       if (!library) { | ||||
|         Logger.error(`[ApiController] Invalid library not found in reorder ${orderdata[i].id}`) | ||||
|         return res.sendStatus(500) | ||||
|       } | ||||
|       if (library.update({ displayOrder: orderdata[i].newOrder })) { | ||||
|         hasUpdates = true | ||||
|         await this.db.updateEntity('library', library) | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     if (hasUpdates) { | ||||
|       Logger.info(`[ApiController] Updated library display orders`) | ||||
|     } else { | ||||
|       Logger.info(`[ApiController] Library orders were up to date`) | ||||
|     } | ||||
| 
 | ||||
|     var libraries = this.db.libraries.map(lib => lib.toJSON()) | ||||
|     res.json(libraries) | ||||
|   } | ||||
| 
 | ||||
|   // GET: Global library search
 | ||||
|   search(req, res) { | ||||
|     var library = this.db.libraries.find(lib => lib.id === req.params.id) | ||||
|     if (!library) { | ||||
|       return res.status(404).send('Library not found') | ||||
|     } | ||||
|     if (!req.query.q) { | ||||
|       return res.status(400).send('No query string') | ||||
|     } | ||||
|     var maxResults = req.query.max || 3 | ||||
| 
 | ||||
|     var bookMatches = [] | ||||
|     var authorMatches = {} | ||||
|     var seriesMatches = {} | ||||
|     var tagMatches = {} | ||||
| 
 | ||||
|     var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === library.id) | ||||
|     audiobooksInLibrary.forEach((ab) => { | ||||
|       var queryResult = ab.searchQuery(req.query.q) | ||||
|       if (queryResult.book) { | ||||
|         var bookMatchObj = { | ||||
|           audiobook: ab, | ||||
|           matchKey: queryResult.book, | ||||
|           matchText: queryResult.bookMatchText | ||||
|         } | ||||
|         bookMatches.push(bookMatchObj) | ||||
|       } | ||||
|       if (queryResult.authors) { | ||||
|         queryResult.authors.forEach((author) => { | ||||
|           if (!authorMatches[author]) { | ||||
|             authorMatches[author] = { | ||||
|               author: author | ||||
|             } | ||||
|           } | ||||
|         }) | ||||
|       } | ||||
|       if (queryResult.series) { | ||||
|         if (!seriesMatches[queryResult.series]) { | ||||
|           seriesMatches[queryResult.series] = { | ||||
|             series: queryResult.series, | ||||
|             audiobooks: [ab] | ||||
|           } | ||||
|         } else { | ||||
|           seriesMatches[queryResult.series].audiobooks.push(ab) | ||||
|         } | ||||
|       } | ||||
|       if (queryResult.tags && queryResult.tags.length) { | ||||
|         queryResult.tags.forEach((tag) => { | ||||
|           if (!tagMatches[tag]) { | ||||
|             tagMatches[tag] = { | ||||
|               tag, | ||||
|               audiobooks: [ab] | ||||
|             } | ||||
|           } else { | ||||
|             tagMatches[tag].audiobooks.push(ab) | ||||
|           } | ||||
|         }) | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     res.json({ | ||||
|       audiobooks: bookMatches.slice(0, maxResults), | ||||
|       tags: Object.values(tagMatches).slice(0, maxResults), | ||||
|       authors: Object.values(authorMatches).slice(0, maxResults), | ||||
|       series: Object.values(seriesMatches).slice(0, maxResults) | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| module.exports = new LibraryController() | ||||
							
								
								
									
										96
									
								
								server/controllers/MeController.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								server/controllers/MeController.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,96 @@ | ||||
| const Logger = require('../Logger') | ||||
| const { isObject } = require('../utils/index') | ||||
| 
 | ||||
| class MeController { | ||||
|   constructor() { } | ||||
| 
 | ||||
|   // GET: api/me/listening-sessions
 | ||||
|   async getListeningSessions(req, res) { | ||||
|     var listeningSessions = await this.getUserListeningSessionsHelper(req.user.id) | ||||
|     res.json(listeningSessions.slice(0, 10)) | ||||
|   } | ||||
| 
 | ||||
|   // GET: api/me/listening-stats
 | ||||
|   async getListeningStats(req, res) { | ||||
|     var listeningStats = await this.getUserListeningStatsHelpers(req.user.id) | ||||
|     res.json(listeningStats) | ||||
|   } | ||||
| 
 | ||||
|   // PATCH: api/me/audiobook/:id/reset-progress
 | ||||
|   async resetAudiobookProgress(req, res) { | ||||
|     var audiobook = this.db.audiobooks.find(ab => ab.id === req.params.id) | ||||
|     if (!audiobook) { | ||||
|       return res.status(404).send('Audiobook not found') | ||||
|     } | ||||
|     req.user.resetAudiobookProgress(audiobook) | ||||
|     await this.db.updateEntity('user', req.user) | ||||
| 
 | ||||
|     var userAudiobookData = req.user.audiobooks[audiobook.id] | ||||
|     if (userAudiobookData) { | ||||
|       this.clientEmitter(req.user.id, 'current_user_audiobook_update', { id: audiobook.id, data: userAudiobookData }) | ||||
|     } | ||||
| 
 | ||||
|     this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) | ||||
|     res.sendStatus(200) | ||||
|   } | ||||
| 
 | ||||
|   // PATCH: api/me/audiobook/:id
 | ||||
|   async updateAudiobookData(req, res) { | ||||
|     var audiobook = this.db.audiobooks.find(ab => ab.id === req.params.id) | ||||
|     if (!audiobook) { | ||||
|       return res.status(404).send('Audiobook not found') | ||||
|     } | ||||
|     var wasUpdated = req.user.updateAudiobookData(audiobook, req.body) | ||||
|     if (wasUpdated) { | ||||
|       await this.db.updateEntity('user', req.user) | ||||
|       this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) | ||||
|     } | ||||
|     res.sendStatus(200) | ||||
|   } | ||||
| 
 | ||||
|   // PATCH: api/me/audiobook/batch/update
 | ||||
|   async batchUpdateAudiobookData(req, res) { | ||||
|     var userAbDataPayloads = req.body | ||||
|     if (!userAbDataPayloads || !userAbDataPayloads.length) { | ||||
|       return res.sendStatus(500) | ||||
|     } | ||||
| 
 | ||||
|     var shouldUpdate = false | ||||
|     userAbDataPayloads.forEach((userAbData) => { | ||||
|       var audiobook = this.db.audiobooks.find(ab => ab.id === userAbData.audiobookId) | ||||
|       if (audiobook) { | ||||
|         var wasUpdated = req.user.updateAudiobookData(audiobook, userAbData) | ||||
|         if (wasUpdated) shouldUpdate = true | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     if (shouldUpdate) { | ||||
|       await this.db.updateEntity('user', req.user) | ||||
|       this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) | ||||
|     } | ||||
| 
 | ||||
|     res.sendStatus(200) | ||||
|   } | ||||
| 
 | ||||
|   // PATCH: api/me/password
 | ||||
|   updatePassword(req, res) { | ||||
|     this.auth.userChangePassword(req, res) | ||||
|   } | ||||
| 
 | ||||
|   // PATCH: api/me/settings
 | ||||
|   async updateSettings(req, res) { | ||||
|     var settingsUpdate = req.body | ||||
|     if (!settingsUpdate || !isObject(settingsUpdate)) { | ||||
|       return res.sendStatus(500) | ||||
|     } | ||||
|     var madeUpdates = req.user.updateSettings(settingsUpdate) | ||||
|     if (madeUpdates) { | ||||
|       await this.db.updateEntity('user', req.user) | ||||
|     } | ||||
|     return res.json({ | ||||
|       success: true, | ||||
|       settings: req.user.settings | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| module.exports = new MeController() | ||||
							
								
								
									
										152
									
								
								server/controllers/UserController.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										152
									
								
								server/controllers/UserController.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,152 @@ | ||||
| const Logger = require('../Logger') | ||||
| const User = require('../objects/User') | ||||
| 
 | ||||
| const { getId } = require('../utils/index') | ||||
| 
 | ||||
| class UserController { | ||||
|   constructor() { } | ||||
| 
 | ||||
|   async create(req, res) { | ||||
|     if (!req.user.isRoot) { | ||||
|       Logger.warn('Non-root user attempted to create user', req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|     var account = req.body | ||||
| 
 | ||||
|     var username = account.username | ||||
|     var usernameExists = this.db.users.find(u => u.username.toLowerCase() === username.toLowerCase()) | ||||
|     if (usernameExists) { | ||||
|       return res.status(500).send('Username already taken') | ||||
|     } | ||||
| 
 | ||||
|     account.id = getId('usr') | ||||
|     account.pash = await this.auth.hashPass(account.password) | ||||
|     delete account.password | ||||
|     account.token = await this.auth.generateAccessToken({ userId: account.id }) | ||||
|     account.createdAt = Date.now() | ||||
|     var newUser = new User(account) | ||||
|     var success = await this.db.insertEntity('user', newUser) | ||||
|     if (success) { | ||||
|       this.clientEmitter(req.user.id, 'user_added', newUser) | ||||
|       res.json({ | ||||
|         user: newUser.toJSONForBrowser() | ||||
|       }) | ||||
|     } else { | ||||
|       return res.status(500).send('Failed to save new user') | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   findAll(req, res) { | ||||
|     if (!req.user.isRoot) return res.sendStatus(403) | ||||
|     var users = this.db.users.map(u => this.userJsonWithBookProgressDetails(u)) | ||||
|     res.json(users) | ||||
|   } | ||||
| 
 | ||||
|   findOne(req, res) { | ||||
|     if (!req.user.isRoot) { | ||||
|       Logger.error('User other than root attempting to get user', req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
| 
 | ||||
|     var user = this.db.users.find(u => u.id === req.params.id) | ||||
|     if (!user) { | ||||
|       return res.sendStatus(404) | ||||
|     } | ||||
| 
 | ||||
|     res.json(this.userJsonWithBookProgressDetails(user)) | ||||
|   } | ||||
| 
 | ||||
|   async update(req, res) { | ||||
|     if (!req.user.isRoot) { | ||||
|       Logger.error('User other than root attempting to update user', req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
| 
 | ||||
|     var user = this.db.users.find(u => u.id === req.params.id) | ||||
|     if (!user) { | ||||
|       return res.sendStatus(404) | ||||
|     } | ||||
| 
 | ||||
|     var account = req.body | ||||
| 
 | ||||
|     if (account.username !== undefined && account.username !== user.username) { | ||||
|       var usernameExists = this.db.users.find(u => u.username.toLowerCase() === account.username.toLowerCase()) | ||||
|       if (usernameExists) { | ||||
|         return res.status(500).send('Username already taken') | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // Updating password
 | ||||
|     if (account.password) { | ||||
|       account.pash = await this.auth.hashPass(account.password) | ||||
|       delete account.password | ||||
|     } | ||||
| 
 | ||||
|     var hasUpdated = user.update(account) | ||||
|     if (hasUpdated) { | ||||
|       await this.db.updateEntity('user', user) | ||||
|     } | ||||
| 
 | ||||
|     this.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser()) | ||||
|     res.json({ | ||||
|       success: true, | ||||
|       user: user.toJSONForBrowser() | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   async delete(req, res) { | ||||
|     if (!req.user.isRoot) { | ||||
|       Logger.error('User other than root attempting to delete user', req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|     if (req.params.id === 'root') { | ||||
|       return res.sendStatus(500) | ||||
|     } | ||||
|     if (req.user.id === req.params.id) { | ||||
|       Logger.error('Attempting to delete themselves...') | ||||
|       return res.sendStatus(500) | ||||
|     } | ||||
|     var user = this.db.users.find(u => u.id === req.params.id) | ||||
|     if (!user) { | ||||
|       Logger.error('User not found') | ||||
|       return res.json({ | ||||
|         error: 'User not found' | ||||
|       }) | ||||
|     } | ||||
| 
 | ||||
|     // delete user collections
 | ||||
|     var userCollections = this.db.collections.filter(c => c.userId === user.id) | ||||
|     var collectionsToRemove = userCollections.map(uc => uc.id) | ||||
|     for (let i = 0; i < collectionsToRemove.length; i++) { | ||||
|       await this.db.removeEntity('collection', collectionsToRemove[i]) | ||||
|     } | ||||
| 
 | ||||
|     // Todo: check if user is logged in and cancel streams
 | ||||
| 
 | ||||
|     var userJson = user.toJSONForBrowser() | ||||
|     await this.db.removeEntity('user', user.id) | ||||
|     this.clientEmitter(req.user.id, 'user_removed', userJson) | ||||
|     res.json({ | ||||
|       success: true | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   // GET: api/users/:id/listening-sessions
 | ||||
|   async getListeningSessions(req, res) { | ||||
|     if (!req.user.isRoot && req.user.id !== req.params.id) { | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|     var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id) | ||||
|     res.json(listeningSessions.slice(0, 10)) | ||||
|   } | ||||
| 
 | ||||
|   // GET: api/users/:id/listening-stats
 | ||||
|   async getListeningStats(req, res) { | ||||
|     if (!req.user.isRoot && req.user.id !== req.params.id) { | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|     var listeningStats = await this.getUserListeningStatsHelpers(req.params.id) | ||||
|     res.json(listeningStats) | ||||
|   } | ||||
| } | ||||
| module.exports = new UserController() | ||||
| @ -153,6 +153,17 @@ class AudioFile { | ||||
|     this.metadata.setData(data) | ||||
|   } | ||||
| 
 | ||||
|   // New scanner creates AudioFile from AudioFileScanner
 | ||||
|   setData2(fileData, probeData) { | ||||
|     this.index = fileData.index || null | ||||
|     this.ino = fileData.ino || null | ||||
|     this.filename = fileData.filename | ||||
|     this.ext = fileData.ext | ||||
|     this.path = fileData.path | ||||
|     this.fullPath = fileData.fullPath | ||||
|     this.addedAt = Date.now() | ||||
|   } | ||||
| 
 | ||||
|   syncChapters(updatedChapters) { | ||||
|     if (this.chapters.length !== updatedChapters.length) { | ||||
|       this.chapters = updatedChapters.map(ch => ({ ...ch })) | ||||
|  | ||||
| @ -353,6 +353,7 @@ class Audiobook { | ||||
|       if (imageFile) { | ||||
|         data.coverFullPath = imageFile.fullPath | ||||
|         var relImagePath = imageFile.path.replace(this.path, '') | ||||
|         console.log('SET BOOK PATH', imageFile.path, 'REPLACE', this.path, 'RESULT', relImagePath) | ||||
|         data.cover = Path.posix.join(`/s/book/${this.id}`, relImagePath) | ||||
|       } | ||||
|     } | ||||
| @ -822,5 +823,141 @@ class Audiobook { | ||||
|     var audioFile = this.audioFiles[0] | ||||
|     return this.book.setDetailsFromFileMetadata(audioFile.metadata) | ||||
|   } | ||||
| 
 | ||||
|   // Returns null if file not found, true if file was updated, false if up to date
 | ||||
|   checkFileFound(fileFound, isAudioFile) { | ||||
|     var hasUpdated = false | ||||
| 
 | ||||
|     const arrayToCheck = isAudioFile ? this.audioFiles : this.otherFiles | ||||
| 
 | ||||
|     var existingFile = arrayToCheck.find(_af => _af.ino === fileFound.ino) | ||||
|     if (!existingFile) { | ||||
|       existingFile = arrayToCheck.find(_af => _af.path === fileFound.path) | ||||
|       if (existingFile) { | ||||
|         // file inode was updated
 | ||||
|         existingFile.ino = fileFound.ino | ||||
|         hasUpdated = true | ||||
|       } else { | ||||
|         // file not found
 | ||||
|         return null | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     if (existingFile.filename !== fileFound.filename) { | ||||
|       existingFile.filename = fileFound.filename | ||||
|       existingFile.ext = fileFound.ext | ||||
|       hasUpdated = true | ||||
|     } | ||||
| 
 | ||||
|     if (existingFile.path !== fileFound.path) { | ||||
|       existingFile.path = fileFound.path | ||||
|       existingFile.fullPath = fileFound.fullPath | ||||
|       hasUpdated = true | ||||
|     } else if (existingFile.fullPath !== fileFound.fullPath) { | ||||
|       existingFile.fullPath = fileFound.fullPath | ||||
|       hasUpdated = true | ||||
|     } | ||||
| 
 | ||||
|     if (!isAudioFile && existingFile.filetype !== fileFound.filetype) { | ||||
|       existingFile.filetype = fileFound.filetype | ||||
|       hasUpdated = true | ||||
|     } | ||||
| 
 | ||||
|     return hasUpdated | ||||
|   } | ||||
| 
 | ||||
|   checkShouldScan(dataFound) { | ||||
|     var hasUpdated = false | ||||
| 
 | ||||
|     if (dataFound.ino !== this.ino) { | ||||
|       this.ino = dataFound.ino | ||||
|       hasUpdated = true | ||||
|     } | ||||
| 
 | ||||
|     if (dataFound.folderId !== this.folderId) { | ||||
|       Logger.warn(`[Audiobook] Check scan audiobook changed folder ${this.folderId} -> ${dataFound.folderId}`) | ||||
|       this.folderId = dataFound.folderId | ||||
|       hasUpdated = true | ||||
|     } | ||||
| 
 | ||||
|     if (dataFound.path !== this.path) { | ||||
|       Logger.warn(`[Audiobook] Check scan audiobook changed path "${this.path}" -> "${dataFound.path}"`) | ||||
|       this.path = dataFound.path | ||||
|       this.fullPath = dataFound.fullPath | ||||
|       hasUpdated = true | ||||
|     } else if (dataFound.fullPath !== this.fullPath) { | ||||
|       Logger.warn(`[Audiobook] Check scan audiobook changed fullpath "${this.fullPath}" -> "${dataFound.fullPath}"`) | ||||
|       this.fullPath = dataFound.fullPath | ||||
|       hasUpdated = true | ||||
|     } | ||||
| 
 | ||||
|     var newAudioFileData = [] | ||||
|     var newOtherFileData = [] | ||||
| 
 | ||||
|     dataFound.audioFiles.forEach((af) => { | ||||
|       var audioFileFoundCheck = this.checkFileFound(af, true) | ||||
|       if (audioFileFoundCheck === null) { | ||||
|         newAudioFileData.push(af) | ||||
|       } else if (audioFileFoundCheck === true) { | ||||
|         hasUpdated = true | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     dataFound.otherFiles.forEach((otherFileData) => { | ||||
|       var fileFoundCheck = this.checkFileFound(otherFileData, false) | ||||
|       if (fileFoundCheck === null) { | ||||
|         newOtherFileData.push(otherFileData) | ||||
|       } else if (fileFoundCheck === true) { | ||||
|         hasUpdated = true | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     const audioFilesRemoved = [] | ||||
|     const otherFilesRemoved = [] | ||||
| 
 | ||||
|     // inodes will all be up to date at this point
 | ||||
|     this.audioFiles = this.audioFiles.filter(af => { | ||||
|       if (!dataFound.audioFiles.find(_af => _af.ino === af.ino)) { | ||||
|         audioFilesRemoved.push(af.toJSON()) | ||||
|         return false | ||||
|       } | ||||
|       return true | ||||
|     }) | ||||
| 
 | ||||
|     // Remove all tracks that were associated with removed audio files
 | ||||
|     if (audioFilesRemoved.length) { | ||||
|       const audioFilesRemovedInodes = audioFilesRemoved.map(afr => afr.ino) | ||||
|       this.tracks = this.tracks.filter(t => !audioFilesRemovedInodes.includes(t.ino)) | ||||
|       this.checkUpdateMissingParts() | ||||
|       hasUpdated = true | ||||
|     } | ||||
| 
 | ||||
|     this.otherFiles = this.otherFiles.filter(otherFile => { | ||||
|       if (!dataFound.otherFiles.find(_otherFile => _otherFile.ino === otherFile.ino)) { | ||||
|         otherFilesRemoved.push(otherFile.toJSON()) | ||||
| 
 | ||||
|         // Check remove cover
 | ||||
|         if (otherFile.fullPath === this.book.coverFullPath) { | ||||
|           Logger.debug(`[Audiobook] "${this.title}" Check scan book cover removed`) | ||||
|           this.book.removeCover() | ||||
|         } | ||||
| 
 | ||||
|         return false | ||||
|       } | ||||
|       return true | ||||
|     }) | ||||
| 
 | ||||
|     if (otherFilesRemoved.length) { | ||||
|       hasUpdated = true | ||||
|     } | ||||
| 
 | ||||
|     return { | ||||
|       updated: hasUpdated, | ||||
|       newAudioFileData, | ||||
|       newOtherFileData, | ||||
|       audioFilesRemoved, | ||||
|       otherFilesRemoved | ||||
|     } | ||||
|   } | ||||
| } | ||||
| module.exports = Audiobook | ||||
							
								
								
									
										22
									
								
								server/scanner/AudioFileScanner.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								server/scanner/AudioFileScanner.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,22 @@ | ||||
| const AudioFile = require('../objects/AudioFile') | ||||
| const AudioProbeData = require('./AudioProbeData') | ||||
| 
 | ||||
| const prober = require('../utils/prober') | ||||
| const Logger = require('../Logger') | ||||
| 
 | ||||
| class AudioFileScanner { | ||||
|   constructor() { } | ||||
| 
 | ||||
|   async scan(audioFileData, verbose = false) { | ||||
|     var probeData = await prober.probe2(audioFileData.fullPath, verbose) | ||||
|     if (probeData.error) { | ||||
|       Logger.error(`[AudioFileScanner] ${probeData.error} : "${audioFileData.fullPath}"`) | ||||
|       return null | ||||
|     } | ||||
| 
 | ||||
|     var audioFile = new AudioFile() | ||||
|     // TODO: Build audio file
 | ||||
|     return audioFile | ||||
|   } | ||||
| } | ||||
| module.exports = new AudioFileScanner() | ||||
							
								
								
									
										74
									
								
								server/scanner/AudioProbeData.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								server/scanner/AudioProbeData.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,74 @@ | ||||
| const AudioFileMetadata = require('../objects/AudioFileMetadata') | ||||
| 
 | ||||
| class AudioProbeData { | ||||
|   constructor() { | ||||
|     this.embeddedCoverArt = null | ||||
|     this.format = null | ||||
|     this.duration = null | ||||
|     this.size = null | ||||
|     this.bitRate = null | ||||
|     this.codec = null | ||||
|     this.timeBase = null | ||||
|     this.language = null | ||||
|     this.channelLayout = null | ||||
|     this.channels = null | ||||
|     this.sampleRate = null | ||||
|     this.chapters = [] | ||||
| 
 | ||||
|     this.audioFileMetadata = null | ||||
| 
 | ||||
|     this.trackNumber = null | ||||
|     this.trackTotal = null | ||||
|   } | ||||
| 
 | ||||
|   getDefaultAudioStream(audioStreams) { | ||||
|     if (audioStreams.length === 1) return audioStreams[0] | ||||
|     var defaultStream = audioStreams.find(a => a.is_default) | ||||
|     if (!defaultStream) return audioStreams[0] | ||||
|     return defaultStream | ||||
|   } | ||||
| 
 | ||||
|   getEmbeddedCoverArt(videoStream) { | ||||
|     const ImageCodecs = ['mjpeg', 'jpeg', 'png'] | ||||
|     return ImageCodecs.includes(videoStream.codec) ? videoStream.codec : null | ||||
|   } | ||||
| 
 | ||||
|   setData(data) { | ||||
|     var audioStream = getDefaultAudioStream(data.audio_streams) | ||||
| 
 | ||||
|     this.embeddedCoverArt = data.video_stream ? this.getEmbeddedCoverArt(data.video_stream) : false | ||||
|     this.format = data.format | ||||
|     this.duration = data.duration | ||||
|     this.size = data.size | ||||
|     this.bitRate = audioStream.bit_rate || data.bit_rate | ||||
|     this.codec = audioStream.codec | ||||
|     this.timeBase = audioStream.time_base | ||||
|     this.language = audioStream.language | ||||
|     this.channelLayout = audioStream.channel_layout | ||||
|     this.channels = audioStream.channels | ||||
|     this.sampleRate = audioStream.sample_rate | ||||
|     this.chapters = data.chapters || [] | ||||
| 
 | ||||
|     var metatags = {} | ||||
|     for (const key in data) { | ||||
|       if (data[key] && key.startsWith('file_tag')) { | ||||
|         metatags[key] = data[key] | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     this.audioFileMetadata = new AudioFileMetadata() | ||||
|     this.audioFileMetadata.setData(metatags) | ||||
| 
 | ||||
|     // Track ID3 tag might be "3/10" or just "3"
 | ||||
|     if (this.audioFileMetadata.tagTrack) { | ||||
|       var trackParts = this.audioFileMetadata.tagTrack.split('/').map(part => Number(part)) | ||||
|       if (trackParts.length > 0) { | ||||
|         this.trackNumber = trackParts[0] | ||||
|       } | ||||
|       if (trackParts.length > 1) { | ||||
|         this.trackTotal = trackParts[1] | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| module.exports = AudioProbeData | ||||
							
								
								
									
										34
									
								
								server/scanner/LibraryScan.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								server/scanner/LibraryScan.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,34 @@ | ||||
| const Folder = require('../objects/Folder') | ||||
| 
 | ||||
| const { getId } = require('../utils/index') | ||||
| 
 | ||||
| class LibraryScan { | ||||
|   constructor() { | ||||
|     this.id = null | ||||
|     this.libraryId = null | ||||
|     this.libraryName = null | ||||
|     this.folders = null | ||||
| 
 | ||||
|     this.scanOptions = null | ||||
| 
 | ||||
|     this.startedAt = null | ||||
|     this.finishedAt = null | ||||
| 
 | ||||
|     this.folderScans = [] | ||||
|   } | ||||
| 
 | ||||
|   get _scanOptions() { return this.scanOptions || {} } | ||||
|   get forceRescan() { return !!this._scanOptions.forceRescan } | ||||
| 
 | ||||
|   setData(library, scanOptions) { | ||||
|     this.id = getId('lscan') | ||||
|     this.libraryId = library.id | ||||
|     this.libraryName = library.name | ||||
|     this.folders = library.folders.map(folder => Folder(folder.toJSON())) | ||||
| 
 | ||||
|     this.scanOptions = scanOptions | ||||
| 
 | ||||
|     this.startedAt = Date.now() | ||||
|   } | ||||
| } | ||||
| module.exports = LibraryScan | ||||
							
								
								
									
										68
									
								
								server/scanner/ScanOptions.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								server/scanner/ScanOptions.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,68 @@ | ||||
| const { CoverDestination } = require('../utils/constants') | ||||
| 
 | ||||
| class ScanOptions { | ||||
|   constructor(options) { | ||||
|     this.forceRescan = false | ||||
| 
 | ||||
|     this.metadataPrecedence = [ | ||||
|       { | ||||
|         id: 'directory', | ||||
|         include: true | ||||
|       }, | ||||
|       { | ||||
|         id: 'reader-desc-txt', | ||||
|         include: true | ||||
|       }, | ||||
|       { | ||||
|         id: 'audio-file-metadata', | ||||
|         include: true | ||||
|       }, | ||||
|       { | ||||
|         id: 'metadata-opf', | ||||
|         include: true | ||||
|       }, | ||||
|       { | ||||
|         id: 'external-source', | ||||
|         include: false | ||||
|       } | ||||
|     ] | ||||
| 
 | ||||
|     // Server settings
 | ||||
|     this.parseSubtitles = false | ||||
|     this.findCovers = false | ||||
|     this.coverDestination = CoverDestination.METADATA | ||||
| 
 | ||||
|     if (options) { | ||||
|       this.construct(options) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   construct(options) { | ||||
|     for (const key in options) { | ||||
|       if (key === 'metadataPrecedence' && options[key].length) { | ||||
|         this.metadataPrecedence = [...options[key]] | ||||
|       } else if (this[key] !== undefined) { | ||||
|         this[key] = options[key] | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   toJSON() { | ||||
|     return { | ||||
|       forceRescan: this.forceRescan, | ||||
|       metadataPrecedence: this.metadataPrecedence, | ||||
|       parseSubtitles: this.parseSubtitles, | ||||
|       findCovers: this.findCovers, | ||||
|       coverDestination: this.coverDestination | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   setData(options, serverSettings) { | ||||
|     this.forceRescan = !!options.forceRescan | ||||
| 
 | ||||
|     this.parseSubtitles = !!serverSettings.scannerParseSubtitle | ||||
|     this.findCovers = !!serverSettings.scannerFindCovers | ||||
|     this.coverDestination = serverSettings.coverDestination | ||||
|   } | ||||
| } | ||||
| module.exports = ScanOptions | ||||
							
								
								
									
										172
									
								
								server/scanner/Scanner.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								server/scanner/Scanner.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,172 @@ | ||||
| const fs = require('fs-extra') | ||||
| const Path = require('path') | ||||
| 
 | ||||
| // Utils
 | ||||
| const Logger = require('../Logger') | ||||
| const { version } = require('../../package.json') | ||||
| const audioFileScanner = require('../utils/audioFileScanner') | ||||
| const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('../utils/scandir') | ||||
| const { comparePaths, getIno, getId } = require('../utils/index') | ||||
| const { secondsToTimestamp } = require('../utils/fileUtils') | ||||
| const { ScanResult, CoverDestination } = require('../utils/constants') | ||||
| 
 | ||||
| const AudioFileScanner = require('./AudioFileScanner') | ||||
| const BookFinder = require('../BookFinder') | ||||
| const Audiobook = require('../objects/Audiobook') | ||||
| const LibraryScan = require('./LibraryScan') | ||||
| const ScanOptions = require('./ScanOptions') | ||||
| 
 | ||||
| class Scanner { | ||||
|   constructor(AUDIOBOOK_PATH, METADATA_PATH, db, coverController, emitter) { | ||||
|     this.AudiobookPath = AUDIOBOOK_PATH | ||||
|     this.MetadataPath = METADATA_PATH | ||||
|     this.BookMetadataPath = Path.posix.join(this.MetadataPath.replace(/\\/g, '/'), 'books') | ||||
| 
 | ||||
|     this.db = db | ||||
|     this.coverController = coverController | ||||
|     this.emitter = emitter | ||||
| 
 | ||||
|     this.cancelScan = false | ||||
|     this.cancelLibraryScan = {} | ||||
|     this.librariesScanning = [] | ||||
| 
 | ||||
|     this.bookFinder = new BookFinder() | ||||
|   } | ||||
| 
 | ||||
|   async scan(libraryId, options = {}) { | ||||
|     if (this.librariesScanning.includes(libraryId)) { | ||||
|       Logger.error(`[Scanner] Already scanning ${libraryId}`) | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     var library = this.db.libraries.find(lib => lib.id === libraryId) | ||||
|     if (!library) { | ||||
|       Logger.error(`[Scanner] Library not found for scan ${libraryId}`) | ||||
|       return | ||||
|     } else if (!library.folders.length) { | ||||
|       Logger.warn(`[Scanner] Library has no folders to scan "${library.name}"`) | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     var scanOptions = new ScanOptions() | ||||
|     scanOptions.setData(options, this.db.serverSettings) | ||||
| 
 | ||||
|     var libraryScan = new LibraryScan() | ||||
|     libraryScan.setData(library, scanOptions) | ||||
| 
 | ||||
|     Logger.info(`[Scanner] Starting library scan ${libraryScan.id} for ${libraryScan.libraryName}`) | ||||
| 
 | ||||
|     var results = await this.scanLibrary(libraryScan) | ||||
| 
 | ||||
|     Logger.info(`[Scanner] Library scan ${libraryScan.id} complete`) | ||||
| 
 | ||||
|     return results | ||||
|   } | ||||
| 
 | ||||
|   async scanLibrary(libraryScan) { | ||||
|     var audiobookDataFound = [] | ||||
|     for (let i = 0; i < libraryScan.folders.length; i++) { | ||||
|       var folder = libraryScan.folders[i] | ||||
|       var abDataFoundInFolder = await scanRootDir(folder, this.db.serverSettings) | ||||
|       Logger.debug(`[Scanner] ${abDataFoundInFolder.length} ab data found in folder "${folder.fullPath}"`) | ||||
|       audiobookDataFound = audiobookDataFound.concat(abDataFoundInFolder) | ||||
|     } | ||||
| 
 | ||||
|     // Remove audiobooks with no inode
 | ||||
|     audiobookDataFound = audiobookDataFound.filter(abd => abd.ino) | ||||
| 
 | ||||
|     var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === libraryScan.libraryId) | ||||
| 
 | ||||
|     const audiobooksToUpdate = [] | ||||
|     const audiobooksToRescan = [] | ||||
|     const newAudiobookData = [] | ||||
| 
 | ||||
|     // Check for existing & removed audiobooks
 | ||||
|     for (let i = 0; i < audiobooksInLibrary.length; i++) { | ||||
|       var audiobook = audiobooksInLibrary[i] | ||||
|       var dataFound = audiobookDataFound.find(abd => abd.ino === audiobook.ino || comparePaths(abd.path, audiobook.path)) | ||||
|       if (!dataFound) { | ||||
|         Logger.info(`[Scanner] Audiobook "${audiobook.title}" is missing`) | ||||
|         audiobook.isMissing = true | ||||
|         audiobook.lastUpdate = Date.now() | ||||
|         scanResults.missing++ | ||||
|         audiobooksToUpdate.push(audiobook) | ||||
|       } else { | ||||
|         var checkRes = audiobook.checkShouldRescan(dataFound) | ||||
|         if (checkRes.newAudioFileData.length || checkRes.newOtherFileData.length) { | ||||
|           // existing audiobook has new files
 | ||||
|           checkRes.audiobook = audiobook | ||||
|           audiobooksToRescan.push(checkRes) | ||||
|         } else if (checkRes.updated) { | ||||
|           audiobooksToUpdate.push(audiobook) | ||||
|         } | ||||
| 
 | ||||
|         // Remove this abf
 | ||||
|         audiobookDataFound = audiobookDataFound.filter(abf => abf.ino !== dataFound.ino) | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // Potential NEW Audiobooks
 | ||||
|     for (let i = 0; i < audiobookDataFound.length; i++) { | ||||
|       var dataFound = audiobookDataFound[i] | ||||
|       var hasEbook = dataFound.otherFiles.find(otherFile => otherFile.filetype === 'ebook') | ||||
|       if (!hasEbook && !dataFound.audioFiles.length) { | ||||
|         Logger.info(`[Scanner] Directory found "${audiobookDataFound.path}" has no ebook or audio files`) | ||||
|       } else { | ||||
|         newAudiobookData.push(dataFound) | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     var rescans = [] | ||||
|     for (let i = 0; i < audiobooksToRescan.length; i++) { | ||||
|       var rescan = this.rescanAudiobook(audiobooksToRescan[i]) | ||||
|       rescans.push(rescan) | ||||
|     } | ||||
|     var newscans = [] | ||||
|     for (let i = 0; i < newAudiobookData.length; i++) { | ||||
|       var newscan = this.scanNewAudiobook(newAudiobookData[i]) | ||||
|       newscans.push(newscan) | ||||
|     } | ||||
| 
 | ||||
|     var rescanResults = await Promise.all(rescans) | ||||
| 
 | ||||
|     var newscanResults = await Promise.all(newscans) | ||||
| 
 | ||||
|     // TODO: Return report
 | ||||
|     return { | ||||
|       updates: 0, | ||||
|       additions: 0 | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   // Return scan result payload
 | ||||
|   async rescanAudiobook(audiobookCheckData) { | ||||
|     const { newAudioFileData, newOtherFileData, audiobook } = audiobookCheckData | ||||
|     if (newAudioFileData.length) { | ||||
|       var newAudioFiles = await this.scanAudioFiles(newAudioFileData) | ||||
|       // TODO: Update audiobook tracks
 | ||||
|     } | ||||
|     if (newOtherFileData.length) { | ||||
|       // TODO: Check other files
 | ||||
|     } | ||||
| 
 | ||||
|     return { | ||||
|       updated: true | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   async scanNewAudiobook(audiobookData) { | ||||
|     // TODO: Return new audiobook
 | ||||
|     return null | ||||
|   } | ||||
| 
 | ||||
|   async scanAudioFiles(audioFileData) { | ||||
|     var proms = [] | ||||
|     for (let i = 0; i < audioFileData.length; i++) { | ||||
|       var prom = AudioFileScanner.scan(audioFileData[i]) | ||||
|       proms.push(prom) | ||||
|     } | ||||
|     return Promise.all(proms) | ||||
|   } | ||||
| } | ||||
| module.exports = Scanner | ||||
| @ -13,7 +13,7 @@ function getDefaultAudioStream(audioStreams) { | ||||
| 
 | ||||
| async function scan(path, verbose = false) { | ||||
|   Logger.debug(`Scanning path "${path}"`) | ||||
|   var probeData = await prober(path, verbose) | ||||
|   var probeData = await prober.probe(path, verbose) | ||||
|   if (!probeData || !probeData.audio_streams || !probeData.audio_streams.length) { | ||||
|     return { | ||||
|       error: 'Invalid audio file' | ||||
|  | ||||
| @ -89,10 +89,17 @@ function setFileOwner(path, uid, gid) { | ||||
| } | ||||
| module.exports.setFileOwner = setFileOwner | ||||
| 
 | ||||
| async function recurseFiles(path) { | ||||
| async function recurseFiles(path, relPathToReplace = null) { | ||||
|   path = path.replace(/\\/g, '/') | ||||
|   if (!path.endsWith('/')) path = path + '/' | ||||
| 
 | ||||
|   if (relPathToReplace) { | ||||
|     relPathToReplace = relPathToReplace.replace(/\\/g, '/') | ||||
|     if (!relPathToReplace.endsWith('/')) relPathToReplace += '/' | ||||
|   } else { | ||||
|     relPathToReplace = path | ||||
|   } | ||||
| 
 | ||||
|   const options = { | ||||
|     mode: rra.LIST, | ||||
|     recursive: true, | ||||
| @ -116,7 +123,7 @@ async function recurseFiles(path) { | ||||
|     } | ||||
| 
 | ||||
|     // Ignore any file if a directory or the filename starts with "."
 | ||||
|     var relpath = item.fullname.replace(path, '') | ||||
|     var relpath = item.fullname.replace(relPathToReplace, '') | ||||
|     var pathStartsWithPeriod = relpath.split('/').find(p => p.startsWith('.')) | ||||
|     if (pathStartsWithPeriod) { | ||||
|       Logger.debug(`[fileUtils] Ignoring path has . "${relpath}"`) | ||||
| @ -126,9 +133,9 @@ async function recurseFiles(path) { | ||||
|     return true | ||||
|   }).map((item) => ({ | ||||
|     name: item.name, | ||||
|     path: item.fullname.replace(path, ''), | ||||
|     path: item.fullname.replace(relPathToReplace, ''), | ||||
|     dirpath: item.path, | ||||
|     reldirpath: item.path.replace(path, ''), | ||||
|     reldirpath: item.path.replace(relPathToReplace, ''), | ||||
|     fullpath: item.fullname, | ||||
|     extension: item.extension, | ||||
|     deep: item.deep | ||||
|  | ||||
| @ -1,5 +1,8 @@ | ||||
| var Ffmpeg = require('fluent-ffmpeg') | ||||
| const Path = require('path') | ||||
| 
 | ||||
| const AudioProbeData = require('../scanner/AudioProbeData') | ||||
| 
 | ||||
| const Logger = require('../Logger') | ||||
| 
 | ||||
| function tryGrabBitRate(stream, all_streams, total_bit_rate) { | ||||
| @ -241,4 +244,31 @@ function probe(filepath, verbose = false) { | ||||
|     }) | ||||
|   }) | ||||
| } | ||||
| module.exports = probe | ||||
| module.exports.probe = probe | ||||
| 
 | ||||
| // Updated probe returns AudioProbeData object
 | ||||
| function probe2(filepath, verbose = false) { | ||||
|   return new Promise((resolve) => { | ||||
|     Ffmpeg.ffprobe(filepath, ['-show_chapters'], (err, raw) => { | ||||
|       if (err) { | ||||
|         console.error(err) | ||||
|         var errorMsg = err ? err.message : null | ||||
|         resolve({ | ||||
|           error: errorMsg || 'Probe Error' | ||||
|         }) | ||||
|       } else { | ||||
|         var rawProbeData = parseProbeData(raw, verbose) | ||||
|         if (!rawProbeData || !rawProbeData.audio_streams.length) { | ||||
|           resolve({ | ||||
|             error: rawProbeData ? 'Invalid audio file: no audio streams found' : 'Probe Failed' | ||||
|           }) | ||||
|         } else { | ||||
|           var probeData = new AudioProbeData() | ||||
|           probeData.setData(rawProbeData) | ||||
|           resolve(probeData) | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
|   }) | ||||
| } | ||||
| module.exports.probe2 = probe2 | ||||
| @ -267,7 +267,7 @@ function getAudiobookDataFromDir(folderPath, dir, parseSubtitle = false) { | ||||
| async function getAudiobookFileData(folder, audiobookPath, serverSettings = {}) { | ||||
|   var parseSubtitle = !!serverSettings.scannerParseSubtitle | ||||
| 
 | ||||
|   var fileItems = await recurseFiles(audiobookPath) | ||||
|   var fileItems = await recurseFiles(audiobookPath, folder.fullPath) | ||||
| 
 | ||||
|   audiobookPath = audiobookPath.replace(/\\/g, '/') | ||||
|   var folderFullPath = folder.fullPath.replace(/\\/g, '/') | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user