diff --git a/server/Database.js b/server/Database.js index 6df1dec4..f9b5f7a1 100644 --- a/server/Database.js +++ b/server/Database.js @@ -61,6 +61,7 @@ class Database { require('./models/BookChapter')(this.sequelize) require('./models/Genre')(this.sequelize) require('./models/BookGenre')(this.sequelize) + require('./models/PodcastGenre')(this.sequelize) require('./models/BookNarrator')(this.sequelize) require('./models/Series')(this.sequelize) require('./models/BookSeries')(this.sequelize) diff --git a/server/models/AudioBookmark.js b/server/models/AudioBookmark.js index c733a29e..fe50ed37 100644 --- a/server/models/AudioBookmark.js +++ b/server/models/AudioBookmark.js @@ -1,7 +1,5 @@ const { DataTypes, Model } = require('sequelize') -const uppercaseFirst = str => `${str[0].toUpperCase()}${str.substr(1)}` - /* * Polymorphic association: https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/ * Book has many AudioBookmark. PodcastEpisode has many AudioBookmark. @@ -10,7 +8,7 @@ module.exports = (sequelize) => { class AudioBookmark extends Model { getMediaItem(options) { if (!this.mediaItemType) return Promise.resolve(null) - const mixinMethodName = `get${uppercaseFirst(this.mediaItemType)}` + const mixinMethodName = `get${this.mediaItemType}` return this[mixinMethodName](options) } } @@ -21,7 +19,7 @@ module.exports = (sequelize) => { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - mediaItemId: DataTypes.UUIDV4, + MediaItemId: DataTypes.UUIDV4, mediaItemType: DataTypes.STRING, title: DataTypes.STRING, time: DataTypes.INTEGER @@ -32,29 +30,29 @@ module.exports = (sequelize) => { const { User, Book, PodcastEpisode } = sequelize.models Book.hasMany(AudioBookmark, { - foreignKey: 'mediaItemId', + foreignKey: 'MediaItemId', constraints: false, scope: { - mediaItemType: 'book' + mediaItemType: 'Book' } }) - AudioBookmark.belongsTo(Book, { foreignKey: 'mediaItemId', constraints: false }) + AudioBookmark.belongsTo(Book, { foreignKey: 'MediaItemId', constraints: false }) PodcastEpisode.hasMany(AudioBookmark, { - foreignKey: 'mediaItemId', + foreignKey: 'MediaItemId', constraints: false, scope: { - mediaItemType: 'podcastEpisode' + mediaItemType: 'PodcastEpisode' } }) - AudioBookmark.belongsTo(PodcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) + AudioBookmark.belongsTo(PodcastEpisode, { foreignKey: 'MediaItemId', constraints: false }) AudioBookmark.addHook('afterFind', findResult => { if (!Array.isArray(findResult)) findResult = [findResult] for (const instance of findResult) { - if (instance.mediaItemType === 'book' && instance.Book !== undefined) { + if (instance.mediaItemType === 'Book' && instance.Book !== undefined) { instance.MediaItem = instance.Book - } else if (instance.mediaItemType === 'podcastEpisode' && instance.PodcastEpisode !== undefined) { + } else if (instance.mediaItemType === 'PodcastEpisode' && instance.PodcastEpisode !== undefined) { instance.MediaItem = instance.PodcastEpisode } // To prevent mistakes: diff --git a/server/models/AudioTrack.js b/server/models/AudioTrack.js index ac208151..8a4b9f65 100644 --- a/server/models/AudioTrack.js +++ b/server/models/AudioTrack.js @@ -1,7 +1,5 @@ const { DataTypes, Model } = require('sequelize') -const uppercaseFirst = str => `${str[0].toUpperCase()}${str.substr(1)}` - /* * Polymorphic association: https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/ * Book has many AudioTrack. PodcastEpisode has one AudioTrack. @@ -10,7 +8,7 @@ module.exports = (sequelize) => { class AudioTrack extends Model { getMediaItem(options) { if (!this.mediaItemType) return Promise.resolve(null) - const mixinMethodName = `get${uppercaseFirst(this.mediaItemType)}` + const mixinMethodName = `get${this.mediaItemType}` return this[mixinMethodName](options) } } @@ -21,14 +19,16 @@ module.exports = (sequelize) => { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - mediaItemId: DataTypes.UUIDV4, + MediaItemId: DataTypes.UUIDV4, mediaItemType: DataTypes.STRING, index: DataTypes.INTEGER, - startOffset: DataTypes.INTEGER, - duration: DataTypes.INTEGER, + startOffset: DataTypes.FLOAT, + duration: DataTypes.FLOAT, title: DataTypes.STRING, mimeType: DataTypes.STRING, - codec: DataTypes.STRING + codec: DataTypes.STRING, + trackNumber: DataTypes.INTEGER, + discNumber: DataTypes.INTEGER }, { sequelize, modelName: 'AudioTrack' @@ -40,29 +40,29 @@ module.exports = (sequelize) => { AudioTrack.belongsTo(MediaFile) Book.hasMany(AudioTrack, { - foreignKey: 'mediaItemId', + foreignKey: 'MediaItemId', constraints: false, scope: { - mediaItemType: 'book' + mediaItemType: 'Book' } }) - AudioTrack.belongsTo(Book, { foreignKey: 'mediaItemId', constraints: false }) + AudioTrack.belongsTo(Book, { foreignKey: 'MediaItemId', constraints: false }) PodcastEpisode.hasOne(AudioTrack, { - foreignKey: 'mediaItemId', + foreignKey: 'MediaItemId', constraints: false, scope: { - mediaItemType: 'podcastEpisode' + mediaItemType: 'PodcastEpisode' } }) - AudioTrack.belongsTo(PodcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) + AudioTrack.belongsTo(PodcastEpisode, { foreignKey: 'MediaItemId', constraints: false }) AudioTrack.addHook('afterFind', findResult => { if (!Array.isArray(findResult)) findResult = [findResult] for (const instance of findResult) { - if (instance.mediaItemType === 'book' && instance.Book !== undefined) { + if (instance.mediaItemType === 'Book' && instance.Book !== undefined) { instance.MediaItem = instance.Book - } else if (instance.mediaItemType === 'podcastEpisode' && instance.PodcastEpisode !== undefined) { + } else if (instance.mediaItemType === 'PodcastEpisode' && instance.PodcastEpisode !== undefined) { instance.MediaItem = instance.PodcastEpisode } // To prevent mistakes: diff --git a/server/models/Book.js b/server/models/Book.js index a5f6bb68..b677c69a 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -30,8 +30,8 @@ module.exports = (sequelize) => { LibraryItem.hasOne(Book) Book.belongsTo(LibraryItem) - FileMetadata.hasOne(Book) - Book.belongsTo(FileMetadata, { as: 'ImageFile' }) // Ref: https://sequelize.org/docs/v6/core-concepts/assocs/#defining-an-alias + FileMetadata.hasOne(Book, { foreignKey: 'ImageFileId ' }) + Book.belongsTo(FileMetadata, { as: 'ImageFile', foreignKey: 'ImageFileId' }) // Ref: https://sequelize.org/docs/v6/core-concepts/assocs/#defining-an-alias EBookFile.hasOne(Book) Book.belongsTo(EBookFile) diff --git a/server/models/CollectionBook.js b/server/models/CollectionBook.js index 8767e95c..c4032ced 100644 --- a/server/models/CollectionBook.js +++ b/server/models/CollectionBook.js @@ -11,6 +11,8 @@ module.exports = (sequelize) => { } }, { sequelize, + timestamps: true, + updatedAt: false, modelName: 'CollectionBook' }) diff --git a/server/models/EBookFile.js b/server/models/EBookFile.js index 5ae5d629..05edf2e7 100644 --- a/server/models/EBookFile.js +++ b/server/models/EBookFile.js @@ -16,8 +16,8 @@ module.exports = (sequelize) => { const { FileMetadata } = sequelize.models - FileMetadata.hasOne(EBookFile) - EBookFile.belongsTo(FileMetadata) + FileMetadata.hasOne(EBookFile, { foreignKey: 'FileMetadataId' }) + EBookFile.belongsTo(FileMetadata, { as: 'FileMetadata', foreignKey: 'FileMetadataId' }) return EBookFile } \ No newline at end of file diff --git a/server/models/Feed.js b/server/models/Feed.js index 30ba6e32..8849a292 100644 --- a/server/models/Feed.js +++ b/server/models/Feed.js @@ -1,7 +1,5 @@ const { DataTypes, Model } = require('sequelize') -const uppercaseFirst = str => `${str[0].toUpperCase()}${str.substr(1)}` - /* * Polymorphic association: https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/ * Feeds can be created from LibraryItem, Collection, Playlist or Series @@ -10,7 +8,7 @@ module.exports = (sequelize) => { class Feed extends Model { getEntity(options) { if (!this.entityType) return Promise.resolve(null) - const mixinMethodName = `get${uppercaseFirst(this.entityType)}` + const mixinMethodName = `get${this.entityType}` return this[mixinMethodName](options) } } @@ -23,7 +21,7 @@ module.exports = (sequelize) => { }, slug: DataTypes.STRING, entityType: DataTypes.STRING, - entityId: DataTypes.UUIDV4, + EntityId: DataTypes.UUIDV4, entityUpdatedAt: DataTypes.DATE, serverAddress: DataTypes.STRING, feedURL: DataTypes.STRING, @@ -49,51 +47,51 @@ module.exports = (sequelize) => { Feed.belongsTo(User) LibraryItem.hasMany(Feed, { - foreignKey: 'entityId', + foreignKey: 'EntityId', constraints: false, scope: { - entityType: 'libraryItem' + entityType: 'LibraryItem' } }) - Feed.belongsTo(LibraryItem, { foreignKey: 'entityId', constraints: false }) + Feed.belongsTo(LibraryItem, { foreignKey: 'EntityId', constraints: false }) Collection.hasMany(Feed, { - foreignKey: 'entityId', + foreignKey: 'EntityId', constraints: false, scope: { - entityType: 'collection' + entityType: 'Collection' } }) - Feed.belongsTo(Collection, { foreignKey: 'entityId', constraints: false }) + Feed.belongsTo(Collection, { foreignKey: 'EntityId', constraints: false }) Series.hasMany(Feed, { - foreignKey: 'entityId', + foreignKey: 'EntityId', constraints: false, scope: { - entityType: 'series' + entityType: 'Series' } }) - Feed.belongsTo(Series, { foreignKey: 'entityId', constraints: false }) + Feed.belongsTo(Series, { foreignKey: 'EntityId', constraints: false }) Playlist.hasMany(Feed, { - foreignKey: 'entityId', + foreignKey: 'EntityId', constraints: false, scope: { - entityType: 'playlist' + entityType: 'Playlist' } }) - Feed.belongsTo(Playlist, { foreignKey: 'entityId', constraints: false }) + Feed.belongsTo(Playlist, { foreignKey: 'EntityId', constraints: false }) Feed.addHook('afterFind', findResult => { if (!Array.isArray(findResult)) findResult = [findResult] for (const instance of findResult) { - if (instance.entityType === 'libraryItem' && instance.LibraryItem !== undefined) { + if (instance.entityType === 'LibraryItem' && instance.LibraryItem !== undefined) { instance.Entity = instance.LibraryItem - } else if (instance.mediaItemType === 'collection' && instance.Collection !== undefined) { + } else if (instance.mediaItemType === 'Collection' && instance.Collection !== undefined) { instance.Entity = instance.Collection - } else if (instance.mediaItemType === 'series' && instance.Series !== undefined) { + } else if (instance.mediaItemType === 'Series' && instance.Series !== undefined) { instance.Entity = instance.Series - } else if (instance.mediaItemType === 'playlist' && instance.Playlist !== undefined) { + } else if (instance.mediaItemType === 'Playlist' && instance.Playlist !== undefined) { instance.Entity = instance.Playlist } diff --git a/server/models/FeedEpisode.js b/server/models/FeedEpisode.js index ed99a539..3b00d774 100644 --- a/server/models/FeedEpisode.js +++ b/server/models/FeedEpisode.js @@ -20,7 +20,7 @@ module.exports = (sequelize) => { season: DataTypes.STRING, episode: DataTypes.STRING, episodeType: DataTypes.STRING, - duration: DataTypes.INTEGER, + duration: DataTypes.FLOAT, filePath: DataTypes.STRING, explicit: DataTypes.BOOLEAN }, { diff --git a/server/models/FileMetadata.js b/server/models/FileMetadata.js index 31c84797..ff290eb7 100644 --- a/server/models/FileMetadata.js +++ b/server/models/FileMetadata.js @@ -20,6 +20,10 @@ module.exports = (sequelize) => { }, { sequelize, freezeTableName: true, // sequelize uses datum as singular of data + name: { + singular: 'FileMetadata', + plural: 'FileMetadata' + }, modelName: 'FileMetadata' }) diff --git a/server/models/Genre.js b/server/models/Genre.js index 52823ae1..5c022879 100644 --- a/server/models/Genre.js +++ b/server/models/Genre.js @@ -9,7 +9,8 @@ module.exports = (sequelize) => { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - name: DataTypes.STRING + name: DataTypes.STRING, + cleanName: DataTypes.STRING }, { sequelize, modelName: 'Genre' diff --git a/server/models/LibraryFile.js b/server/models/LibraryFile.js index f4839a15..54c93a4f 100644 --- a/server/models/LibraryFile.js +++ b/server/models/LibraryFile.js @@ -18,8 +18,8 @@ module.exports = (sequelize) => { LibraryItem.hasMany(LibraryFile) LibraryFile.belongsTo(LibraryItem) - FileMetadata.hasOne(LibraryFile) - LibraryFile.belongsTo(FileMetadata) + FileMetadata.hasOne(LibraryFile, { foreignKey: 'FileMetadataId' }) + LibraryFile.belongsTo(FileMetadata, { as: 'FileMetadata', foreignKey: 'FileMetadataId' }) return LibraryFile } \ No newline at end of file diff --git a/server/models/MediaFile.js b/server/models/MediaFile.js index d4937aba..da0f1f60 100644 --- a/server/models/MediaFile.js +++ b/server/models/MediaFile.js @@ -22,8 +22,8 @@ module.exports = (sequelize) => { const { FileMetadata } = sequelize.models - FileMetadata.hasOne(MediaFile) - MediaFile.belongsTo(FileMetadata) + FileMetadata.hasOne(MediaFile, { foreignKey: 'FileMetadataId' }) + MediaFile.belongsTo(FileMetadata, { as: 'FileMetadata', foreignKey: 'FileMetadataId' }) return MediaFile } \ No newline at end of file diff --git a/server/models/MediaProgress.js b/server/models/MediaProgress.js index 296f9338..c45a136d 100644 --- a/server/models/MediaProgress.js +++ b/server/models/MediaProgress.js @@ -1,7 +1,5 @@ const { DataTypes, Model } = require('sequelize') -const uppercaseFirst = str => `${str[0].toUpperCase()}${str.substr(1)}` - /* * Polymorphic association: https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/ * Book has many MediaProgress. PodcastEpisode has many MediaProgress. @@ -10,7 +8,7 @@ module.exports = (sequelize) => { class MediaProgress extends Model { getMediaItem(options) { if (!this.mediaItemType) return Promise.resolve(null) - const mixinMethodName = `get${uppercaseFirst(this.mediaItemType)}` + const mixinMethodName = `get${this.mediaItemType}` return this[mixinMethodName](options) } } @@ -21,10 +19,10 @@ module.exports = (sequelize) => { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - mediaItemId: DataTypes.UUIDV4, + MediaItemId: DataTypes.UUIDV4, mediaItemType: DataTypes.STRING, - duration: DataTypes.INTEGER, - currentTime: DataTypes.INTEGER, + duration: DataTypes.FLOAT, + currentTime: DataTypes.FLOAT, isFinished: DataTypes.BOOLEAN, hideFromContinueListening: DataTypes.BOOLEAN, finishedAt: DataTypes.DATE @@ -35,29 +33,29 @@ module.exports = (sequelize) => { const { Book, PodcastEpisode, User } = sequelize.models Book.hasMany(MediaProgress, { - foreignKey: 'mediaItemId', + foreignKey: 'MediaItemId', constraints: false, scope: { - mediaItemType: 'book' + mediaItemType: 'Book' } }) - MediaProgress.belongsTo(Book, { foreignKey: 'mediaItemId', constraints: false }) + MediaProgress.belongsTo(Book, { foreignKey: 'MediaItemId', constraints: false }) PodcastEpisode.hasMany(MediaProgress, { - foreignKey: 'mediaItemId', + foreignKey: 'MediaItemId', constraints: false, scope: { - mediaItemType: 'podcastEpisode' + mediaItemType: 'PodcastEpisode' } }) - MediaProgress.belongsTo(PodcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) + MediaProgress.belongsTo(PodcastEpisode, { foreignKey: 'MediaItemId', constraints: false }) MediaProgress.addHook('afterFind', findResult => { if (!Array.isArray(findResult)) findResult = [findResult] for (const instance of findResult) { - if (instance.mediaItemType === 'book' && instance.Book !== undefined) { + if (instance.mediaItemType === 'Book' && instance.Book !== undefined) { instance.MediaItem = instance.Book - } else if (instance.mediaItemType === 'podcastEpisode' && instance.PodcastEpisode !== undefined) { + } else if (instance.mediaItemType === 'PodcastEpisode' && instance.PodcastEpisode !== undefined) { instance.MediaItem = instance.PodcastEpisode } // To prevent mistakes: diff --git a/server/models/Notification.js b/server/models/Notification.js index ce768e69..485d169c 100644 --- a/server/models/Notification.js +++ b/server/models/Notification.js @@ -10,7 +10,7 @@ module.exports = (sequelize) => { primaryKey: true }, eventName: DataTypes.STRING, - urls: DataTypes.TEXT, // JSON array of urls + urls: DataTypes.JSON, // JSON array of urls titleTemplate: DataTypes.STRING(1000), bodyTemplate: DataTypes.TEXT, type: DataTypes.STRING, @@ -18,16 +18,12 @@ module.exports = (sequelize) => { lastAttemptFailed: DataTypes.BOOLEAN, numConsecutiveFailedAttempts: DataTypes.INTEGER, numTimesFired: DataTypes.INTEGER, - enabled: DataTypes.BOOLEAN + enabled: DataTypes.BOOLEAN, + extraData: DataTypes.JSON }, { sequelize, modelName: 'Notification' }) - const { Library } = sequelize.models - - Library.hasMany(Notification) - Notification.belongsTo(Library) - return Notification } \ No newline at end of file diff --git a/server/models/Person.js b/server/models/Person.js index 6bdccf11..e38b1cde 100644 --- a/server/models/Person.js +++ b/server/models/Person.js @@ -19,8 +19,8 @@ module.exports = (sequelize) => { }) const { FileMetadata } = sequelize.models - FileMetadata.hasMany(Person) - Person.belongsTo(FileMetadata, { as: 'ImageFile' }) // Ref: https://sequelize.org/docs/v6/core-concepts/assocs/#defining-an-alias + FileMetadata.hasMany(Person, { foreignKey: 'ImageFileId' }) + Person.belongsTo(FileMetadata, { as: 'ImageFile', foreignKey: 'ImageFileId' }) // Ref: https://sequelize.org/docs/v6/core-concepts/assocs/#defining-an-alias return Person } \ No newline at end of file diff --git a/server/models/PlaybackSession.js b/server/models/PlaybackSession.js index 9e13bad7..b471d913 100644 --- a/server/models/PlaybackSession.js +++ b/server/models/PlaybackSession.js @@ -1,12 +1,10 @@ const { DataTypes, Model } = require('sequelize') -const uppercaseFirst = str => `${str[0].toUpperCase()}${str.substr(1)}` - module.exports = (sequelize) => { class PlaybackSession extends Model { getMediaItem(options) { if (!this.mediaItemType) return Promise.resolve(null) - const mixinMethodName = `get${uppercaseFirst(this.mediaItemType)}` + const mixinMethodName = `get${this.mediaItemType}` return this[mixinMethodName](options) } } @@ -17,16 +15,15 @@ module.exports = (sequelize) => { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - mediaItemId: DataTypes.UUIDV4, + MediaItemId: DataTypes.UUIDV4, mediaItemType: DataTypes.STRING, displayTitle: DataTypes.STRING, displayAuthor: DataTypes.STRING, - duration: DataTypes.INTEGER, + duration: DataTypes.FLOAT, playMethod: DataTypes.STRING, mediaPlayer: DataTypes.STRING, - startTime: DataTypes.INTEGER, - currentTime: DataTypes.INTEGER, - timeListening: DataTypes.INTEGER, + startTime: DataTypes.FLOAT, + currentTime: DataTypes.FLOAT, serverVersion: DataTypes.STRING }, { sequelize, @@ -42,29 +39,29 @@ module.exports = (sequelize) => { PlaybackSession.belongsTo(Device) Book.hasMany(PlaybackSession, { - foreignKey: 'mediaItemId', + foreignKey: 'MediaItemId', constraints: false, scope: { - mediaItemType: 'book' + mediaItemType: 'Book' } }) - PlaybackSession.belongsTo(Book, { foreignKey: 'mediaItemId', constraints: false }) + PlaybackSession.belongsTo(Book, { foreignKey: 'MediaItemId', constraints: false }) PodcastEpisode.hasOne(PlaybackSession, { - foreignKey: 'mediaItemId', + foreignKey: 'MediaItemId', constraints: false, scope: { - mediaItemType: 'podcastEpisode' + mediaItemType: 'PodcastEpisode' } }) - PlaybackSession.belongsTo(PodcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) + PlaybackSession.belongsTo(PodcastEpisode, { foreignKey: 'MediaItemId', constraints: false }) PlaybackSession.addHook('afterFind', findResult => { if (!Array.isArray(findResult)) findResult = [findResult] for (const instance of findResult) { - if (instance.mediaItemType === 'book' && instance.Book !== undefined) { + if (instance.mediaItemType === 'Book' && instance.Book !== undefined) { instance.MediaItem = instance.Book - } else if (instance.mediaItemType === 'podcastEpisode' && instance.PodcastEpisode !== undefined) { + } else if (instance.mediaItemType === 'PodcastEpisode' && instance.PodcastEpisode !== undefined) { instance.MediaItem = instance.PodcastEpisode } // To prevent mistakes: diff --git a/server/models/PlaylistMediaItem.js b/server/models/PlaylistMediaItem.js index bbc8aa5d..0e6c14c8 100644 --- a/server/models/PlaylistMediaItem.js +++ b/server/models/PlaylistMediaItem.js @@ -1,12 +1,10 @@ const { DataTypes, Model } = require('sequelize') -const uppercaseFirst = str => `${str[0].toUpperCase()}${str.substr(1)}` - module.exports = (sequelize) => { class PlaylistMediaItem extends Model { getMediaItem(options) { if (!this.mediaItemType) return Promise.resolve(null) - const mixinMethodName = `get${uppercaseFirst(this.mediaItemType)}` + const mixinMethodName = `get${this.mediaItemType}` return this[mixinMethodName](options) } } @@ -17,39 +15,41 @@ module.exports = (sequelize) => { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - mediaItemId: DataTypes.UUIDV4, + MediaItemId: DataTypes.UUIDV4, mediaItemType: DataTypes.STRING }, { sequelize, + timestamps: true, + updatedAt: false, modelName: 'PlaylistMediaItem' }) const { Book, PodcastEpisode, Playlist } = sequelize.models Book.hasMany(PlaylistMediaItem, { - foreignKey: 'mediaItemId', + foreignKey: 'MediaItemId', constraints: false, scope: { - mediaItemType: 'book' + mediaItemType: 'Book' } }) - PlaylistMediaItem.belongsTo(Book, { foreignKey: 'mediaItemId', constraints: false }) + PlaylistMediaItem.belongsTo(Book, { foreignKey: 'MediaItemId', constraints: false }) PodcastEpisode.hasOne(PlaylistMediaItem, { - foreignKey: 'mediaItemId', + foreignKey: 'MediaItemId', constraints: false, scope: { - mediaItemType: 'podcastEpisode' + mediaItemType: 'PodcastEpisode' } }) - PlaylistMediaItem.belongsTo(PodcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) + PlaylistMediaItem.belongsTo(PodcastEpisode, { foreignKey: 'MediaItemId', constraints: false }) PlaylistMediaItem.addHook('afterFind', findResult => { if (!Array.isArray(findResult)) findResult = [findResult] for (const instance of findResult) { - if (instance.mediaItemType === 'book' && instance.Book !== undefined) { + if (instance.mediaItemType === 'Book' && instance.Book !== undefined) { instance.MediaItem = instance.Book - } else if (instance.mediaItemType === 'podcastEpisode' && instance.PodcastEpisode !== undefined) { + } else if (instance.mediaItemType === 'PodcastEpisode' && instance.PodcastEpisode !== undefined) { instance.MediaItem = instance.PodcastEpisode } // To prevent mistakes: diff --git a/server/models/Podcast.js b/server/models/Podcast.js index 9bfdf81f..cfb3f2dd 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -13,14 +13,14 @@ module.exports = (sequelize) => { title: DataTypes.STRING, author: DataTypes.STRING, releaseDate: DataTypes.STRING, - feedUrl: DataTypes.STRING, - imageUrl: DataTypes.STRING, + feedURL: DataTypes.STRING, + imageURL: DataTypes.STRING, description: DataTypes.TEXT, - itunesPageUrl: DataTypes.STRING, + itunesPageURL: DataTypes.STRING, itunesId: DataTypes.STRING, itunesArtistId: DataTypes.STRING, language: DataTypes.STRING, - type: DataTypes.STRING, + podcastType: DataTypes.STRING, explicit: DataTypes.BOOLEAN, autoDownloadEpisodes: DataTypes.BOOLEAN, @@ -39,8 +39,8 @@ module.exports = (sequelize) => { LibraryItem.hasOne(Podcast) Podcast.belongsTo(LibraryItem) - FileMetadata.hasOne(Podcast) - Podcast.belongsTo(FileMetadata, { as: 'ImageFile' }) // Ref: https://sequelize.org/docs/v6/core-concepts/assocs/#defining-an-alias + FileMetadata.hasOne(Podcast, { foreignKey: 'ImageFileId' }) + Podcast.belongsTo(FileMetadata, { as: 'ImageFile', foreignKey: 'ImageFileId' }) // Ref: https://sequelize.org/docs/v6/core-concepts/assocs/#defining-an-alias return Podcast } \ No newline at end of file diff --git a/server/models/PodcastGenre.js b/server/models/PodcastGenre.js new file mode 100644 index 00000000..dec9fc6d --- /dev/null +++ b/server/models/PodcastGenre.js @@ -0,0 +1,31 @@ +const { DataTypes, Model } = require('sequelize') + +module.exports = (sequelize) => { + class PodcastGenre extends Model { } + + PodcastGenre.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + } + }, { + sequelize, + modelName: 'PodcastGenre', + timestamps: false + }) + + // Super Many-to-Many + // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship + const { Podcast, Genre } = sequelize.models + Podcast.belongsToMany(Genre, { through: PodcastGenre }) + Genre.belongsToMany(Podcast, { through: PodcastGenre }) + + Podcast.hasMany(PodcastGenre) + PodcastGenre.belongsTo(Podcast) + + Genre.hasMany(PodcastGenre) + PodcastGenre.belongsTo(Genre) + + return PodcastGenre +} \ No newline at end of file diff --git a/server/models/Tag.js b/server/models/Tag.js index 4268f4af..12ae4a54 100644 --- a/server/models/Tag.js +++ b/server/models/Tag.js @@ -9,7 +9,8 @@ module.exports = (sequelize) => { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - name: DataTypes.STRING + name: DataTypes.STRING, + cleanName: DataTypes.STRING }, { sequelize, modelName: 'Tag' diff --git a/server/models/User.js b/server/models/User.js index d085f6e1..e81cb353 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -22,7 +22,8 @@ module.exports = (sequelize) => { type: DataTypes.BOOLEAN, defaultValue: false }, - lastSeen: DataTypes.DATE + lastSeen: DataTypes.DATE, + extraData: DataTypes.JSON }, { sequelize, modelName: 'User' diff --git a/server/utils/migrations/dbMigration.js b/server/utils/migrations/dbMigration.js index d3679cf5..ca951ee2 100644 --- a/server/utils/migrations/dbMigration.js +++ b/server/utils/migrations/dbMigration.js @@ -1,33 +1,145 @@ +const Path = require('path') const uuidv4 = require("uuid").v4 const package = require('../../../package.json') +const { AudioMimeType } = require('../constants') const Logger = require('../../Logger') const Database = require('../../Database') const oldDbFiles = require('./oldDbFiles') +const dateAndTime = require('../../libs/dateAndTime') const oldDbIdMap = { users: {}, libraries: {}, libraryFolders: {}, libraryItems: {}, - books: {}, - tags: {} + tags: {}, // key is tag string + genres: {}, // key is genre string + people: {}, // key is author id or narrator name clean + series: {}, + collections: {}, + files: {}, // key is fullpath + podcastEpisodes: {}, + books: {}, // key is library item id + devices: {} // key is a json stringify of the old DeviceInfo data } const newRecords = { User: [], + UserPermission: [], Library: [], LibraryFolder: [], LibrarySetting: [], + FileMetadata: [], + Person: [], LibraryItem: [], + LibraryFile: [], + EBookFile: [], Book: [], + BookAuthor: [], + BookNarrator: [], BookChapter: [], Tag: [], BookTag: [], - Podcast: [] + Genre: [], + BookGenre: [], + Series: [], + BookSeries: [], + Podcast: [], + PodcastTag: [], + PodcastGenre: [], + PodcastEpisode: [], + MediaProgress: [], + AudioBookmark: [], + MediaFile: [], + MediaStream: [], + AudioTrack: [], + Device: [], + PlaybackSession: [], + PlaybackSessionListenTime: [], + Collection: [], + CollectionBook: [], + Playlist: [], + PlaylistMediaItem: [], + Feed: [], + FeedEpisode: [], + Setting: [], + Notification: [] +} + +function getDeviceInfoString(deviceInfo, UserId) { + if (!deviceInfo) return null + const dupe = { ...deviceInfo } + dupe.UserId = UserId + delete dupe.serverVersion + return JSON.stringify(dupe) +} + +function getMimeType(formatName) { + const format = formatName.toUpperCase() + if (AudioMimeType[format]) { + return AudioMimeType[format] + } else { + return AudioMimeType.MP3 + } +} + +function cleanAudioFileMetaTags(metaTags) { + if (!metaTags) return {} + + const cleaned = {} + for (const tag in metaTags) { + if (tag.startsWith('tag') && metaTags[tag]) { + const tagName = tag.substring(3) + cleaned[tagName] = metaTags[tag] + } + } + + return cleaned } function migrateBook(oldLibraryItem, LibraryItem) { const oldBook = oldLibraryItem.media + // + // Migrate ImageFile + // + let ImageFileId = null + if (oldBook.coverPath) { + ImageFileId = oldDbIdMap.files[oldBook.coverPath] || null + if (!ImageFileId) { + const FileMetadata = { + id: uuidv4(), + filename: Path.basename(oldBook.coverPath), + ext: Path.extname(oldBook.coverPath), + path: oldBook.coverPath, + createdAt: LibraryItem.createdAt, + updatedAt: LibraryItem.updatedAt + } + newRecords.FileMetadata.push(FileMetadata) + oldDbIdMap.files[oldBook.coverPath] = FileMetadata.id + ImageFileId = FileMetadata.id + } + } + + // + // Migrate EBookFile + // + let EBookFileId = null + if (oldBook.ebookFile) { + if (oldDbIdMap.files[oldBook.ebookFile.metadata?.path]) { + const EBookFile = { + id: uuidv4(), + FileMetadataId: oldDbIdMap.files[oldBook.ebookFile.metadata?.path] + } + newRecords.EBookFile.push(EBookFile) + EBookFileId = EBookFile.id + } else { + Logger.warn(`[dbMigration] migrateBook: `) + } + } + + // + // Migrate Book + // const Book = { id: uuidv4(), title: oldBook.metadata.title, @@ -42,30 +154,119 @@ function migrateBook(oldLibraryItem, LibraryItem) { explicit: !!oldBook.metadata.explicit, lastCoverSearchQuery: oldBook.lastCoverSearchQuery, lastCoverSearch: oldBook.lastCoverSearch, - LibraryItemId: LibraryItem.id, createdAt: LibraryItem.createdAt, - updatedAt: LibraryItem.updatedAt + updatedAt: LibraryItem.updatedAt, + ImageFileId, + EBookFileId, + LibraryItemId: LibraryItem.id, } - oldDbIdMap.books[oldLibraryItem.id] = Book.id newRecords.Book.push(Book) + oldDbIdMap.books[oldLibraryItem.id] = Book.id - // TODO: Handle cover image record - // TODO: Handle EBook record + // + // Migrate AudioTracks + // + const oldAudioFiles = oldBook.audioFiles + let startOffset = 0 + for (const oldAudioFile of oldAudioFiles) { + const FileMetadataId = oldDbIdMap.files[oldAudioFile.metadata.path] + if (!FileMetadataId) { + Logger.warn(`[dbMigration] migrateBook: File metadata not found for audio file "${oldAudioFile.metadata.path}"`) + continue + } - // Logger.info(`[dbMigration] migrateBook: Book migrated "${Book.title}" (${Book.id})`) + const ext = oldAudioFile.metadata.ext || '' + const MediaFile = { + id: uuidv4(), + formatName: ext.slice(1).toLowerCase(), + formatNameLong: oldAudioFile.format, + duration: oldAudioFile.duration, + bitrate: oldAudioFile.bitRate, + size: oldAudioFile.metadata.size, + tags: cleanAudioFileMetaTags(oldAudioFile.metaTags), + createdAt: LibraryItem.createdAt, + updatedAt: LibraryItem.updatedAt, + FileMetadataId + } + newRecords.MediaFile.push(MediaFile) + + const MediaStream = { + id: uuidv4(), + index: null, + codecType: 'audio', + codec: oldAudioFile.codec, + channels: oldAudioFile.channels, + channelLayout: oldAudioFile.channelLayout, + bitrate: oldAudioFile.bitRate, + timeBase: oldAudioFile.timeBase, + duration: oldAudioFile.duration, + sampleRate: null, + language: oldAudioFile.language, + default: true, + chapters: oldAudioFile.chapters, + createdAt: LibraryItem.createdAt, + updatedAt: LibraryItem.updatedAt, + MediaFileId: MediaFile.id + } + newRecords.MediaStream.push(MediaStream) + + if (oldAudioFile.embeddedCoverArt) { + const CoverMediaStream = { + id: uuidv4(), + index: null, + codecType: 'video', + codec: oldAudioFile.embeddedCoverArt, + default: true, + createdAt: LibraryItem.createdAt, + updatedAt: LibraryItem.updatedAt, + MediaFileId: MediaFile.id + } + newRecords.MediaStream.push(CoverMediaStream) + } + + const include = !oldAudioFile.exclude && !oldAudioFile.invalid + + const AudioTrack = { + id: uuidv4(), + MediaItemId: Book.id, + mediaItemType: 'Book', + index: oldAudioFile.index, + startOffset: include ? startOffset : null, + duration: oldAudioFile.duration, + title: oldAudioFile.metadata.filename, + mimeType: getMimeType(MediaFile.formatName), + codec: oldAudioFile.codec, + trackNumber: oldAudioFile.trackNumFromMeta || oldAudioFile.trackNumFromFilename, + discNumber: oldAudioFile.discNumFromMeta || oldAudioFile.discNumFromFilename, + createdAt: LibraryItem.createdAt, + updatedAt: LibraryItem.updatedAt, + MediaFileId: MediaFile.id + } + newRecords.AudioTrack.push(AudioTrack) + + if (include) { + startOffset += AudioTrack.duration + } + } + + // + // Migrate Tags + // const oldTags = oldBook.tags || [] for (const oldTag of oldTags) { - let tagId = null - if (oldDbIdMap[oldTag]) { - tagId = oldDbIdMap[oldTag] - } else { + const oldTagCleaned = oldTag.trim().toLowerCase() + let tagId = oldDbIdMap.tags[oldTagCleaned] + + if (!tagId) { const Tag = { id: uuidv4(), - name: oldTag + name: oldTag, + cleanName: oldTagCleaned, + createdAt: LibraryItem.createdAt, + updatedAt: LibraryItem.updatedAt } tagId = Tag.id - newRecords.Tag.push(Tag) } @@ -76,34 +277,333 @@ function migrateBook(oldLibraryItem, LibraryItem) { }) } + // + // Migrate BookChapters + // for (const oldChapter of oldBook.chapters) { - const BookChapter = { + newRecords.BookChapter.push({ id: uuidv4(), index: oldChapter.id, start: oldChapter.start, end: oldChapter.end, title: oldChapter.title, + createdAt: LibraryItem.createdAt, + updatedAt: LibraryItem.updatedAt, BookId: Book.id + }) + } + + + // + // Migrate Genres + // + const oldGenres = oldBook.metadata.genres || [] + for (const oldGenre of oldGenres) { + const oldGenreCleaned = oldGenre.trim().toLowerCase() + let genreId = oldDbIdMap.genres[oldGenreCleaned] + + if (!genreId) { + const Genre = { + id: uuidv4(), + name: oldGenre, + cleanName: oldGenreCleaned, + createdAt: LibraryItem.createdAt, + updatedAt: LibraryItem.updatedAt + } + genreId = Genre.id + newRecords.Genre.push(Genre) + } + + newRecords.BookGenre.push({ + id: uuidv4(), + BookId: Book.id, + GenreId: genreId + }) + } + + // + // Migrate BookAuthors + // + for (const oldBookAuthor of oldBook.metadata.authors) { + if (oldDbIdMap.people[oldBookAuthor.id]) { + newRecords.BookAuthor.push({ + id: uuidv4(), + PersonId: oldDbIdMap.people[oldBookAuthor.id], + BookId: Book.id + }) + } else { + Logger.warn(`[dbMigration] migrateBook: Book author not found "${oldBookAuthor.name}"`) + } + } + + // + // Migrate BookNarrators + // + for (const oldBookNarrator of oldBook.metadata.narrators) { + let PersonId = oldDbIdMap.people[oldBookNarrator] + if (!PersonId) { + const Person = { + id: uuidv4(), + type: 'Narrator', + name: oldBookNarrator, + createdAt: LibraryItem.createdAt, + updatedAt: LibraryItem.updatedAt + } + newRecords.Person.push(Person) + PersonId = Person.id + } + + newRecords.BookNarrator.push({ + id: uuidv4(), + PersonId, + BookId: Book.id + }) + } + + // + // Migrate BookSeries + // + for (const oldBookSeries of oldBook.metadata.series) { + if (oldDbIdMap.series[oldBookSeries.id]) { + const BookSeries = { + id: uuidv4(), + sequence: oldBookSeries.sequence, + SeriesId: oldDbIdMap.series[oldBookSeries.id], + BookId: Book.id + } + newRecords.BookSeries.push(BookSeries) + } else { + Logger.warn(`[dbMigration] migrateBook: Series not found "${oldBookSeries.name}"`) } - newRecords.BookChapter.push(BookChapter) } } function migratePodcast(oldLibraryItem, LibraryItem) { - // TODO: Migrate podcast + const oldPodcast = oldLibraryItem.media + const oldPodcastMetadata = oldPodcast.metadata + + // + // Migrate ImageFile + // + let ImageFileId = null + if (oldPodcast.coverPath) { + ImageFileId = oldDbIdMap.files[oldPodcast.coverPath] || null + if (!ImageFileId) { + const FileMetadata = { + id: uuidv4(), + filename: Path.basename(oldPodcast.coverPath), + ext: Path.extname(oldPodcast.coverPath), + path: oldPodcast.coverPath, + createdAt: LibraryItem.createdAt, + updatedAt: LibraryItem.updatedAt + } + newRecords.FileMetadata.push(FileMetadata) + oldDbIdMap.files[oldPodcast.coverPath] = FileMetadata.id + ImageFileId = FileMetadata.id + } + } + + // + // 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, + maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload, + lastCoverSearchQuery: oldPodcast.lastCoverSearchQuery, + lastCoverSearch: oldPodcast.lastCoverSearch, + createdAt: LibraryItem.createdAt, + updatedAt: LibraryItem.updatedAt, + ImageFileId, + LibraryItemId: LibraryItem.id + } + newRecords.Podcast.push(Podcast) + + // + // Migrate Tags + // + const oldTags = oldPodcast.tags || [] + for (const oldTag of oldTags) { + const oldTagCleaned = oldTag.trim().toLowerCase() + let tagId = oldDbIdMap.tags[oldTagCleaned] + + if (!tagId) { + const Tag = { + id: uuidv4(), + name: oldTag, + cleanName: oldTagCleaned, + createdAt: LibraryItem.createdAt, + updatedAt: LibraryItem.updatedAt + } + tagId = Tag.id + newRecords.Tag.push(Tag) + } + + newRecords.PodcastTag.push({ + id: uuidv4(), + PodcastId: Podcast.id, + TagId: tagId + }) + } + + // + // Migrate Genres + // + const oldGenres = oldPodcastMetadata.genres || [] + for (const oldGenre of oldGenres) { + const oldGenreCleaned = oldGenre.trim().toLowerCase() + let genreId = oldDbIdMap.genres[oldGenreCleaned] + + if (!genreId) { + const Genre = { + id: uuidv4(), + name: oldGenre, + cleanName: oldGenreCleaned, + createdAt: LibraryItem.createdAt, + updatedAt: LibraryItem.updatedAt + } + genreId = Genre.id + newRecords.Genre.push(Genre) + } + + newRecords.PodcastGenre.push({ + id: uuidv4(), + PodcastId: Podcast.id, + GenreId: genreId + }) + } + + // + // Migrate PodcastEpisodes + // + const oldEpisodes = oldPodcast.episodes || [] + for (const oldEpisode of oldEpisodes) { + const oldAudioFile = oldEpisode.audioFile + const FileMetadataId = oldDbIdMap.files[oldAudioFile.metadata.path] + if (!FileMetadataId) { + Logger.warn(`[dbMigration] migratePodcast: File metadata not found for audio file "${oldAudioFile.metadata.path}"`) + continue + } + + const PodcastEpisode = { + id: uuidv4(), + index: oldEpisode.index, + season: oldEpisode.season, + episode: oldEpisode.episode, + episodeType: oldEpisode.episodeType, + title: oldEpisode.title, + subtitle: oldEpisode.subtitle, + description: oldEpisode.description, + pubDate: oldEpisode.pubDate, + enclosureURL: oldEpisode.enclosure?.url || null, + enclosureSize: oldEpisode.enclosure?.length || null, + enclosureType: oldEpisode.enclosure?.type || null, + publishedAt: oldEpisode.publishedAt, + createdAt: oldEpisode.addedAt, + updatedAt: oldEpisode.updatedAt, + PodcastId: Podcast.id + } + newRecords.PodcastEpisode.push(PodcastEpisode) + oldDbIdMap.podcastEpisodes[oldEpisode.id] = PodcastEpisode.id + + // + // Migrate AudioTrack + // + const ext = oldAudioFile.metadata.ext || '' + const MediaFile = { + id: uuidv4(), + formatName: ext.slice(1).toLowerCase(), + formatNameLong: oldAudioFile.format, + duration: oldAudioFile.duration, + bitrate: oldAudioFile.bitRate, + size: oldAudioFile.metadata.size, + tags: cleanAudioFileMetaTags(oldAudioFile.metaTags), + createdAt: LibraryItem.createdAt, + updatedAt: LibraryItem.updatedAt, + FileMetadataId + } + newRecords.MediaFile.push(MediaFile) + + const MediaStream = { + id: uuidv4(), + index: null, + codecType: 'audio', + codec: oldAudioFile.codec, + channels: oldAudioFile.channels, + channelLayout: oldAudioFile.channelLayout, + bitrate: oldAudioFile.bitRate, + timeBase: oldAudioFile.timeBase, + duration: oldAudioFile.duration, + sampleRate: null, + language: oldAudioFile.language, + default: true, + chapters: oldAudioFile.chapters, + createdAt: LibraryItem.createdAt, + updatedAt: LibraryItem.updatedAt, + MediaFileId: MediaFile.id + } + newRecords.MediaStream.push(MediaStream) + + if (oldAudioFile.embeddedCoverArt) { + const CoverMediaStream = { + id: uuidv4(), + index: null, + codecType: 'video', + codec: oldAudioFile.embeddedCoverArt, + default: true, + createdAt: LibraryItem.createdAt, + updatedAt: LibraryItem.updatedAt, + MediaFileId: MediaFile.id + } + newRecords.MediaStream.push(CoverMediaStream) + } + + const AudioTrack = { + id: uuidv4(), + MediaItemId: Podcast.id, + mediaItemType: 'Podcast', + index: oldAudioFile.index, + startOffset: 0, + duration: oldAudioFile.duration, + title: oldAudioFile.metadata.filename, + mimeType: getMimeType(MediaFile.formatName), + codec: oldAudioFile.codec, + trackNumber: oldAudioFile.trackNumFromMeta || oldAudioFile.trackNumFromFilename, + discNumber: oldAudioFile.discNumFromMeta || oldAudioFile.discNumFromFilename, + createdAt: LibraryItem.createdAt, + updatedAt: LibraryItem.updatedAt, + MediaFileId: MediaFile.id + } + newRecords.AudioTrack.push(AudioTrack) + } } function migrateLibraryItems(oldLibraryItems) { - for (const oldLibraryItem of oldLibraryItems) { - // Logger.info(`[dbMigration] migrateLibraryItems: Migrating library item "${oldLibraryItem.media.metadata.title}" (${oldLibraryItem.id})`) - const LibraryId = oldDbIdMap.libraryFolders[oldLibraryItem.folderId] if (!LibraryId) { Logger.error(`[dbMigration] migrateLibraryItems: Old library folder id not found "${oldLibraryItem.folderId}"`) continue } + // + // Migrate LibraryItem + // const LibraryItem = { id: uuidv4(), ino: oldLibraryItem.ino, @@ -125,8 +625,39 @@ function migrateLibraryItems(oldLibraryItems) { oldDbIdMap.libraryItems[oldLibraryItem.id] = LibraryItem.id newRecords.LibraryItem.push(LibraryItem) - // Logger.info(`[dbMigration] migrateLibraryItems: LibraryItem "${LibraryItem.path}" migrated (${LibraryItem.id})`) + // + // Migrate LibraryFiles + // + for (const oldLibraryFile of oldLibraryItem.libraryFiles) { + const FileMetadata = { + id: uuidv4(), + ino: oldLibraryFile.ino, + filename: oldLibraryFile.metadata.filename, + ext: oldLibraryFile.metadata.ext, + path: oldLibraryFile.metadata.path, + size: oldLibraryFile.metadata.size, + mtime: oldLibraryFile.metadata.mtimeMs, + ctime: oldLibraryFile.metadata.ctimeMs, + birthtime: oldLibraryFile.metadata.birthtimeMs, + createdAt: oldLibraryFile.addedAt || Date.now(), + updatedAt: oldLibraryFile.updatedAt || Date.now() + } + newRecords.FileMetadata.push(FileMetadata) + oldDbIdMap.files[FileMetadata.path] = FileMetadata.id + const LibraryFile = { + id: uuidv4(), + createdAt: FileMetadata.createdAt, + updatedAt: FileMetadata.updatedAt, + FileMetadataId: FileMetadata.id, + LibraryItemId: LibraryItem.id + } + newRecords.LibraryFile.push(LibraryFile) + } + + // + // Migrate Book/Podcast + // if (oldLibraryItem.mediaType === 'book') { migrateBook(oldLibraryItem, LibraryItem) } else if (oldLibraryItem.mediaType === 'podcast') { @@ -137,8 +668,9 @@ function migrateLibraryItems(oldLibraryItems) { function migrateLibraries(oldLibraries) { for (const oldLibrary of oldLibraries) { - // Logger.info(`[dbMigration] migrateLibraries: Migrating library "${oldLibrary.name}" (${oldLibrary.id})`) - + // + // Migrate Library + // const Library = { id: uuidv4(), name: oldLibrary.name, @@ -152,41 +684,88 @@ function migrateLibraries(oldLibraries) { oldDbIdMap.libraries[oldLibrary.id] = Library.id newRecords.Library.push(Library) + // + // Migrate LibrarySettings + // const oldLibrarySettings = oldLibrary.settings || {} for (const oldSettingsKey in oldLibrarySettings) { - const LibrarySetting = { + newRecords.LibrarySetting.push({ id: uuidv4(), key: oldSettingsKey, value: oldLibrarySettings[oldSettingsKey], + createdAt: oldLibrary.createdAt, + updatedAt: oldLibrary.lastUpdate, LibraryId: Library.id - } - newRecords.LibrarySetting.push(LibrarySetting) - // Logger.info(`[dbMigration] migrateLibraries: LibrarySetting "${LibrarySetting.key}" migrated (${LibrarySetting.id})`) + }) } - // Logger.info(`[dbMigration] migrateLibraries: Library "${Library.name}" migrated (${Library.id})`) - + // + // Migrate LibraryFolders + // for (const oldFolder of oldLibrary.folders) { - // Logger.info(`[dbMigration] migrateLibraries: Migrating folder "${oldFolder.fullPath}" (${oldFolder.id})`) - const LibraryFolder = { id: uuidv4(), path: oldFolder.fullPath, - LibraryId: Library.id, - createdAt: oldFolder.addedAt + createdAt: oldFolder.addedAt, + updatedAt: oldLibrary.lastUpdate, + LibraryId: Library.id } oldDbIdMap.libraryFolders[oldFolder.id] = LibraryFolder.id newRecords.LibraryFolder.push(LibraryFolder) - - // Logger.info(`[dbMigration] migrateLibraries: LibraryFolder "${LibraryFolder.path}" migrated (${LibraryFolder.id})`) } } } +function migrateAuthors(oldAuthors) { + for (const oldAuthor of oldAuthors) { + let imageFileId = null + if (oldAuthor.imagePath) { + const FileMetadata = { + id: uuidv4(), + filename: Path.basename(oldAuthor.imagePath), + ext: Path.extname(oldAuthor.imagePath), + path: oldAuthor.imagePath, + createdAt: oldAuthor.addedAt || Date.now(), + updatedAt: oldAuthor.updatedAt || Date.now() + } + newRecords.FileMetadata.push(FileMetadata) + imageFileId = FileMetadata.id + } + + const Person = { + id: uuidv4(), + type: 'Author', + name: oldAuthor.name, + asin: oldAuthor.asin || null, + description: oldAuthor.description, + createdAt: oldAuthor.addedAt || Date.now(), + updatedAt: oldAuthor.updatedAt || Date.now(), + ImageFileId: imageFileId + } + oldDbIdMap.people[oldAuthor.id] = Person.id + newRecords.Person.push(Person) + } +} + +function migrateSeries(oldSerieses) { + for (const oldSeries of oldSerieses) { + const Series = { + id: uuidv4(), + name: oldSeries.name, + description: oldSeries.description || null, + createdAt: oldSeries.addedAt || Date.now(), + updatedAt: oldSeries.updatedAt || Date.now() + } + oldDbIdMap.series[oldSeries.id] = Series.id + newRecords.Series.push(Series) + } +} + function migrateUsers(oldUsers) { for (const oldUser of oldUsers) { - // Logger.info(`[dbMigration] migrateUsers: Migrating user "${oldUser.username}" (${oldUser.id})`) - + // + // Migrate User + // const User = { id: uuidv4(), username: oldUser.username, @@ -195,18 +774,441 @@ function migrateUsers(oldUsers) { token: oldUser.token || null, isActive: !!oldUser.isActive, lastSeen: oldUser.lastSeen || null, + extraData: { + seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [] + }, createdAt: oldUser.createdAt || Date.now() } oldDbIdMap.users[oldUser.id] = User.id newRecords.User.push(User) - // Logger.info(`[dbMigration] migrateUsers: User "${User.username}" migrated (${User.id})`) + // + // Migrate UserPermissions + // + for (const oldUserPermission in oldUser.permissions) { + if (!['accessAllLibraries', 'accessAllTags'].includes(oldUserPermission)) { + const UserPermission = { + id: uuidv4(), + key: oldUserPermission, + value: !!oldUser.permissions[oldUserPermission], + createdAt: User.createdAt, + UserId: User.id + } + newRecords.UserPermission.push(UserPermission) + } + } + if (oldUser.librariesAccessible?.length) { + const UserPermission = { + id: uuidv4(), + key: 'librariesAccessible', + value: JSON.stringify(oldUser.librariesAccessible), + createdAt: User.createdAt, + UserId: User.id + } + newRecords.UserPermission.push(UserPermission) + } + if (oldUser.itemTagsAccessible?.length) { + const UserPermission = { + id: uuidv4(), + key: 'itemTagsAccessible', + value: JSON.stringify(oldUser.itemTagsAccessible), + createdAt: User.createdAt, + UserId: User.id + } + newRecords.UserPermission.push(UserPermission) + } - // for (const oldMediaProgress of oldUser.mediaProgress) { - // const MediaProgress = { + // + // 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, + isFinished: !!oldMediaProgress.isFinished, + hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening, + finishedAt: oldMediaProgress.finishedAt, + createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate, + updatedAt: oldMediaProgress.lastUpdate, + UserId: User.id + } + newRecords.MediaProgress.push(MediaProgress) + } + + // + // Migrate AudioBookmarks + // + for (const oldBookmark of oldUser.bookmarks) { + const MediaItemId = oldDbIdMap.books[oldBookmark.libraryItemId] + if (!MediaItemId) { + Logger.warn(`[dbMigration] migrateUsers: Unable to find media item for audio bookmark "${oldBookmark.id}"`) + continue + } + + const AudioBookmark = { + id: uuidv4(), + MediaItemId, + mediaItemType: 'Book', + title: oldBookmark.title, + time: oldBookmark.time, + createdAt: oldBookmark.createdAt, + updatedAt: oldBookmark.createdAt, + UserId: User.id + } + newRecords.AudioBookmark.push(AudioBookmark) + } + } +} + +function migrateSessions(oldSessions) { + for (const oldSession of oldSessions) { + const UserId = oldDbIdMap.users[oldSession.userId] || null // Can be null + + // + // Migrate Device + // + let DeviceId = null + if (oldSession.deviceInfo) { + const oldDeviceInfo = oldSession.deviceInfo + const deviceInfoStr = getDeviceInfoString(oldDeviceInfo, UserId) + DeviceId = oldDbIdMap.devices[deviceInfoStr] + if (!DeviceId) { + let clientName = 'Unknown' + let clientVersion = null + let deviceName = null + let deviceVersion = oldDeviceInfo.browserVersion || null + 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}` + } + + const id = uuidv4() + const Device = { + id, + identifier: id, // Temp for migration + clientName, + clientVersion, + ipAddress: oldDeviceInfo.ipAddress, + deviceName, // e.g. Windows 10 Chrome, Google Pixel 6, Apple iPhone 10,3 + deviceVersion, + UserId + } + newRecords.Device.push(Device) + oldDbIdMap.devices[deviceInfoStr] = 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, + 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, // Can be null + DeviceId + } + newRecords.PlaybackSession.push(PlaybackSession) + + if (oldSession.timeListening) { + const PlaybackSessionListenTime = { + id: uuidv4(), + time: Math.min(Math.round(oldSession.timeListening), 86400), // Max time will be 24 hours, + date: oldSession.date || dateAndTime.format(new Date(PlaybackSession.createdAt), 'YYYY-MM-DD'), + createdAt: PlaybackSession.createdAt, + updatedAt: PlaybackSession.updatedAt, + PlaybackSessionId: PlaybackSession.id + } + newRecords.PlaybackSessionListenTime.push(PlaybackSessionListenTime) + } + } +} + +function migrateCollections(oldCollections) { + 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) + + BookIds.forEach((BookId) => { + const CollectionBook = { + id: uuidv4(), + createdAt: Collection.createdAt, + BookId, + CollectionId: Collection.id + } + newRecords.CollectionBook.push(CollectionBook) + }) + } +} + +function migratePlaylists(oldPlaylists) { + 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) + + MediaItemIds.forEach((MediaItemId) => { + const PlaylistMediaItem = { + id: uuidv4(), + MediaItemId, + mediaItemType, + createdAt: Playlist.createdAt, + PlaylistId: Playlist.id + } + newRecords.PlaylistMediaItem.push(PlaylistMediaItem) + }) + } +} + +function migrateFeeds(oldFeeds) { + for (const oldFeed of oldFeeds) { + if (!oldFeed.episodes?.length) { + continue + } + + let entityType = null + let EntityId = null + + if (oldFeed.entityType === 'collection') { + entityType = 'Collection' + EntityId = oldDbIdMap.collections[oldFeed.entityId] + } else if (oldFeed.entityType === 'libraryItem') { + entityType = 'LibraryItem' + EntityId = oldDbIdMap.libraryItems[oldFeed.entityId] + } else if (oldFeed.entityType === 'series') { + entityType = 'Series' + EntityId = oldDbIdMap.series[oldFeed.entityId] + } + + if (!EntityId) { + Logger.warn(`[dbMigration] migrateFeeds: Entity not found for feed "${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, + EntityId, + entityUpdatedAt: oldFeed.entityUpdatedAt, + serverAddress: oldFeed.serverAddress, + feedURL: oldFeed.feedUrl, + 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) + } + } +} + +function migrateSettings(oldSettings) { + const serverSettings = oldSettings.find(s => s.id === 'server-settings') + const notificationSettings = oldSettings.find(s => s.id === 'notification-settings') + + if (serverSettings) { + for (const serverSettingsKey in serverSettings) { + if (serverSettingsKey === 'id') continue + + let value = serverSettings[serverSettingsKey] + if (value === undefined) value = null + else if (serverSettingsKey === 'sortingPrefixes') value = JSON.stringify(value) + + newRecords.Setting.push({ + key: serverSettingsKey, + value, + type: 0 + }) + } + } + + if (notificationSettings) { + const cleanedCopy = { + appriseApiUrl: notificationSettings.appriseApiUrl || null, + notificationMaxFailedAttempts: notificationSettings.maxFailedAttempts ?? 5, + notificationMaxQueue: notificationSettings.maxNotificationQueue ?? 20, + notificationDelay: notificationSettings.notificationDelay ?? 1000 // ms delay between firing notifications + } + for (const notificationSettingKey in cleanedCopy) { + newRecords.Setting.push({ + key: notificationSettingKey, + value: cleanedCopy[notificationSettingKey], + type: 1 + }) + } + + // + // Migrate Notifications + // + if (notificationSettings.notifications?.length) { + for (const oldNotification of notificationSettings.notifications) { + + const Notification = { + id: uuidv4(), + eventName: oldNotification.eventName, + urls: JSON.stringify(oldNotification.urls), // JSON array of urls + titleTemplate: oldNotification.titleTemplate, + bodyTemplate: oldNotification.bodyTemplate, + type: oldNotification.type, + lastFiredAt: oldNotification.lastFiredAt, + lastAttemptFailed: oldNotification.lastAttemptFailed, + numConsecutiveFailedAttempts: oldNotification.numConsecutiveFailedAttempts, + numTimesFired: oldNotification.numTimesFired, + enabled: !!oldNotification.enabled, + createdAt: oldNotification.createdAt, + updatedAt: oldNotification.createdAt + } + newRecords.Notification.push(Notification) + } + } } } @@ -216,16 +1218,27 @@ module.exports.migrate = async () => { const data = await oldDbFiles.init() const start = Date.now() + migrateAuthors(data.authors) + migrateSeries(data.series) migrateLibraries(data.libraries) migrateLibraryItems(data.libraryItems) migrateUsers(data.users) + migrateSessions(data.sessions) + migrateCollections(data.collections) + migratePlaylists(data.playlists) + migrateFeeds(data.feeds) + migrateSettings(data.settings) + let totalRecords = 0 for (const model in newRecords) { - Logger.info(`[dbMigration] Creating ${newRecords[model].length} ${model} records`) - await Database.models[model].bulkCreate(newRecords[model]) + Logger.info(`[dbMigration] Inserting ${newRecords[model].length} ${model} rows`) + if (newRecords[model].length) { + await Database.models[model].bulkCreate(newRecords[model]) + totalRecords += newRecords[model].length + } } const elapsed = Date.now() - start - Logger.info(`[dbMigration] Migration complete. Elapsed ${(elapsed / 1000).toFixed(2)}s`) + Logger.info(`[dbMigration] Migration complete. ${totalRecords} rows. Elapsed ${(elapsed / 1000).toFixed(2)}s`) } \ No newline at end of file