const { DataTypes, QueryInterface } = require('sequelize') const Path = require('path') const uuidv4 = require("uuid").v4 const Logger = require('../../Logger') const fs = require('../../libs/fsExtra') const oldDbFiles = require('./oldDbFiles') const oldDbIdMap = { users: {}, libraries: {}, libraryFolders: {}, libraryItems: {}, authors: {}, // key is (new) library id with another map of author ids series: {}, // key is (new) library id with another map of series ids collections: {}, podcastEpisodes: {}, books: {}, // key is library item id podcasts: {}, // key is library item id devices: {} // key is a json stringify of the old DeviceInfo data OR deviceId if it exists } function getDeviceInfoString(deviceInfo, UserId) { if (!deviceInfo) return null if (deviceInfo.deviceId) return deviceInfo.deviceId const keys = [ UserId, deviceInfo.browserName || null, deviceInfo.browserVersion || null, deviceInfo.osName || null, deviceInfo.osVersion || null, deviceInfo.clientVersion || null, deviceInfo.manufacturer || null, deviceInfo.model || null, deviceInfo.sdkVersion || null, deviceInfo.ipAddress || null ].map(k => k || '') return 'temp-' + Buffer.from(keys.join('-'), 'utf-8').toString('base64') } /** * Migrate oldLibraryItem.media to Book model * Migrate BookSeries and BookAuthor * @param {objects.LibraryItem} oldLibraryItem * @param {object} LibraryItem models.LibraryItem object * @returns {object} { book: object, bookSeries: [], bookAuthor: [] } */ function migrateBook(oldLibraryItem, LibraryItem) { const oldBook = oldLibraryItem.media const _newRecords = { book: null, bookSeries: [], bookAuthor: [] } // // Migrate Book // const Book = { id: uuidv4(), title: oldBook.metadata.title, subtitle: oldBook.metadata.subtitle, publishedYear: oldBook.metadata.publishedYear, publishedDate: oldBook.metadata.publishedDate, publisher: oldBook.metadata.publisher, description: oldBook.metadata.description, isbn: oldBook.metadata.isbn, asin: oldBook.metadata.asin, language: oldBook.metadata.language, explicit: !!oldBook.metadata.explicit, abridged: !!oldBook.metadata.abridged, lastCoverSearchQuery: oldBook.lastCoverSearchQuery, lastCoverSearch: oldBook.lastCoverSearch, createdAt: LibraryItem.createdAt, updatedAt: LibraryItem.updatedAt, narrators: oldBook.metadata.narrators, ebookFile: oldBook.ebookFile, coverPath: oldBook.coverPath, audioFiles: oldBook.audioFiles, chapters: oldBook.chapters, tags: oldBook.tags, genres: oldBook.metadata.genres } _newRecords.book = Book oldDbIdMap.books[oldLibraryItem.id] = Book.id // // Migrate BookAuthors // const bookAuthorsInserted = [] for (const oldBookAuthor of oldBook.metadata.authors) { if (oldDbIdMap.authors[LibraryItem.libraryId][oldBookAuthor.id]) { const authorId = oldDbIdMap.authors[LibraryItem.libraryId][oldBookAuthor.id] if (bookAuthorsInserted.includes(authorId)) continue // Duplicate prevention bookAuthorsInserted.push(authorId) _newRecords.bookAuthor.push({ id: uuidv4(), authorId, bookId: Book.id }) } else { Logger.warn(`[dbMigration] migrateBook: Book author not found "${oldBookAuthor.name}"`) } } // // Migrate BookSeries // const bookSeriesInserted = [] for (const oldBookSeries of oldBook.metadata.series) { if (oldDbIdMap.series[LibraryItem.libraryId][oldBookSeries.id]) { const seriesId = oldDbIdMap.series[LibraryItem.libraryId][oldBookSeries.id] if (bookSeriesInserted.includes(seriesId)) continue // Duplicate prevention bookSeriesInserted.push(seriesId) _newRecords.bookSeries.push({ id: uuidv4(), sequence: oldBookSeries.sequence, seriesId: oldDbIdMap.series[LibraryItem.libraryId][oldBookSeries.id], bookId: Book.id }) } else { Logger.warn(`[dbMigration] migrateBook: Series not found "${oldBookSeries.name}"`) } } return _newRecords } /** * Migrate oldLibraryItem.media to Podcast model * Migrate PodcastEpisode * @param {objects.LibraryItem} oldLibraryItem * @param {object} LibraryItem models.LibraryItem object * @returns {object} { podcast: object, podcastEpisode: [] } */ function migratePodcast(oldLibraryItem, LibraryItem) { const _newRecords = { podcast: null, podcastEpisode: [] } const oldPodcast = oldLibraryItem.media const oldPodcastMetadata = oldPodcast.metadata // // Migrate Podcast // const Podcast = { id: uuidv4(), title: oldPodcastMetadata.title, author: oldPodcastMetadata.author, releaseDate: oldPodcastMetadata.releaseDate, feedURL: oldPodcastMetadata.feedUrl, imageURL: oldPodcastMetadata.imageUrl, description: oldPodcastMetadata.description, itunesPageURL: oldPodcastMetadata.itunesPageUrl, itunesId: oldPodcastMetadata.itunesId, itunesArtistId: oldPodcastMetadata.itunesArtistId, language: oldPodcastMetadata.language, podcastType: oldPodcastMetadata.type, explicit: !!oldPodcastMetadata.explicit, autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes, autoDownloadSchedule: oldPodcast.autoDownloadSchedule, lastEpisodeCheck: oldPodcast.lastEpisodeCheck, maxEpisodesToKeep: oldPodcast.maxEpisodesToKeep || 0, maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload || 3, lastCoverSearchQuery: oldPodcast.lastCoverSearchQuery, lastCoverSearch: oldPodcast.lastCoverSearch, createdAt: LibraryItem.createdAt, updatedAt: LibraryItem.updatedAt, coverPath: oldPodcast.coverPath, tags: oldPodcast.tags, genres: oldPodcastMetadata.genres } _newRecords.podcast = Podcast oldDbIdMap.podcasts[oldLibraryItem.id] = Podcast.id // // Migrate PodcastEpisodes // const oldEpisodes = oldPodcast.episodes || [] for (const oldEpisode of oldEpisodes) { oldEpisode.audioFile.index = 1 const PodcastEpisode = { id: uuidv4(), oldEpisodeId: oldEpisode.id, index: oldEpisode.index, season: oldEpisode.season || null, episode: oldEpisode.episode || null, episodeType: oldEpisode.episodeType || null, title: oldEpisode.title, subtitle: oldEpisode.subtitle || null, description: oldEpisode.description || null, pubDate: oldEpisode.pubDate || null, enclosureURL: oldEpisode.enclosure?.url || null, enclosureSize: oldEpisode.enclosure?.length || null, enclosureType: oldEpisode.enclosure?.type || null, publishedAt: oldEpisode.publishedAt || null, createdAt: oldEpisode.addedAt, updatedAt: oldEpisode.updatedAt, podcastId: Podcast.id, audioFile: oldEpisode.audioFile, chapters: oldEpisode.chapters || [] } _newRecords.podcastEpisode.push(PodcastEpisode) oldDbIdMap.podcastEpisodes[oldEpisode.id] = PodcastEpisode.id } return _newRecords } /** * Migrate libraryItems to LibraryItem, Book, Podcast models * @param {Array} oldLibraryItems * @returns {object} { libraryItem: [], book: [], podcast: [], podcastEpisode: [], bookSeries: [], bookAuthor: [] } */ function migrateLibraryItems(oldLibraryItems) { const _newRecords = { book: [], podcast: [], podcastEpisode: [], bookSeries: [], bookAuthor: [], libraryItem: [] } for (const oldLibraryItem of oldLibraryItems) { const libraryFolderId = oldDbIdMap.libraryFolders[oldLibraryItem.folderId] if (!libraryFolderId) { Logger.error(`[dbMigration] migrateLibraryItems: Old library folder id not found "${oldLibraryItem.folderId}"`) continue } const libraryId = oldDbIdMap.libraries[oldLibraryItem.libraryId] if (!libraryId) { Logger.error(`[dbMigration] migrateLibraryItems: Old library id not found "${oldLibraryItem.libraryId}"`) continue } if (!['book', 'podcast'].includes(oldLibraryItem.mediaType)) { Logger.error(`[dbMigration] migrateLibraryItems: Not migrating library item with mediaType=${oldLibraryItem.mediaType}`) continue } // // Migrate LibraryItem // const LibraryItem = { id: uuidv4(), oldLibraryItemId: oldLibraryItem.id, ino: oldLibraryItem.ino, path: oldLibraryItem.path, relPath: oldLibraryItem.relPath, mediaId: null, // set below mediaType: oldLibraryItem.mediaType, isFile: !!oldLibraryItem.isFile, isMissing: !!oldLibraryItem.isMissing, isInvalid: !!oldLibraryItem.isInvalid, mtime: oldLibraryItem.mtimeMs, ctime: oldLibraryItem.ctimeMs, birthtime: oldLibraryItem.birthtimeMs, lastScan: oldLibraryItem.lastScan, lastScanVersion: oldLibraryItem.scanVersion, createdAt: oldLibraryItem.addedAt, updatedAt: oldLibraryItem.updatedAt, libraryId, libraryFolderId, libraryFiles: oldLibraryItem.libraryFiles.map(lf => { if (lf.isSupplementary === undefined) lf.isSupplementary = null return lf }) } oldDbIdMap.libraryItems[oldLibraryItem.id] = LibraryItem.id _newRecords.libraryItem.push(LibraryItem) // // Migrate Book/Podcast // if (oldLibraryItem.mediaType === 'book') { const bookRecords = migrateBook(oldLibraryItem, LibraryItem) _newRecords.book.push(bookRecords.book) _newRecords.bookAuthor.push(...bookRecords.bookAuthor) _newRecords.bookSeries.push(...bookRecords.bookSeries) LibraryItem.mediaId = oldDbIdMap.books[oldLibraryItem.id] } else if (oldLibraryItem.mediaType === 'podcast') { const podcastRecords = migratePodcast(oldLibraryItem, LibraryItem) _newRecords.podcast.push(podcastRecords.podcast) _newRecords.podcastEpisode.push(...podcastRecords.podcastEpisode) LibraryItem.mediaId = oldDbIdMap.podcasts[oldLibraryItem.id] } } return _newRecords } /** * Migrate Library and LibraryFolder * @param {Array} oldLibraries * @returns {object} { library: [], libraryFolder: [] } */ function migrateLibraries(oldLibraries) { const _newRecords = { library: [], libraryFolder: [] } for (const oldLibrary of oldLibraries) { if (!['book', 'podcast'].includes(oldLibrary.mediaType)) { Logger.error(`[dbMigration] migrateLibraries: Not migrating library with mediaType=${oldLibrary.mediaType}`) continue } // // Migrate Library // const Library = { id: uuidv4(), oldLibraryId: oldLibrary.id, name: oldLibrary.name, displayOrder: oldLibrary.displayOrder, icon: oldLibrary.icon || null, mediaType: oldLibrary.mediaType || null, provider: oldLibrary.provider, settings: oldLibrary.settings || {}, createdAt: oldLibrary.createdAt, updatedAt: oldLibrary.lastUpdate } oldDbIdMap.libraries[oldLibrary.id] = Library.id _newRecords.library.push(Library) // // Migrate LibraryFolders // for (const oldFolder of oldLibrary.folders) { const LibraryFolder = { id: uuidv4(), path: oldFolder.fullPath, createdAt: oldFolder.addedAt, updatedAt: oldLibrary.lastUpdate, libraryId: Library.id } oldDbIdMap.libraryFolders[oldFolder.id] = LibraryFolder.id _newRecords.libraryFolder.push(LibraryFolder) } } return _newRecords } /** * Migrate Author * Previously Authors were shared between libraries, this will ensure every author has one library * @param {Array} oldAuthors * @param {Array} oldLibraryItems * @returns {Array} Array of Author model objs */ function migrateAuthors(oldAuthors, oldLibraryItems) { const _newRecords = [] for (const oldAuthor of oldAuthors) { // Get an array of NEW library ids that have this author const librariesWithThisAuthor = [...new Set(oldLibraryItems.map(li => { if (!li.media.metadata.authors?.some(au => au.id === oldAuthor.id)) return null if (!oldDbIdMap.libraries[li.libraryId]) { Logger.warn(`[dbMigration] Authors library id ${li.libraryId} was not migrated`) } return oldDbIdMap.libraries[li.libraryId] }).filter(lid => lid))] if (!librariesWithThisAuthor.length) { Logger.error(`[dbMigration] Author ${oldAuthor.name} was not found in any libraries`) } for (const libraryId of librariesWithThisAuthor) { const Author = { id: uuidv4(), name: oldAuthor.name, asin: oldAuthor.asin || null, description: oldAuthor.description, imagePath: oldAuthor.imagePath, createdAt: oldAuthor.addedAt || Date.now(), updatedAt: oldAuthor.updatedAt || Date.now(), libraryId } if (!oldDbIdMap.authors[libraryId]) oldDbIdMap.authors[libraryId] = {} oldDbIdMap.authors[libraryId][oldAuthor.id] = Author.id _newRecords.push(Author) } } return _newRecords } /** * Migrate Series * Previously Series were shared between libraries, this will ensure every series has one library * @param {Array} oldSerieses * @param {Array} oldLibraryItems * @returns {Array} Array of Series model objs */ function migrateSeries(oldSerieses, oldLibraryItems) { const _newRecords = [] // Originaly series were shared between libraries if they had the same name // Series will be separate between libraries for (const oldSeries of oldSerieses) { // Get an array of NEW library ids that have this series const librariesWithThisSeries = [...new Set(oldLibraryItems.map(li => { if (!li.media.metadata.series?.some(se => se.id === oldSeries.id)) return null return oldDbIdMap.libraries[li.libraryId] }).filter(lid => lid))] if (!librariesWithThisSeries.length) { Logger.error(`[dbMigration] Series ${oldSeries.name} was not found in any libraries`) } for (const libraryId of librariesWithThisSeries) { const Series = { id: uuidv4(), name: oldSeries.name, description: oldSeries.description || null, createdAt: oldSeries.addedAt || Date.now(), updatedAt: oldSeries.updatedAt || Date.now(), libraryId } if (!oldDbIdMap.series[libraryId]) oldDbIdMap.series[libraryId] = {} oldDbIdMap.series[libraryId][oldSeries.id] = Series.id _newRecords.push(Series) } } return _newRecords } /** * Migrate users to User and MediaProgress models * @param {Array} oldUsers * @returns {object} { user: [], mediaProgress: [] } */ function migrateUsers(oldUsers) { const _newRecords = { user: [], mediaProgress: [] } for (const oldUser of oldUsers) { // // Migrate User // // Convert old library ids to new ids const librariesAccessible = (oldUser.librariesAccessible || []).map((lid) => oldDbIdMap.libraries[lid]).filter(li => li) // Convert old library item ids to new ids const bookmarks = (oldUser.bookmarks || []).map(bm => { bm.libraryItemId = oldDbIdMap.libraryItems[bm.libraryItemId] return bm }).filter(bm => bm.libraryItemId) // Convert old series ids to new const seriesHideFromContinueListening = (oldUser.seriesHideFromContinueListening || []).map(oldSeriesId => { // Series were split to be per library // This will use the first series it finds for (const libraryId in oldDbIdMap.series) { if (oldDbIdMap.series[libraryId][oldSeriesId]) { return oldDbIdMap.series[libraryId][oldSeriesId] } } return null }).filter(se => se) const User = { id: uuidv4(), username: oldUser.username, pash: oldUser.pash || null, type: oldUser.type || null, token: oldUser.token || null, isActive: !!oldUser.isActive, lastSeen: oldUser.lastSeen || null, extraData: { seriesHideFromContinueListening, oldUserId: oldUser.id // Used to keep old tokens }, createdAt: oldUser.createdAt || Date.now(), permissions: { ...oldUser.permissions, librariesAccessible, itemTagsSelected: oldUser.itemTagsSelected || [] }, bookmarks } oldDbIdMap.users[oldUser.id] = User.id _newRecords.user.push(User) // // Migrate MediaProgress // for (const oldMediaProgress of oldUser.mediaProgress) { let mediaItemType = 'book' let mediaItemId = null if (oldMediaProgress.episodeId) { mediaItemType = 'podcastEpisode' mediaItemId = oldDbIdMap.podcastEpisodes[oldMediaProgress.episodeId] } else { mediaItemId = oldDbIdMap.books[oldMediaProgress.libraryItemId] } if (!mediaItemId) { Logger.warn(`[dbMigration] migrateUsers: Unable to find media item for media progress "${oldMediaProgress.id}"`) continue } const MediaProgress = { id: uuidv4(), mediaItemId, mediaItemType, duration: oldMediaProgress.duration, currentTime: oldMediaProgress.currentTime, ebookLocation: oldMediaProgress.ebookLocation || null, ebookProgress: oldMediaProgress.ebookProgress || null, isFinished: !!oldMediaProgress.isFinished, hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening, finishedAt: oldMediaProgress.finishedAt, createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate, updatedAt: oldMediaProgress.lastUpdate, userId: User.id, extraData: { libraryItemId: oldDbIdMap.libraryItems[oldMediaProgress.libraryItemId], progress: oldMediaProgress.progress } } _newRecords.mediaProgress.push(MediaProgress) } } return _newRecords } /** * Migrate playbackSessions to PlaybackSession and Device models * @param {Array} oldSessions * @returns {object} { playbackSession: [], device: [] } */ function migrateSessions(oldSessions) { const _newRecords = { device: [], playbackSession: [] } for (const oldSession of oldSessions) { const userId = oldDbIdMap.users[oldSession.userId] if (!userId) { Logger.info(`[dbMigration] Not migrating playback session ${oldSession.id} because user was not found`) continue } // // Migrate Device // let deviceId = null if (oldSession.deviceInfo) { const oldDeviceInfo = oldSession.deviceInfo const deviceDeviceId = getDeviceInfoString(oldDeviceInfo, userId) deviceId = oldDbIdMap.devices[deviceDeviceId] if (!deviceId) { let clientName = 'Unknown' let clientVersion = null let deviceName = null let deviceVersion = oldDeviceInfo.browserVersion || null let extraData = {} if (oldDeviceInfo.sdkVersion) { clientName = 'Abs Android' clientVersion = oldDeviceInfo.clientVersion || null deviceName = `${oldDeviceInfo.manufacturer} ${oldDeviceInfo.model}` deviceVersion = oldDeviceInfo.sdkVersion } else if (oldDeviceInfo.model) { clientName = 'Abs iOS' clientVersion = oldDeviceInfo.clientVersion || null deviceName = `${oldDeviceInfo.manufacturer} ${oldDeviceInfo.model}` } else if (oldDeviceInfo.osName && oldDeviceInfo.browserName) { clientName = 'Abs Web' clientVersion = oldDeviceInfo.serverVersion || null deviceName = `${oldDeviceInfo.osName} ${oldDeviceInfo.osVersion || 'N/A'} ${oldDeviceInfo.browserName}` } if (oldDeviceInfo.manufacturer) { extraData.manufacturer = oldDeviceInfo.manufacturer } if (oldDeviceInfo.model) { extraData.model = oldDeviceInfo.model } if (oldDeviceInfo.osName) { extraData.osName = oldDeviceInfo.osName } if (oldDeviceInfo.osVersion) { extraData.osVersion = oldDeviceInfo.osVersion } if (oldDeviceInfo.browserName) { extraData.browserName = oldDeviceInfo.browserName } const id = uuidv4() const Device = { id, deviceId: deviceDeviceId, clientName, clientVersion, ipAddress: oldDeviceInfo.ipAddress, deviceName, // e.g. Windows 10 Chrome, Google Pixel 6, Apple iPhone 10,3 deviceVersion, userId, extraData } deviceId = Device.id _newRecords.device.push(Device) oldDbIdMap.devices[deviceDeviceId] = Device.id } } // // Migrate PlaybackSession // let mediaItemId = null let mediaItemType = 'book' if (oldSession.mediaType === 'podcast') { mediaItemId = oldDbIdMap.podcastEpisodes[oldSession.episodeId] || null mediaItemType = 'podcastEpisode' } else { mediaItemId = oldDbIdMap.books[oldSession.libraryItemId] || null } const PlaybackSession = { id: uuidv4(), mediaItemId, // Can be null mediaItemType, libraryId: oldDbIdMap.libraries[oldSession.libraryId] || null, displayTitle: oldSession.displayTitle, displayAuthor: oldSession.displayAuthor, duration: oldSession.duration, playMethod: oldSession.playMethod, mediaPlayer: oldSession.mediaPlayer, startTime: oldSession.startTime, currentTime: oldSession.currentTime, serverVersion: oldSession.deviceInfo?.serverVersion || null, createdAt: oldSession.startedAt, updatedAt: oldSession.updatedAt, userId, deviceId, timeListening: oldSession.timeListening, coverPath: oldSession.coverPath, mediaMetadata: oldSession.mediaMetadata, date: oldSession.date, dayOfWeek: oldSession.dayOfWeek, extraData: { libraryItemId: oldDbIdMap.libraryItems[oldSession.libraryItemId] } } _newRecords.playbackSession.push(PlaybackSession) } return _newRecords } /** * Migrate collections to Collection & CollectionBook * @param {Array} oldCollections * @returns {object} { collection: [], collectionBook: [] } */ function migrateCollections(oldCollections) { const _newRecords = { collection: [], collectionBook: [] } for (const oldCollection of oldCollections) { const libraryId = oldDbIdMap.libraries[oldCollection.libraryId] if (!libraryId) { Logger.warn(`[dbMigration] migrateCollections: Library not found for collection "${oldCollection.name}" (id:${oldCollection.libraryId})`) continue } const BookIds = oldCollection.books.map(lid => oldDbIdMap.books[lid]).filter(bid => bid) if (!BookIds.length) { Logger.warn(`[dbMigration] migrateCollections: Collection "${oldCollection.name}" has no books`) continue } const Collection = { id: uuidv4(), name: oldCollection.name, description: oldCollection.description, createdAt: oldCollection.createdAt, updatedAt: oldCollection.lastUpdate, libraryId } oldDbIdMap.collections[oldCollection.id] = Collection.id _newRecords.collection.push(Collection) let order = 1 BookIds.forEach((bookId) => { const CollectionBook = { id: uuidv4(), createdAt: Collection.createdAt, bookId, collectionId: Collection.id, order: order++ } _newRecords.collectionBook.push(CollectionBook) }) } return _newRecords } /** * Migrate playlists to Playlist and PlaylistMediaItem * @param {Array} oldPlaylists * @returns {object} { playlist: [], playlistMediaItem: [] } */ function migratePlaylists(oldPlaylists) { const _newRecords = { playlist: [], playlistMediaItem: [] } for (const oldPlaylist of oldPlaylists) { const libraryId = oldDbIdMap.libraries[oldPlaylist.libraryId] if (!libraryId) { Logger.warn(`[dbMigration] migratePlaylists: Library not found for playlist "${oldPlaylist.name}" (id:${oldPlaylist.libraryId})`) continue } const userId = oldDbIdMap.users[oldPlaylist.userId] if (!userId) { Logger.warn(`[dbMigration] migratePlaylists: User not found for playlist "${oldPlaylist.name}" (id:${oldPlaylist.userId})`) continue } let mediaItemType = 'book' let MediaItemIds = [] oldPlaylist.items.forEach((itemObj) => { if (itemObj.episodeId) { mediaItemType = 'podcastEpisode' if (oldDbIdMap.podcastEpisodes[itemObj.episodeId]) { MediaItemIds.push(oldDbIdMap.podcastEpisodes[itemObj.episodeId]) } } else if (oldDbIdMap.books[itemObj.libraryItemId]) { MediaItemIds.push(oldDbIdMap.books[itemObj.libraryItemId]) } }) if (!MediaItemIds.length) { Logger.warn(`[dbMigration] migratePlaylists: Playlist "${oldPlaylist.name}" has no items`) continue } const Playlist = { id: uuidv4(), name: oldPlaylist.name, description: oldPlaylist.description, createdAt: oldPlaylist.createdAt, updatedAt: oldPlaylist.lastUpdate, userId, libraryId } _newRecords.playlist.push(Playlist) let order = 1 MediaItemIds.forEach((mediaItemId) => { const PlaylistMediaItem = { id: uuidv4(), mediaItemId, mediaItemType, createdAt: Playlist.createdAt, playlistId: Playlist.id, order: order++ } _newRecords.playlistMediaItem.push(PlaylistMediaItem) }) } return _newRecords } /** * Migrate feeds to Feed and FeedEpisode models * @param {Array} oldFeeds * @returns {object} { feed: [], feedEpisode: [] } */ function migrateFeeds(oldFeeds) { const _newRecords = { feed: [], feedEpisode: [] } for (const oldFeed of oldFeeds) { if (!oldFeed.episodes?.length) { continue } let entityId = null if (oldFeed.entityType === 'collection') { entityId = oldDbIdMap.collections[oldFeed.entityId] } else if (oldFeed.entityType === 'libraryItem') { entityId = oldDbIdMap.libraryItems[oldFeed.entityId] } else if (oldFeed.entityType === 'series') { // Series were split to be per library // This will use the first series it finds for (const libraryId in oldDbIdMap.series) { if (oldDbIdMap.series[libraryId][oldFeed.entityId]) { entityId = oldDbIdMap.series[libraryId][oldFeed.entityId] break } } } if (!entityId) { Logger.warn(`[dbMigration] migrateFeeds: Entity not found for feed "${oldFeed.entityType}" (id:${oldFeed.entityId})`) continue } const userId = oldDbIdMap.users[oldFeed.userId] if (!userId) { Logger.warn(`[dbMigration] migrateFeeds: User not found for feed (id:${oldFeed.userId})`) continue } const oldFeedMeta = oldFeed.meta const Feed = { id: uuidv4(), slug: oldFeed.slug, entityType: oldFeed.entityType, entityId, entityUpdatedAt: oldFeed.entityUpdatedAt, serverAddress: oldFeed.serverAddress, feedURL: oldFeed.feedUrl, coverPath: oldFeed.coverPath || null, imageURL: oldFeedMeta.imageUrl, siteURL: oldFeedMeta.link, title: oldFeedMeta.title, description: oldFeedMeta.description, author: oldFeedMeta.author, podcastType: oldFeedMeta.type || null, language: oldFeedMeta.language || null, ownerName: oldFeedMeta.ownerName || null, ownerEmail: oldFeedMeta.ownerEmail || null, explicit: !!oldFeedMeta.explicit, preventIndexing: !!oldFeedMeta.preventIndexing, createdAt: oldFeed.createdAt, updatedAt: oldFeed.updatedAt, userId } _newRecords.feed.push(Feed) // // Migrate FeedEpisodes // for (const oldFeedEpisode of oldFeed.episodes) { const FeedEpisode = { id: uuidv4(), title: oldFeedEpisode.title, author: oldFeedEpisode.author, description: oldFeedEpisode.description, siteURL: oldFeedEpisode.link, enclosureURL: oldFeedEpisode.enclosure?.url || null, enclosureType: oldFeedEpisode.enclosure?.type || null, enclosureSize: oldFeedEpisode.enclosure?.size || null, pubDate: oldFeedEpisode.pubDate, season: oldFeedEpisode.season || null, episode: oldFeedEpisode.episode || null, episodeType: oldFeedEpisode.episodeType || null, duration: oldFeedEpisode.duration, filePath: oldFeedEpisode.fullPath, explicit: !!oldFeedEpisode.explicit, createdAt: oldFeed.createdAt, updatedAt: oldFeed.updatedAt, feedId: Feed.id } _newRecords.feedEpisode.push(FeedEpisode) } } return _newRecords } /** * Migrate ServerSettings, NotificationSettings and EmailSettings to Setting model * @param {Array} oldSettings * @returns {Array} Array of Setting model objs */ function migrateSettings(oldSettings) { const _newRecords = [] const serverSettings = oldSettings.find(s => s.id === 'server-settings') const notificationSettings = oldSettings.find(s => s.id === 'notification-settings') const emailSettings = oldSettings.find(s => s.id === 'email-settings') if (serverSettings) { _newRecords.push({ key: 'server-settings', value: serverSettings }) } if (notificationSettings) { _newRecords.push({ key: 'notification-settings', value: notificationSettings }) } if (emailSettings) { _newRecords.push({ key: 'email-settings', value: emailSettings }) } return _newRecords } /** * Load old libraries and bulkCreate new Library and LibraryFolder rows * @param {Map} DatabaseModels */ async function handleMigrateLibraries(DatabaseModels) { const oldLibraries = await oldDbFiles.loadOldData('libraries') const newLibraryRecords = migrateLibraries(oldLibraries) for (const model in newLibraryRecords) { Logger.info(`[dbMigration] Inserting ${newLibraryRecords[model].length} ${model} rows`) await DatabaseModels[model].bulkCreate(newLibraryRecords[model]) } } /** * Load old EmailSettings, NotificationSettings and ServerSettings and bulkCreate new Setting rows * @param {Map} DatabaseModels */ async function handleMigrateSettings(DatabaseModels) { const oldSettings = await oldDbFiles.loadOldData('settings') const newSettings = migrateSettings(oldSettings) Logger.info(`[dbMigration] Inserting ${newSettings.length} setting rows`) await DatabaseModels.setting.bulkCreate(newSettings) } /** * Load old authors and bulkCreate new Author rows * @param {Map} DatabaseModels * @param {Array} oldLibraryItems */ async function handleMigrateAuthors(DatabaseModels, oldLibraryItems) { const oldAuthors = await oldDbFiles.loadOldData('authors') const newAuthors = migrateAuthors(oldAuthors, oldLibraryItems) Logger.info(`[dbMigration] Inserting ${newAuthors.length} author rows`) await DatabaseModels.author.bulkCreate(newAuthors) } /** * Load old series and bulkCreate new Series rows * @param {Map} DatabaseModels * @param {Array} oldLibraryItems */ async function handleMigrateSeries(DatabaseModels, oldLibraryItems) { const oldSeries = await oldDbFiles.loadOldData('series') const newSeries = migrateSeries(oldSeries, oldLibraryItems) Logger.info(`[dbMigration] Inserting ${newSeries.length} series rows`) await DatabaseModels.series.bulkCreate(newSeries) } /** * bulkCreate new LibraryItem, Book and Podcast rows * @param {Map} DatabaseModels * @param {Array} oldLibraryItems */ async function handleMigrateLibraryItems(DatabaseModels, oldLibraryItems) { const newItemsBooksPodcasts = migrateLibraryItems(oldLibraryItems) for (const model in newItemsBooksPodcasts) { Logger.info(`[dbMigration] Inserting ${newItemsBooksPodcasts[model].length} ${model} rows`) await DatabaseModels[model].bulkCreate(newItemsBooksPodcasts[model]) } } /** * Migrate authors, series then library items in chunks * Authors and series require old library items loaded first * @param {Map} DatabaseModels */ async function handleMigrateAuthorsSeriesAndLibraryItems(DatabaseModels) { const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems') await handleMigrateAuthors(DatabaseModels, oldLibraryItems) await handleMigrateSeries(DatabaseModels, oldLibraryItems) // Migrate library items in chunks of 1000 const numChunks = Math.ceil(oldLibraryItems.length / 1000) for (let i = 0; i < numChunks; i++) { let start = i * 1000 await handleMigrateLibraryItems(DatabaseModels, oldLibraryItems.slice(start, start + 1000)) } } /** * Load old users and bulkCreate new User rows * @param {Map} DatabaseModels */ async function handleMigrateUsers(DatabaseModels) { const oldUsers = await oldDbFiles.loadOldData('users') const newUserRecords = migrateUsers(oldUsers) for (const model in newUserRecords) { Logger.info(`[dbMigration] Inserting ${newUserRecords[model].length} ${model} rows`) await DatabaseModels[model].bulkCreate(newUserRecords[model]) } } /** * Load old sessions and bulkCreate new PlaybackSession & Device rows * @param {Map} DatabaseModels */ async function handleMigrateSessions(DatabaseModels) { const oldSessions = await oldDbFiles.loadOldData('sessions') let chunkSize = 1000 let numChunks = Math.ceil(oldSessions.length / chunkSize) for (let i = 0; i < numChunks; i++) { let start = i * chunkSize const newSessionRecords = migrateSessions(oldSessions.slice(start, start + chunkSize)) for (const model in newSessionRecords) { Logger.info(`[dbMigration] Inserting ${newSessionRecords[model].length} ${model} rows`) await DatabaseModels[model].bulkCreate(newSessionRecords[model]) } } } /** * Load old collections and bulkCreate new Collection, CollectionBook models * @param {Map} DatabaseModels */ async function handleMigrateCollections(DatabaseModels) { const oldCollections = await oldDbFiles.loadOldData('collections') const newCollectionRecords = migrateCollections(oldCollections) for (const model in newCollectionRecords) { Logger.info(`[dbMigration] Inserting ${newCollectionRecords[model].length} ${model} rows`) await DatabaseModels[model].bulkCreate(newCollectionRecords[model]) } } /** * Load old playlists and bulkCreate new Playlist, PlaylistMediaItem models * @param {Map} DatabaseModels */ async function handleMigratePlaylists(DatabaseModels) { const oldPlaylists = await oldDbFiles.loadOldData('playlists') const newPlaylistRecords = migratePlaylists(oldPlaylists) for (const model in newPlaylistRecords) { Logger.info(`[dbMigration] Inserting ${newPlaylistRecords[model].length} ${model} rows`) await DatabaseModels[model].bulkCreate(newPlaylistRecords[model]) } } /** * Load old feeds and bulkCreate new Feed, FeedEpisode models * @param {Map} DatabaseModels */ async function handleMigrateFeeds(DatabaseModels) { const oldFeeds = await oldDbFiles.loadOldData('feeds') const newFeedRecords = migrateFeeds(oldFeeds) for (const model in newFeedRecords) { Logger.info(`[dbMigration] Inserting ${newFeedRecords[model].length} ${model} rows`) await DatabaseModels[model].bulkCreate(newFeedRecords[model]) } } module.exports.migrate = async (DatabaseModels) => { Logger.info(`[dbMigration] Starting migration`) const start = Date.now() // Migrate to Library and LibraryFolder models await handleMigrateLibraries(DatabaseModels) // Migrate EmailSettings, NotificationSettings and ServerSettings to Setting model await handleMigrateSettings(DatabaseModels) // Migrate Series, Author, LibraryItem, Book, Podcast await handleMigrateAuthorsSeriesAndLibraryItems(DatabaseModels) // Migrate User, MediaProgress await handleMigrateUsers(DatabaseModels) // Migrate PlaybackSession, Device await handleMigrateSessions(DatabaseModels) // Migrate Collection, CollectionBook await handleMigrateCollections(DatabaseModels) // Migrate Playlist, PlaylistMediaItem await handleMigratePlaylists(DatabaseModels) // Migrate Feed, FeedEpisode await handleMigrateFeeds(DatabaseModels) // Purge author images and cover images from cache try { const CachePath = Path.join(global.MetadataPath, 'cache') await fs.emptyDir(Path.join(CachePath, 'covers')) await fs.emptyDir(Path.join(CachePath, 'images')) } catch (error) { Logger.error(`[dbMigration] Failed to purge author/cover image cache`, error) } // Put all old db folders into a zipfile oldDb.zip await oldDbFiles.zipWrapOldDb() const elapsed = Date.now() - start Logger.info(`[dbMigration] Migration complete. Elapsed ${(elapsed / 1000).toFixed(2)}s`) } /** * @returns {boolean} true if old database exists */ module.exports.checkShouldMigrate = async () => { if (await oldDbFiles.checkHasOldDb()) return true return oldDbFiles.checkHasOldDbZip() } /** * Migration from 2.3.0 to 2.3.1 - create extraData columns in LibraryItem and PodcastEpisode * @param {QueryInterface} queryInterface */ async function migrationPatchNewColumns(queryInterface) { try { return queryInterface.sequelize.transaction(t => { return Promise.all([ queryInterface.addColumn('libraryItems', 'extraData', { type: DataTypes.JSON }, { transaction: t }), queryInterface.addColumn('podcastEpisodes', 'extraData', { type: DataTypes.JSON }, { transaction: t }), queryInterface.addColumn('libraries', 'extraData', { type: DataTypes.JSON }, { transaction: t }) ]) }) } catch (error) { Logger.error(`[dbMigration] Migration from 2.3.0+ column creation failed`, error) return false } } /** * Migration from 2.3.0 to 2.3.1 - old library item ids * @param {/src/Database} ctx */ async function handleOldLibraryItems(ctx) { const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems') const libraryItems = await ctx.models.libraryItem.getAllOldLibraryItems() const bulkUpdateItems = [] const bulkUpdateEpisodes = [] for (const libraryItem of libraryItems) { // Find matching old library item by ino const matchingOldLibraryItem = oldLibraryItems.find(oli => oli.ino === libraryItem.ino) if (matchingOldLibraryItem) { oldDbIdMap.libraryItems[matchingOldLibraryItem.id] = libraryItem.id bulkUpdateItems.push({ id: libraryItem.id, extraData: { oldLibraryItemId: matchingOldLibraryItem.id } }) if (libraryItem.media.episodes?.length && matchingOldLibraryItem.media.episodes?.length) { for (const podcastEpisode of libraryItem.media.episodes) { // Find matching old episode by audio file ino const matchingOldPodcastEpisode = matchingOldLibraryItem.media.episodes.find(oep => oep.audioFile?.ino && oep.audioFile.ino === podcastEpisode.audioFile?.ino) if (matchingOldPodcastEpisode) { oldDbIdMap.podcastEpisodes[matchingOldPodcastEpisode.id] = podcastEpisode.id bulkUpdateEpisodes.push({ id: podcastEpisode.id, extraData: { oldEpisodeId: matchingOldPodcastEpisode.id } }) } } } } } if (bulkUpdateEpisodes.length) { await ctx.models.podcastEpisode.bulkCreate(bulkUpdateEpisodes, { updateOnDuplicate: ['extraData'] }) } if (bulkUpdateItems.length) { await ctx.models.libraryItem.bulkCreate(bulkUpdateItems, { updateOnDuplicate: ['extraData'] }) } Logger.info(`[dbMigration] Migration 2.3.0+: Updated ${bulkUpdateItems.length} library items & ${bulkUpdateEpisodes.length} episodes`) } /** * Migration from 2.3.0 to 2.3.1 - updating oldLibraryId * @param {/src/Database} ctx */ async function handleOldLibraries(ctx) { const oldLibraries = await oldDbFiles.loadOldData('libraries') const libraries = await ctx.models.library.getAllOldLibraries() let librariesUpdated = 0 for (const library of libraries) { // Find matching old library using exact match on folder paths, exact match on library name const matchingOldLibrary = oldLibraries.find(ol => { if (ol.name !== library.name) { return false } const folderPaths = ol.folders?.map(f => f.fullPath) || [] return folderPaths.join(',') === library.folderPaths.join(',') }) if (matchingOldLibrary) { library.oldLibraryId = matchingOldLibrary.id oldDbIdMap.libraries[library.oldLibraryId] = library.id await ctx.models.library.updateFromOld(library) librariesUpdated++ } } Logger.info(`[dbMigration] Migration 2.3.0+: Updated ${librariesUpdated} libraries`) } /** * Migration from 2.3.0 to 2.3.1 - fixing librariesAccessible and bookmarks * @param {/src/Database} ctx */ async function handleOldUsers(ctx) { const users = await ctx.models.user.getOldUsers() let usersUpdated = 0 for (const user of users) { let hasUpdates = false if (user.bookmarks?.length) { user.bookmarks = user.bookmarks.map(bm => { // Only update if this is not the old id format if (!bm.libraryItemId.startsWith('li_')) return bm bm.libraryItemId = oldDbIdMap.libraryItems[bm.libraryItemId] hasUpdates = true return bm }).filter(bm => bm.libraryItemId) } // Convert old library ids to new library ids if (user.librariesAccessible?.length) { user.librariesAccessible = user.librariesAccessible.map(lid => { if (!lid.startsWith('lib_') && lid !== 'main') return lid // Already not an old library id so dont change hasUpdates = true return oldDbIdMap.libraries[lid] }).filter(lid => lid) } if (user.seriesHideFromContinueListening?.length) { user.seriesHideFromContinueListening = user.seriesHideFromContinueListening.map((seriesId) => { if (seriesId.startsWith('se_')) { hasUpdates = true return null // Filter out old series ids } return seriesId }).filter(se => se) } if (hasUpdates) { await ctx.models.user.updateFromOld(user) usersUpdated++ } } Logger.info(`[dbMigration] Migration 2.3.0+: Updated ${usersUpdated} users`) } /** * Migration from 2.3.0 to 2.3.1 * @param {/src/Database} ctx */ module.exports.migrationPatch = async (ctx) => { const queryInterface = ctx.sequelize.getQueryInterface() const librariesTableDescription = await queryInterface.describeTable('libraries') if (librariesTableDescription?.extraData) { Logger.info(`[dbMigration] Migration patch 2.3.0+ - extraData columns already on model`) } else { const migrationResult = await migrationPatchNewColumns(queryInterface) if (migrationResult === false) { return } } const oldDbPath = Path.join(global.ConfigPath, 'oldDb.zip') if (!await fs.pathExists(oldDbPath)) { Logger.info(`[dbMigration] Migration patch 2.3.0+ unnecessary - no oldDb.zip found`) return } const migrationStart = Date.now() Logger.info(`[dbMigration] Applying migration patch from 2.3.0+`) // Extract from oldDb.zip if (!await oldDbFiles.checkExtractItemsUsersAndLibraries()) { return } await handleOldLibraryItems(ctx) await handleOldLibraries(ctx) await handleOldUsers(ctx) await oldDbFiles.removeOldItemsUsersAndLibrariesFolders() const elapsed = Date.now() - migrationStart Logger.info(`[dbMigration] Migration patch 2.3.0+ finished. Elapsed ${(elapsed / 1000).toFixed(2)}s`) } /** * Migration from 2.3.3 to 2.3.4 * Adding coverPath column to Feed model * @param {/src/Database} ctx */ module.exports.migrationPatch2 = async (ctx) => { const queryInterface = ctx.sequelize.getQueryInterface() const feedTableDescription = await queryInterface.describeTable('feeds') if (feedTableDescription?.coverPath) { Logger.info(`[dbMigration] Migration patch 2.3.3+ - coverPath column is already on model`) return } try { await queryInterface.sequelize.transaction(t => { return Promise.all([ queryInterface.addColumn('feeds', 'coverPath', { type: DataTypes.STRING }, { transaction: t }) ]) }) Logger.info(`[dbMigration] Migration patch 2.3.3+ finished`) } catch (error) { Logger.error(`[dbMigration] Migration from 2.3.3+ column creation failed`, error) throw new Error('Migration 2.3.3+ failed ' + error) } }