mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-31 02:17:01 -04:00 
			
		
		
		
	
		
			
				
	
	
		
			410 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			410 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| const Path = require('path')
 | |
| const fs = require('../libs/fsExtra')
 | |
| const njodb = require('../libs/njodb')
 | |
| 
 | |
| const { SupportedEbookTypes } = require('./globals')
 | |
| const { PlayMethod } = require('./constants')
 | |
| const { getId } = require('./index')
 | |
| const { filePathToPOSIX } = require('./fileUtils')
 | |
| const Logger = require('../Logger')
 | |
| 
 | |
| const Library = require('../objects/Library')
 | |
| const LibraryItem = require('../objects/LibraryItem')
 | |
| const Book = require('../objects/mediaTypes/Book')
 | |
| 
 | |
| const BookMetadata = require('../objects/metadata/BookMetadata')
 | |
| const FileMetadata = require('../objects/metadata/FileMetadata')
 | |
| 
 | |
| const AudioFile = require('../objects/files/AudioFile')
 | |
| const EBookFile = require('../objects/files/EBookFile')
 | |
| const LibraryFile = require('../objects/files/LibraryFile')
 | |
| const AudioMetaTags = require('../objects/metadata/AudioMetaTags')
 | |
| 
 | |
| const Author = require('../objects/entities/Author')
 | |
| const Series = require('../objects/entities/Series')
 | |
| 
 | |
| const MediaProgress = require('../objects/user/MediaProgress')
 | |
| const PlaybackSession = require('../objects/PlaybackSession')
 | |
| 
 | |
| const { isObject } = require('.')
 | |
| const User = require('../objects/user/User')
 | |
| 
 | |
| var authorsToAdd = []
 | |
| var existingDbAuthors = []
 | |
| var seriesToAdd = []
 | |
| var existingDbSeries = []
 | |
| 
 | |
| // Load old audiobooks
 | |
| async function loadAudiobooks() {
 | |
|   var audiobookPath = Path.join(global.ConfigPath, 'audiobooks')
 | |
| 
 | |
|   Logger.debug(`[dbMigration] loadAudiobooks path ${audiobookPath}`)
 | |
|   var pathExists = await fs.pathExists(audiobookPath)
 | |
|   if (!pathExists) {
 | |
|     Logger.debug(`[dbMigration] loadAudiobooks path does not exist ${audiobookPath}`)
 | |
|     return []
 | |
|   }
 | |
| 
 | |
|   var audiobooksDb = new njodb.Database(audiobookPath)
 | |
|   return audiobooksDb.select(() => true).then((results) => {
 | |
|     Logger.debug(`[dbMigration] loadAudiobooks select results ${results.data.length}`)
 | |
|     return results.data
 | |
|   })
 | |
| }
 | |
| 
 | |
| function makeAuthorsFromOldAb(authorsList) {
 | |
|   return authorsList.filter(a => !!a).map(authorName => {
 | |
|     var existingAuthor = authorsToAdd.find(a => a.name.toLowerCase() === authorName.toLowerCase())
 | |
|     if (existingAuthor) {
 | |
|       return existingAuthor.toJSONMinimal()
 | |
|     }
 | |
|     var existingDbAuthor = existingDbAuthors.find(a => a.name.toLowerCase() === authorName.toLowerCase())
 | |
|     if (existingDbAuthor) {
 | |
|       return existingDbAuthor.toJSONMinimal()
 | |
|     }
 | |
| 
 | |
|     var newAuthor = new Author()
 | |
|     newAuthor.setData({ name: authorName })
 | |
|     authorsToAdd.push(newAuthor)
 | |
|     // Logger.debug(`>>> Created new author named "${authorName}"`)
 | |
|     return newAuthor.toJSONMinimal()
 | |
|   })
 | |
| }
 | |
| 
 | |
| function makeSeriesFromOldAb({ series, volumeNumber }) {
 | |
|   var existingSeries = seriesToAdd.find(s => s.name.toLowerCase() === series.toLowerCase())
 | |
|   if (existingSeries) {
 | |
|     return [existingSeries.toJSONMinimal(volumeNumber)]
 | |
|   }
 | |
|   var existingDbSeriesItem = existingDbSeries.find(s => s.name.toLowerCase() === series.toLowerCase())
 | |
|   if (existingDbSeriesItem) {
 | |
|     return [existingDbSeriesItem.toJSONMinimal(volumeNumber)]
 | |
|   }
 | |
|   var newSeries = new Series()
 | |
|   newSeries.setData({ name: series })
 | |
|   seriesToAdd.push(newSeries)
 | |
|   Logger.info(`>>> Created new series named "${series}"`)
 | |
|   return [newSeries.toJSONMinimal(volumeNumber)]
 | |
| }
 | |
| 
 | |
| function getRelativePath(srcPath, basePath) {
 | |
|   srcPath = filePathToPOSIX(srcPath)
 | |
|   basePath = filePathToPOSIX(basePath)
 | |
|   return srcPath.replace(basePath, '')
 | |
| }
 | |
| 
 | |
| function makeFilesFromOldAb(audiobook) {
 | |
|   var libraryFiles = []
 | |
|   var ebookFiles = []
 | |
| 
 | |
|   var _audioFiles = audiobook.audioFiles || []
 | |
|   var audioFiles = _audioFiles.map((af) => {
 | |
|     var fileMetadata = new FileMetadata(af)
 | |
|     fileMetadata.path = af.fullPath
 | |
|     fileMetadata.relPath = getRelativePath(af.fullPath, audiobook.fullPath)
 | |
| 
 | |
|     var newLibraryFile = new LibraryFile()
 | |
|     newLibraryFile.ino = af.ino
 | |
|     newLibraryFile.metadata = fileMetadata.clone()
 | |
|     newLibraryFile.addedAt = af.addedAt
 | |
|     newLibraryFile.updatedAt = Date.now()
 | |
|     libraryFiles.push(newLibraryFile)
 | |
| 
 | |
|     var audioMetaTags = new AudioMetaTags(af.metadata || {}) // Old metaTags was named metadata
 | |
|     delete af.metadata
 | |
| 
 | |
|     var newAudioFile = new AudioFile(af)
 | |
|     newAudioFile.metadata = fileMetadata
 | |
|     newAudioFile.metaTags = audioMetaTags
 | |
|     newAudioFile.updatedAt = Date.now()
 | |
|     return newAudioFile
 | |
|   })
 | |
| 
 | |
|   var _otherFiles = audiobook.otherFiles || []
 | |
|   _otherFiles.forEach((file) => {
 | |
|     var fileMetadata = new FileMetadata(file)
 | |
|     fileMetadata.path = file.fullPath
 | |
|     fileMetadata.relPath = getRelativePath(file.fullPath, audiobook.fullPath)
 | |
| 
 | |
|     var newLibraryFile = new LibraryFile()
 | |
|     newLibraryFile.ino = file.ino
 | |
|     newLibraryFile.metadata = fileMetadata.clone()
 | |
|     newLibraryFile.addedAt = file.addedAt
 | |
|     newLibraryFile.updatedAt = Date.now()
 | |
|     libraryFiles.push(newLibraryFile)
 | |
| 
 | |
|     var formatExt = (file.ext || '').slice(1)
 | |
|     if (SupportedEbookTypes.includes(formatExt)) {
 | |
|       var newEBookFile = new EBookFile()
 | |
|       newEBookFile.ino = file.ino
 | |
|       newEBookFile.metadata = fileMetadata
 | |
|       newEBookFile.ebookFormat = formatExt
 | |
|       newEBookFile.addedAt = file.addedAt
 | |
|       newEBookFile.updatedAt = Date.now()
 | |
|       ebookFiles.push(newEBookFile)
 | |
|     }
 | |
|   })
 | |
| 
 | |
|   return {
 | |
|     libraryFiles,
 | |
|     ebookFiles,
 | |
|     audioFiles
 | |
|   }
 | |
| }
 | |
| 
 | |
| // Metadata path was changed to /metadata/items make sure cover is using new path
 | |
| function cleanOldCoverPath(coverPath) {
 | |
|   if (!coverPath) return null
 | |
|   var oldMetadataPath = Path.posix.join(global.MetadataPath, 'books')
 | |
|   if (coverPath.startsWith(oldMetadataPath)) {
 | |
|     const newMetadataPath = Path.posix.join(global.MetadataPath, 'items')
 | |
|     return coverPath.replace(oldMetadataPath, newMetadataPath)
 | |
|   }
 | |
|   return coverPath
 | |
| }
 | |
| 
 | |
| function makeLibraryItemFromOldAb(audiobook) {
 | |
|   var libraryItem = new LibraryItem()
 | |
|   libraryItem.id = audiobook.id
 | |
|   libraryItem.ino = audiobook.ino
 | |
|   libraryItem.libraryId = audiobook.libraryId
 | |
|   libraryItem.folderId = audiobook.folderId
 | |
|   libraryItem.path = audiobook.fullPath
 | |
|   libraryItem.relPath = audiobook.path
 | |
|   libraryItem.mtimeMs = audiobook.mtimeMs || 0
 | |
|   libraryItem.ctimeMs = audiobook.ctimeMs || 0
 | |
|   libraryItem.birthtimeMs = audiobook.birthtimeMs || 0
 | |
|   libraryItem.addedAt = audiobook.addedAt
 | |
|   libraryItem.updatedAt = audiobook.lastUpdate
 | |
|   libraryItem.lastScan = audiobook.lastScan
 | |
|   libraryItem.scanVersion = audiobook.scanVersion
 | |
|   libraryItem.isMissing = audiobook.isMissing
 | |
|   libraryItem.mediaType = 'book'
 | |
| 
 | |
|   var bookEntity = new Book()
 | |
|   var bookMetadata = new BookMetadata(audiobook.book)
 | |
|   bookMetadata.publishedYear = audiobook.book.publishYear || null
 | |
|   if (audiobook.book.narrator) {
 | |
|     bookMetadata.narrators = (audiobook.book.narrator || '').split(', ')
 | |
|   }
 | |
|   // Returns array of json minimal authors
 | |
|   bookMetadata.authors = makeAuthorsFromOldAb((audiobook.book.authorFL || '').split(', '))
 | |
| 
 | |
|   // Returns array of json minimal series
 | |
|   if (audiobook.book.series) {
 | |
|     bookMetadata.series = makeSeriesFromOldAb(audiobook.book)
 | |
|   }
 | |
| 
 | |
|   bookEntity.libraryItemId = libraryItem.id
 | |
|   bookEntity.metadata = bookMetadata
 | |
|   bookEntity.coverPath = cleanOldCoverPath(audiobook.book.coverFullPath)
 | |
|   bookEntity.tags = [...audiobook.tags]
 | |
| 
 | |
|   var payload = makeFilesFromOldAb(audiobook)
 | |
|   bookEntity.audioFiles = payload.audioFiles
 | |
|   bookEntity.chapters = []
 | |
|   if (audiobook.chapters && audiobook.chapters.length) {
 | |
|     bookEntity.chapters = audiobook.chapters.map(c => ({ ...c }))
 | |
|   }
 | |
|   bookEntity.missingParts = audiobook.missingParts || []
 | |
| 
 | |
|   if (payload.ebookFiles.length) {
 | |
|     bookEntity.ebookFile = payload.ebookFiles[0]
 | |
|   }
 | |
| 
 | |
|   libraryItem.media = bookEntity
 | |
|   libraryItem.libraryFiles = payload.libraryFiles
 | |
|   return libraryItem
 | |
| }
 | |
| 
 | |
| async function migrateLibraryItems(db) {
 | |
|   Logger.info(`==== Starting Library Item migration ====`)
 | |
| 
 | |
|   var audiobooks = await loadAudiobooks()
 | |
|   if (!audiobooks.length) {
 | |
|     Logger.info(`>>> No audiobooks in db, no migration necessary`)
 | |
|     return
 | |
|   }
 | |
| 
 | |
|   Logger.info(`>>> Loaded old audiobook data with ${audiobooks.length} records`)
 | |
| 
 | |
|   if (db.libraryItems.length) {
 | |
|     Logger.info(`>>> Some library items already loaded ${db.libraryItems.length} items | ${db.series.length} series | ${db.authors.length} authors`)
 | |
|     return
 | |
|   }
 | |
| 
 | |
|   if (db.authors && db.authors.length) {
 | |
|     existingDbAuthors = db.authors
 | |
|   }
 | |
|   if (db.series && db.series.length) {
 | |
|     existingDbSeries = db.series
 | |
|   }
 | |
| 
 | |
|   var libraryItems = audiobooks.map((ab) => makeLibraryItemFromOldAb(ab))
 | |
| 
 | |
|   Logger.info(`>>> ${libraryItems.length} Library Items made`)
 | |
|   await db.bulkInsertEntities('libraryItem', libraryItems)
 | |
|   if (authorsToAdd.length) {
 | |
|     Logger.info(`>>> ${authorsToAdd.length} Authors made`)
 | |
|     await db.bulkInsertEntities('author', authorsToAdd)
 | |
|   }
 | |
|   if (seriesToAdd.length) {
 | |
|     Logger.info(`>>> ${seriesToAdd.length} Series made`)
 | |
|     await db.insertEntities('series', seriesToAdd)
 | |
|   }
 | |
|   existingDbSeries = []
 | |
|   existingDbAuthors = []
 | |
|   authorsToAdd = []
 | |
|   seriesToAdd = []
 | |
|   Logger.info(`==== Library Item migration complete ====`)
 | |
| }
 | |
| 
 | |
| function cleanUserObject(db, userObj) {
 | |
|   var cleanedUserPayload = {
 | |
|     ...userObj,
 | |
|     mediaProgress: [],
 | |
|     bookmarks: []
 | |
|   }
 | |
| 
 | |
|   // UserAudiobookData is now MediaProgress and AudioBookmarks separated
 | |
|   if (userObj.audiobooks) {
 | |
|     for (const audiobookId in userObj.audiobooks) {
 | |
|       if (isObject(userObj.audiobooks[audiobookId])) {
 | |
|         // Bookmarks now live on User.js object instead of inside UserAudiobookData
 | |
|         if (userObj.audiobooks[audiobookId].bookmarks) {
 | |
|           const cleanedBookmarks = userObj.audiobooks[audiobookId].bookmarks.map((bm) => {
 | |
|             bm.libraryItemId = audiobookId
 | |
|             return bm
 | |
|           })
 | |
|           cleanedUserPayload.bookmarks = cleanedUserPayload.bookmarks.concat(cleanedBookmarks)
 | |
|         }
 | |
| 
 | |
|         var userAudiobookData = userObj.audiobooks[audiobookId]
 | |
|         var liProgress = new MediaProgress() // New Progress Object
 | |
|         liProgress.id = userAudiobookData.audiobookId
 | |
|         liProgress.libraryItemId = userAudiobookData.audiobookId
 | |
|         liProgress.duration = userAudiobookData.totalDuration
 | |
|         liProgress.isFinished = !!userAudiobookData.isRead
 | |
|         Object.keys(liProgress.toJSON()).forEach((key) => {
 | |
|           if (userAudiobookData[key] !== undefined) {
 | |
|             liProgress[key] = userAudiobookData[key]
 | |
|           }
 | |
|         })
 | |
|         cleanedUserPayload.mediaProgress.push(liProgress.toJSON())
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const user = new User(cleanedUserPayload)
 | |
|   return db.usersDb.update((record) => record.id === user.id, () => user).then((results) => {
 | |
|     Logger.debug(`[dbMigration] Updated User: ${results.updated} | Selected: ${results.selected}`)
 | |
|     return true
 | |
|   }).catch((error) => {
 | |
|     Logger.error(`[dbMigration] Update User Failed: ${error}`)
 | |
|     return false
 | |
|   })
 | |
| }
 | |
| 
 | |
| function cleanSessionObj(db, userListeningSession) {
 | |
|   var newPlaybackSession = new PlaybackSession(userListeningSession)
 | |
|   newPlaybackSession.id = getId('play')
 | |
|   newPlaybackSession.mediaType = 'book'
 | |
|   newPlaybackSession.updatedAt = userListeningSession.lastUpdate
 | |
|   newPlaybackSession.libraryItemId = userListeningSession.audiobookId
 | |
|   newPlaybackSession.playMethod = PlayMethod.TRANSCODE
 | |
| 
 | |
|   // We only have title to transfer over nicely
 | |
|   var bookMetadata = new BookMetadata()
 | |
|   bookMetadata.title = userListeningSession.audiobookTitle || ''
 | |
|   newPlaybackSession.mediaMetadata = bookMetadata
 | |
| 
 | |
|   return db.sessionsDb.update((record) => record.id === userListeningSession.id, () => newPlaybackSession).then((results) => true).catch((error) => {
 | |
|     Logger.error(`[dbMigration] Update Session Failed: ${error}`)
 | |
|     return false
 | |
|   })
 | |
| }
 | |
| 
 | |
| async function migrateUserData(db) {
 | |
|   Logger.info(`==== Starting User migration ====`)
 | |
| 
 | |
|   // Libraries with previous mediaType of "podcast" moved to "book"
 | |
|   //   because migrating those items to podcast objects will be a nightmare
 | |
|   //   users will need to create a new library for podcasts
 | |
|   var availableIcons = ['database', 'audiobook', 'book', 'comic', 'podcast']
 | |
|   const libraries = await db.librariesDb.select((result) => (result.mediaType != 'book' || !availableIcons.includes(result.icon)))
 | |
|     .then((results) => results.data.map(lib => new Library(lib)))
 | |
|   if (!libraries.length) {
 | |
|     Logger.info('[dbMigration] No libraries found needing migration')
 | |
|   } else {
 | |
|     for (const library of libraries) {
 | |
|       Logger.info(`>> Migrating library "${library.name}" with media type "${library.mediaType}"`)
 | |
|       await db.librariesDb.update((record) => record.id === library.id, () => library).then(() => true).catch((error) => {
 | |
|         Logger.error(`[dbMigration] Update library failed: ${error}`)
 | |
|         return false
 | |
|       })
 | |
|     }
 | |
|   }
 | |
| 
 | |
| 
 | |
|   const userObjects = await db.usersDb.select((result) => result.audiobooks != undefined).then((results) => results.data)
 | |
|   if (!userObjects.length) {
 | |
|     Logger.warn('[dbMigration] No users found needing migration')
 | |
|     return
 | |
|   }
 | |
| 
 | |
|   var userCount = 0
 | |
|   for (const userObj of userObjects) {
 | |
|     Logger.info(`[dbMigration] Migrating User "${userObj.username}"`)
 | |
|     var success = await cleanUserObject(db, userObj)
 | |
|     if (!success) {
 | |
|       await new Promise((resolve) => setTimeout(resolve, 500))
 | |
|       Logger.warn(`[dbMigration] Second attempt Migrating User "${userObj.username}"`)
 | |
|       success = await cleanUserObject(db, userObj)
 | |
|       if (!success) {
 | |
|         throw new Error('Db migration failed migrating users')
 | |
|       }
 | |
|     }
 | |
|     userCount++
 | |
|   }
 | |
| 
 | |
|   var sessionCount = 0
 | |
|   const userListeningSessions = await db.sessionsDb.select((result) => result.audiobookId != undefined).then((results) => results.data)
 | |
|   if (userListeningSessions.length) {
 | |
| 
 | |
|     for (const session of userListeningSessions) {
 | |
|       var success = await cleanSessionObj(db, session)
 | |
|       if (!success) {
 | |
|         await new Promise((resolve) => setTimeout(resolve, 500))
 | |
|         Logger.warn(`[dbMigration] Second attempt Migrating Session "${session.id}"`)
 | |
|         success = await cleanSessionObj(db, session)
 | |
|         if (!success) {
 | |
|           Logger.error(`[dbMigration] Failed to migrate session "${session.id}"`)
 | |
|         }
 | |
|       }
 | |
|       if (success) sessionCount++
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   Logger.info(`==== User migration complete (${userCount} Users, ${sessionCount} Sessions) ====`)
 | |
| }
 | |
| 
 | |
| async function checkUpdateMetadataPath() {
 | |
|   var bookMetadataPath = Path.posix.join(global.MetadataPath, 'books') // OLD
 | |
|   if (!(await fs.pathExists(bookMetadataPath))) {
 | |
|     Logger.debug(`[dbMigration] No need to update books metadata path`)
 | |
|     return
 | |
|   }
 | |
|   var itemsMetadataPath = Path.posix.join(global.MetadataPath, 'items')
 | |
|   await fs.rename(bookMetadataPath, itemsMetadataPath)
 | |
|   Logger.info(`>>> Renamed metadata dir from /metadata/books to /metadata/items`)
 | |
| }
 | |
| 
 | |
| module.exports.migrate = async (db) => {
 | |
|   await checkUpdateMetadataPath()
 | |
|   // Before DB Load clean data
 | |
|   await migrateUserData(db)
 | |
|   await db.init()
 | |
|   // After DB Load
 | |
|   await migrateLibraryItems(db)
 | |
|   // TODO: Eventually remove audiobooks db when stable
 | |
| } |