2023-07-04 18:14:44 -05:00
const Path = require ( 'path' )
2023-12-21 14:29:36 -06:00
const { Sequelize , Op } = require ( 'sequelize' )
2023-07-04 18:14:44 -05:00
const packageJson = require ( '../package.json' )
const fs = require ( './libs/fsExtra' )
const Logger = require ( './Logger' )
const dbMigration = require ( './utils/migrations/dbMigration' )
2023-07-22 15:32:20 -05:00
const Auth = require ( './Auth' )
2023-07-04 18:14:44 -05:00
2024-09-04 12:48:10 +03:00
const MigrationManager = require ( './managers/MigrationManager' )
2023-07-04 18:14:44 -05:00
class Database {
constructor ( ) {
this . sequelize = null
this . dbPath = null
2023-07-08 14:40:49 -05:00
this . isNew = false // New absdatabase.sqlite created
2023-07-22 15:32:20 -05:00
this . hasRootUser = false // Used to show initialization page in web ui
2023-07-04 18:14:44 -05:00
this . settings = [ ]
2023-08-13 15:10:26 -05:00
// Cached library filter data
this . libraryFilterData = { }
2023-08-28 17:50:21 -05:00
/** @type {import('./objects/settings/ServerSettings')} */
2023-07-04 18:14:44 -05:00
this . serverSettings = null
2023-08-28 17:50:21 -05:00
/** @type {import('./objects/settings/NotificationSettings')} */
2023-07-04 18:14:44 -05:00
this . notificationSettings = null
2023-08-28 17:50:21 -05:00
/** @type {import('./objects/settings/EmailSettings')} */
2023-07-04 18:14:44 -05:00
this . emailSettings = null
2024-09-29 09:22:39 +03:00
this . supportsUnaccent = false
this . supportsUnicodeFoldings = false
2023-07-04 18:14:44 -05:00
}
get models ( ) {
return this . sequelize ? . models || { }
}
2023-08-20 13:34:03 -05:00
/** @type {typeof import('./models/User')} */
get userModel ( ) {
return this . models . user
}
/** @type {typeof import('./models/Library')} */
get libraryModel ( ) {
return this . models . library
}
2023-09-03 17:51:58 -05:00
/** @type {typeof import('./models/LibraryFolder')} */
get libraryFolderModel ( ) {
return this . models . libraryFolder
}
2023-08-18 14:40:36 -05:00
/** @type {typeof import('./models/Author')} */
get authorModel ( ) {
return this . models . author
}
/** @type {typeof import('./models/Series')} */
get seriesModel ( ) {
return this . models . series
}
2023-08-19 13:59:22 -05:00
/** @type {typeof import('./models/Book')} */
get bookModel ( ) {
return this . models . book
}
2023-08-20 13:34:03 -05:00
/** @type {typeof import('./models/BookSeries')} */
get bookSeriesModel ( ) {
return this . models . bookSeries
}
/** @type {typeof import('./models/BookAuthor')} */
get bookAuthorModel ( ) {
return this . models . bookAuthor
}
2023-08-19 13:59:22 -05:00
/** @type {typeof import('./models/Podcast')} */
get podcastModel ( ) {
return this . models . podcast
}
2023-08-20 13:34:03 -05:00
/** @type {typeof import('./models/PodcastEpisode')} */
get podcastEpisodeModel ( ) {
return this . models . podcastEpisode
}
2023-08-19 13:59:22 -05:00
/** @type {typeof import('./models/LibraryItem')} */
get libraryItemModel ( ) {
return this . models . libraryItem
}
2023-08-19 14:49:06 -05:00
/** @type {typeof import('./models/PodcastEpisode')} */
get podcastEpisodeModel ( ) {
return this . models . podcastEpisode
}
/** @type {typeof import('./models/MediaProgress')} */
get mediaProgressModel ( ) {
return this . models . mediaProgress
}
2023-08-20 13:34:03 -05:00
/** @type {typeof import('./models/Collection')} */
get collectionModel ( ) {
return this . models . collection
}
/** @type {typeof import('./models/CollectionBook')} */
get collectionBookModel ( ) {
return this . models . collectionBook
}
/** @type {typeof import('./models/Playlist')} */
get playlistModel ( ) {
return this . models . playlist
}
/** @type {typeof import('./models/PlaylistMediaItem')} */
get playlistMediaItemModel ( ) {
return this . models . playlistMediaItem
}
/** @type {typeof import('./models/Feed')} */
get feedModel ( ) {
return this . models . feed
}
2023-12-19 17:19:33 -06:00
/** @type {typeof import('./models/FeedEpisode')} */
2023-08-20 13:34:03 -05:00
get feedEpisodeModel ( ) {
return this . models . feedEpisode
}
2023-12-19 17:19:33 -06:00
/** @type {typeof import('./models/PlaybackSession')} */
get playbackSessionModel ( ) {
return this . models . playbackSession
}
2024-01-03 01:36:56 +01:00
/** @type {typeof import('./models/CustomMetadataProvider')} */
get customMetadataProviderModel ( ) {
return this . models . customMetadataProvider
}
2024-06-26 17:03:12 -05:00
/** @type {typeof import('./models/MediaItemShare')} */
get mediaItemShareModel ( ) {
return this . models . mediaItemShare
}
2024-09-12 16:36:39 -05:00
/** @type {typeof import('./models/Device')} */
get deviceModel ( ) {
return this . models . device
}
2023-08-20 13:16:53 -05:00
/ * *
* Check if db file exists
* @ returns { boolean }
* /
2023-07-04 18:14:44 -05:00
async checkHasDb ( ) {
2024-06-22 16:42:13 -05:00
if ( ! ( await fs . pathExists ( this . dbPath ) ) ) {
2023-07-08 14:40:49 -05:00
Logger . info ( ` [Database] absdatabase.sqlite not found at ${ this . dbPath } ` )
2023-07-04 18:14:44 -05:00
return false
}
return true
}
2023-08-20 13:16:53 -05:00
/ * *
* Connect to db , build models and run migrations
* @ param { boolean } [ force = false ] Used for testing , drops & re - creates all tables
* /
2023-07-04 18:14:44 -05:00
async init ( force = false ) {
2023-07-08 14:40:49 -05:00
this . dbPath = Path . join ( global . ConfigPath , 'absdatabase.sqlite' )
2023-07-04 18:14:44 -05:00
// First check if this is a new database
this . isNew = ! ( await this . checkHasDb ( ) ) || force
2024-06-22 16:42:13 -05:00
if ( ! ( await this . connect ( ) ) ) {
2023-07-04 18:14:44 -05:00
throw new Error ( 'Database connection failed' )
}
2024-09-07 22:24:19 +03:00
try {
2024-09-14 08:01:32 +03:00
const migrationManager = new MigrationManager ( this . sequelize , this . isNew , global . ConfigPath )
2024-09-07 22:24:19 +03:00
await migrationManager . init ( packageJson . version )
2024-09-14 08:01:32 +03:00
await migrationManager . runMigrations ( )
2024-09-07 22:24:19 +03:00
} catch ( error ) {
Logger . error ( ` [Database] Failed to run migrations ` , error )
throw new Error ( 'Database migration failed' )
2024-09-04 12:48:10 +03:00
}
2023-07-04 18:14:44 -05:00
await this . buildModels ( force )
2023-07-14 14:50:37 -05:00
Logger . info ( ` [Database] Db initialized with models: ` , Object . keys ( this . sequelize . models ) . join ( ', ' ) )
2023-07-04 18:14:44 -05:00
2025-02-19 17:39:32 +02:00
await this . addTriggers ( )
2023-07-08 14:40:49 -05:00
await this . loadData ( )
2025-02-16 13:38:54 +02:00
Logger . info ( ` [Database] running ANALYZE ` )
await this . sequelize . query ( 'ANALYZE' )
Logger . info ( ` [Database] ANALYZE completed ` )
2023-07-04 18:14:44 -05:00
}
2023-08-20 13:16:53 -05:00
/ * *
* Connect to db
* @ returns { boolean }
* /
2023-07-04 18:14:44 -05:00
async connect ( ) {
Logger . info ( ` [Database] Initializing db at " ${ this . dbPath } " ` )
2023-09-14 22:52:43 -07:00
2023-09-22 16:14:12 -05:00
let logging = false
let benchmark = false
2024-06-22 16:42:13 -05:00
if ( process . env . QUERY _LOGGING === 'log' ) {
2023-09-14 22:52:43 -07:00
// Setting QUERY_LOGGING=log will log all Sequelize queries before they run
2023-09-14 23:04:47 -07:00
Logger . info ( ` [Database] Query logging enabled ` )
2024-01-03 16:19:28 -07:00
logging = ( query ) => Logger . debug ( ` Running the following query: \n ${ query } ` )
2024-06-22 16:42:13 -05:00
} else if ( process . env . QUERY _LOGGING === 'benchmark' ) {
2023-09-14 22:52:43 -07:00
// Setting QUERY_LOGGING=benchmark will log all Sequelize queries and their execution times, after they run
Logger . info ( ` [Database] Query benchmarking enabled" ` )
2024-01-03 16:19:28 -07:00
logging = ( query , time ) => Logger . debug ( ` Ran the following query in ${ time } ms: \n ${ query } ` )
2023-09-22 16:14:00 -05:00
benchmark = true
2023-09-14 22:52:43 -07:00
}
2023-07-04 18:14:44 -05:00
this . sequelize = new Sequelize ( {
dialect : 'sqlite' ,
storage : this . dbPath ,
2023-09-14 22:52:43 -07:00
logging : logging ,
benchmark : benchmark ,
2023-07-22 11:30:29 -05:00
transactionType : 'IMMEDIATE'
2023-07-04 18:14:44 -05:00
} )
// Helper function
2024-06-22 16:42:13 -05:00
this . sequelize . uppercaseFirst = ( str ) => ( str ? ` ${ str [ 0 ] . toUpperCase ( ) } ${ str . substr ( 1 ) } ` : '' )
2023-07-04 18:14:44 -05:00
try {
await this . sequelize . authenticate ( )
2025-01-26 13:44:57 +02:00
// Set SQLite pragmas from environment variables
const allowedPragmas = [
{ name : 'mmap_size' , env : 'SQLITE_MMAP_SIZE' } ,
{ name : 'cache_size' , env : 'SQLITE_CACHE_SIZE' } ,
{ name : 'temp_store' , env : 'SQLITE_TEMP_STORE' }
]
for ( const pragma of allowedPragmas ) {
const value = process . env [ pragma . env ]
if ( value !== undefined ) {
try {
Logger . info ( ` [Database] Running "PRAGMA ${ pragma . name } = ${ value } " ` )
await this . sequelize . query ( ` PRAGMA ${ pragma . name } = ${ value } ` )
const [ result ] = await this . sequelize . query ( ` PRAGMA ${ pragma . name } ` )
Logger . debug ( ` [Database] "PRAGMA ${ pragma . name } " query result: ` , result )
} catch ( error ) {
Logger . error ( ` [Database] Failed to set SQLite pragma ${ pragma . name } ` , error )
}
}
}
2024-09-29 09:22:39 +03:00
if ( process . env . NUSQLITE3 _PATH ) {
await this . loadExtension ( process . env . NUSQLITE3 _PATH )
Logger . info ( ` [Database] Db supports unaccent and unicode foldings ` )
this . supportsUnaccent = true
this . supportsUnicodeFoldings = true
}
2023-07-04 18:14:44 -05:00
Logger . info ( ` [Database] Db connection was successful ` )
return true
} catch ( error ) {
Logger . error ( ` [Database] Failed to connect to db ` , error )
return false
}
}
2024-07-28 16:55:45 -05:00
/ * *
2024-09-29 09:22:39 +03:00
* @ param { string } extension paths to extension binary
2024-07-28 16:55:45 -05:00
* /
2024-09-29 09:22:39 +03:00
async loadExtension ( extension ) {
2024-07-27 21:56:07 +03:00
// This is a hack to get the db connection for loading extensions.
// The proper way would be to use the 'afterConnect' hook, but that hook is never called for sqlite due to a bug in sequelize.
// See https://github.com/sequelize/sequelize/issues/12487
// This is not a public API and may break in the future.
const db = await this . sequelize . dialect . connectionManager . getConnection ( )
if ( typeof db ? . loadExtension !== 'function' ) throw new Error ( 'Failed to get db connection for loading extensions' )
2024-09-29 09:22:39 +03:00
Logger . info ( ` [Database] Loading extension ${ extension } ` )
await new Promise ( ( resolve , reject ) => {
db . loadExtension ( extension , ( err ) => {
if ( err ) {
Logger . error ( ` [Database] Failed to load extension ${ extension } ` , err )
reject ( err )
return
}
Logger . info ( ` [Database] Successfully loaded extension ${ extension } ` )
resolve ( )
2024-07-27 21:56:07 +03:00
} )
2024-09-29 09:22:39 +03:00
} )
2024-07-27 21:56:07 +03:00
}
2023-08-20 13:16:53 -05:00
/ * *
* Disconnect from db
* /
2023-07-08 14:40:49 -05:00
async disconnect ( ) {
Logger . info ( ` [Database] Disconnecting sqlite db ` )
await this . sequelize . close ( )
}
2023-08-20 13:16:53 -05:00
/ * *
* Reconnect to db and init
* /
2023-07-08 14:40:49 -05:00
async reconnect ( ) {
Logger . info ( ` [Database] Reconnecting sqlite db ` )
await this . init ( )
}
2023-07-04 18:14:44 -05:00
buildModels ( force = false ) {
2023-08-16 16:38:48 -05:00
require ( './models/User' ) . init ( this . sequelize )
2023-08-15 18:03:43 -05:00
require ( './models/Library' ) . init ( this . sequelize )
require ( './models/LibraryFolder' ) . init ( this . sequelize )
require ( './models/Book' ) . init ( this . sequelize )
2023-08-16 16:38:48 -05:00
require ( './models/Podcast' ) . init ( this . sequelize )
require ( './models/PodcastEpisode' ) . init ( this . sequelize )
require ( './models/LibraryItem' ) . init ( this . sequelize )
require ( './models/MediaProgress' ) . init ( this . sequelize )
require ( './models/Series' ) . init ( this . sequelize )
2023-08-15 18:03:43 -05:00
require ( './models/BookSeries' ) . init ( this . sequelize )
2023-08-14 18:22:38 -05:00
require ( './models/Author' ) . init ( this . sequelize )
2023-08-15 18:03:43 -05:00
require ( './models/BookAuthor' ) . init ( this . sequelize )
require ( './models/Collection' ) . init ( this . sequelize )
require ( './models/CollectionBook' ) . init ( this . sequelize )
2023-08-16 16:38:48 -05:00
require ( './models/Playlist' ) . init ( this . sequelize )
require ( './models/PlaylistMediaItem' ) . init ( this . sequelize )
2023-08-15 18:03:43 -05:00
require ( './models/Device' ) . init ( this . sequelize )
2023-08-16 16:38:48 -05:00
require ( './models/PlaybackSession' ) . init ( this . sequelize )
2023-08-15 18:03:43 -05:00
require ( './models/Feed' ) . init ( this . sequelize )
require ( './models/FeedEpisode' ) . init ( this . sequelize )
2023-08-16 16:38:48 -05:00
require ( './models/Setting' ) . init ( this . sequelize )
2024-01-03 01:36:56 +01:00
require ( './models/CustomMetadataProvider' ) . init ( this . sequelize )
2024-06-22 16:42:13 -05:00
require ( './models/MediaItemShare' ) . init ( this . sequelize )
2023-07-04 18:14:44 -05:00
2023-07-05 18:18:37 -05:00
return this . sequelize . sync ( { force , alter : false } )
2023-07-04 18:14:44 -05:00
}
2023-07-21 16:59:00 -05:00
/ * *
* Compare two server versions
2024-06-22 16:42:13 -05:00
* @ param { string } v1
* @ param { string } v2
2023-07-21 16:59:00 -05:00
* @ returns { - 1 | 0 | 1 } 1 if v1 > v2
* /
compareVersions ( v1 , v2 ) {
if ( ! v1 || ! v2 ) return 0
2024-06-22 16:42:13 -05:00
return v1 . localeCompare ( v2 , undefined , { numeric : true , sensitivity : 'case' , caseFirst : 'upper' } )
2023-07-21 16:59:00 -05:00
}
2023-07-19 15:36:18 -05:00
/ * *
* Checks if migration to sqlite db is necessary & runs migration .
2024-06-22 16:42:13 -05:00
*
2023-07-19 15:36:18 -05:00
* Check if version was upgraded and run any version specific migrations .
2024-06-22 16:42:13 -05:00
*
2023-07-19 15:36:18 -05:00
* Loads most of the data from the database . This is a temporary solution .
* /
2023-07-08 14:40:49 -05:00
async loadData ( ) {
2024-06-22 16:42:13 -05:00
if ( this . isNew && ( await dbMigration . checkShouldMigrate ( ) ) ) {
2023-07-04 18:14:44 -05:00
Logger . info ( ` [Database] New database was created and old database was detected - migrating old to new ` )
await dbMigration . migrate ( this . models )
}
2023-07-16 15:05:51 -05:00
const settingsData = await this . models . setting . getOldSettings ( )
this . settings = settingsData . settings
this . emailSettings = settingsData . emailSettings
this . serverSettings = settingsData . serverSettings
this . notificationSettings = settingsData . notificationSettings
global . ServerSettings = this . serverSettings . toJSON ( )
// Version specific migrations
2023-10-22 15:53:05 -05:00
if ( packageJson . version !== this . serverSettings . version ) {
if ( this . serverSettings . version === '2.3.0' && this . compareVersions ( packageJson . version , '2.3.0' ) == 1 ) {
await dbMigration . migrationPatch ( this )
}
if ( [ '2.3.0' , '2.3.1' , '2.3.2' , '2.3.3' ] . includes ( this . serverSettings . version ) && this . compareVersions ( packageJson . version , '2.3.3' ) >= 0 ) {
await dbMigration . migrationPatch2 ( this )
}
2023-07-28 18:03:31 -05:00
}
2023-10-22 15:53:05 -05:00
// Build migrations
if ( this . serverSettings . buildNumber <= 0 ) {
await require ( './utils/migrations/absMetadataMigration' ) . migrate ( this )
2023-07-16 15:05:51 -05:00
}
2023-09-08 17:20:39 -05:00
await this . cleanDatabase ( )
2023-07-22 15:32:20 -05:00
// Set if root user has been created
this . hasRootUser = await this . models . user . getHasRootUser ( )
2023-10-22 15:53:05 -05:00
// Update server settings with version/build
let updateServerSettings = false
2023-07-04 18:14:44 -05:00
if ( packageJson . version !== this . serverSettings . version ) {
Logger . info ( ` [Database] Server upgrade detected from ${ this . serverSettings . version } to ${ packageJson . version } ` )
this . serverSettings . version = packageJson . version
2023-10-22 15:53:05 -05:00
this . serverSettings . buildNumber = packageJson . buildNumber
updateServerSettings = true
} else if ( packageJson . buildNumber !== this . serverSettings . buildNumber ) {
Logger . info ( ` [Database] Server v ${ packageJson . version } build upgraded from ${ this . serverSettings . buildNumber } to ${ packageJson . buildNumber } ` )
this . serverSettings . buildNumber = packageJson . buildNumber
updateServerSettings = true
}
if ( updateServerSettings ) {
2023-07-04 18:14:44 -05:00
await this . updateServerSettings ( )
}
}
2023-07-22 15:32:20 -05:00
/ * *
* Create root user
2024-06-22 16:42:13 -05:00
* @ param { string } username
* @ param { string } pash
* @ param { Auth } auth
2024-08-17 17:18:40 -05:00
* @ returns { Promise < boolean > } true if created
2023-07-22 15:32:20 -05:00
* /
async createRootUser ( username , pash , auth ) {
2023-07-08 14:40:49 -05:00
if ( ! this . sequelize ) return false
2024-08-10 15:46:04 -05:00
await this . userModel . createRootUser ( username , pash , auth )
2023-07-22 15:32:20 -05:00
this . hasRootUser = true
return true
2023-07-04 18:14:44 -05:00
}
updateServerSettings ( ) {
2023-07-08 14:40:49 -05:00
if ( ! this . sequelize ) return false
2023-07-04 18:14:44 -05:00
global . ServerSettings = this . serverSettings . toJSON ( )
return this . updateSetting ( this . serverSettings )
}
updateSetting ( settings ) {
2023-07-08 14:40:49 -05:00
if ( ! this . sequelize ) return false
2023-07-04 18:14:44 -05:00
return this . models . setting . updateSettingObj ( settings . toJSON ( ) )
}
getPlaybackSessions ( where = null ) {
2023-07-08 14:40:49 -05:00
if ( ! this . sequelize ) return false
2023-07-04 18:14:44 -05:00
return this . models . playbackSession . getOldPlaybackSessions ( where )
}
getPlaybackSession ( sessionId ) {
2023-07-08 14:40:49 -05:00
if ( ! this . sequelize ) return false
2023-07-04 18:14:44 -05:00
return this . models . playbackSession . getById ( sessionId )
}
createPlaybackSession ( oldSession ) {
2023-07-08 14:40:49 -05:00
if ( ! this . sequelize ) return false
2023-07-04 18:14:44 -05:00
return this . models . playbackSession . createFromOld ( oldSession )
}
updatePlaybackSession ( oldSession ) {
2023-07-08 14:40:49 -05:00
if ( ! this . sequelize ) return false
2023-07-04 18:14:44 -05:00
return this . models . playbackSession . updateFromOld ( oldSession )
}
removePlaybackSession ( sessionId ) {
2023-07-08 14:40:49 -05:00
if ( ! this . sequelize ) return false
2023-07-04 18:14:44 -05:00
return this . models . playbackSession . removeById ( sessionId )
}
2023-09-01 18:01:17 -05:00
replaceTagInFilterData ( oldTag , newTag ) {
for ( const libraryId in this . libraryFilterData ) {
2024-06-22 16:42:13 -05:00
const indexOf = this . libraryFilterData [ libraryId ] . tags . findIndex ( ( n ) => n === oldTag )
2023-09-01 18:01:17 -05:00
if ( indexOf >= 0 ) {
this . libraryFilterData [ libraryId ] . tags . splice ( indexOf , 1 , newTag )
}
}
}
2023-08-13 15:10:26 -05:00
removeTagFromFilterData ( tag ) {
for ( const libraryId in this . libraryFilterData ) {
2024-06-22 16:42:13 -05:00
this . libraryFilterData [ libraryId ] . tags = this . libraryFilterData [ libraryId ] . tags . filter ( ( t ) => t !== tag )
2023-08-13 15:10:26 -05:00
}
}
2023-09-01 18:01:17 -05:00
addTagsToFilterData ( libraryId , tags ) {
if ( ! this . libraryFilterData [ libraryId ] || ! tags ? . length ) return
tags . forEach ( ( t ) => {
if ( ! this . libraryFilterData [ libraryId ] . tags . includes ( t ) ) {
this . libraryFilterData [ libraryId ] . tags . push ( t )
}
} )
}
replaceGenreInFilterData ( oldGenre , newGenre ) {
2023-08-13 15:10:26 -05:00
for ( const libraryId in this . libraryFilterData ) {
2024-06-22 16:42:13 -05:00
const indexOf = this . libraryFilterData [ libraryId ] . genres . findIndex ( ( n ) => n === oldGenre )
2023-09-01 18:01:17 -05:00
if ( indexOf >= 0 ) {
this . libraryFilterData [ libraryId ] . genres . splice ( indexOf , 1 , newGenre )
2023-08-13 15:10:26 -05:00
}
}
}
removeGenreFromFilterData ( genre ) {
for ( const libraryId in this . libraryFilterData ) {
2024-06-22 16:42:13 -05:00
this . libraryFilterData [ libraryId ] . genres = this . libraryFilterData [ libraryId ] . genres . filter ( ( g ) => g !== genre )
2023-08-13 15:10:26 -05:00
}
}
2023-09-01 18:01:17 -05:00
addGenresToFilterData ( libraryId , genres ) {
if ( ! this . libraryFilterData [ libraryId ] || ! genres ? . length ) return
genres . forEach ( ( g ) => {
if ( ! this . libraryFilterData [ libraryId ] . genres . includes ( g ) ) {
this . libraryFilterData [ libraryId ] . genres . push ( g )
}
} )
}
replaceNarratorInFilterData ( oldNarrator , newNarrator ) {
2023-08-13 15:10:26 -05:00
for ( const libraryId in this . libraryFilterData ) {
2024-06-22 16:42:13 -05:00
const indexOf = this . libraryFilterData [ libraryId ] . narrators . findIndex ( ( n ) => n === oldNarrator )
2023-09-01 18:01:17 -05:00
if ( indexOf >= 0 ) {
this . libraryFilterData [ libraryId ] . narrators . splice ( indexOf , 1 , newNarrator )
2023-08-13 15:10:26 -05:00
}
}
}
2023-08-13 17:45:53 -05:00
removeNarratorFromFilterData ( narrator ) {
for ( const libraryId in this . libraryFilterData ) {
2024-06-22 16:42:13 -05:00
this . libraryFilterData [ libraryId ] . narrators = this . libraryFilterData [ libraryId ] . narrators . filter ( ( n ) => n !== narrator )
2023-08-13 17:45:53 -05:00
}
}
2023-09-01 18:01:17 -05:00
addNarratorsToFilterData ( libraryId , narrators ) {
if ( ! this . libraryFilterData [ libraryId ] || ! narrators ? . length ) return
narrators . forEach ( ( n ) => {
if ( ! this . libraryFilterData [ libraryId ] . narrators . includes ( n ) ) {
this . libraryFilterData [ libraryId ] . narrators . push ( n )
2023-08-13 17:45:53 -05:00
}
2023-09-01 18:01:17 -05:00
} )
2023-08-13 17:45:53 -05:00
}
2023-08-18 14:40:36 -05:00
removeSeriesFromFilterData ( libraryId , seriesId ) {
if ( ! this . libraryFilterData [ libraryId ] ) return
2024-06-22 16:42:13 -05:00
this . libraryFilterData [ libraryId ] . series = this . libraryFilterData [ libraryId ] . series . filter ( ( se ) => se . id !== seriesId )
2023-08-18 14:40:36 -05:00
}
addSeriesToFilterData ( libraryId , seriesName , seriesId ) {
if ( ! this . libraryFilterData [ libraryId ] ) return
// Check if series is already added
2024-06-22 16:42:13 -05:00
if ( this . libraryFilterData [ libraryId ] . series . some ( ( se ) => se . id === seriesId ) ) return
2023-08-18 14:40:36 -05:00
this . libraryFilterData [ libraryId ] . series . push ( {
id : seriesId ,
name : seriesName
} )
}
removeAuthorFromFilterData ( libraryId , authorId ) {
if ( ! this . libraryFilterData [ libraryId ] ) return
2024-06-22 16:42:13 -05:00
this . libraryFilterData [ libraryId ] . authors = this . libraryFilterData [ libraryId ] . authors . filter ( ( au ) => au . id !== authorId )
2023-08-18 14:40:36 -05:00
}
addAuthorToFilterData ( libraryId , authorName , authorId ) {
if ( ! this . libraryFilterData [ libraryId ] ) return
// Check if author is already added
2024-06-22 16:42:13 -05:00
if ( this . libraryFilterData [ libraryId ] . authors . some ( ( au ) => au . id === authorId ) ) return
2023-08-18 14:40:36 -05:00
this . libraryFilterData [ libraryId ] . authors . push ( {
id : authorId ,
name : authorName
} )
}
2023-09-01 18:01:17 -05:00
addPublisherToFilterData ( libraryId , publisher ) {
if ( ! this . libraryFilterData [ libraryId ] || ! publisher || this . libraryFilterData [ libraryId ] . publishers . includes ( publisher ) ) return
this . libraryFilterData [ libraryId ] . publishers . push ( publisher )
}
2024-10-08 15:20:42 -07:00
addPublishedDecadeToFilterData ( libraryId , decade ) {
if ( ! this . libraryFilterData [ libraryId ] || ! decade || this . libraryFilterData [ libraryId ] . publishedDecades . includes ( decade ) ) return
this . libraryFilterData [ libraryId ] . publishedDecades . push ( decade )
}
2023-09-01 18:01:17 -05:00
addLanguageToFilterData ( libraryId , language ) {
if ( ! this . libraryFilterData [ libraryId ] || ! language || this . libraryFilterData [ libraryId ] . languages . includes ( language ) ) return
this . libraryFilterData [ libraryId ] . languages . push ( language )
}
2023-08-18 14:40:36 -05:00
/ * *
* Used when updating items to make sure author id exists
* If library filter data is set then use that for check
* otherwise lookup in db
2024-06-22 16:42:13 -05:00
* @ param { string } libraryId
* @ param { string } authorId
2023-08-18 14:40:36 -05:00
* @ returns { Promise < boolean > }
* /
async checkAuthorExists ( libraryId , authorId ) {
if ( ! this . libraryFilterData [ libraryId ] ) {
return this . authorModel . checkExistsById ( authorId )
}
2024-06-22 16:42:13 -05:00
return this . libraryFilterData [ libraryId ] . authors . some ( ( au ) => au . id === authorId )
2023-08-18 14:40:36 -05:00
}
/ * *
* Used when updating items to make sure series id exists
* If library filter data is set then use that for check
* otherwise lookup in db
2024-06-22 16:42:13 -05:00
* @ param { string } libraryId
* @ param { string } seriesId
2023-08-18 14:40:36 -05:00
* @ returns { Promise < boolean > }
* /
async checkSeriesExists ( libraryId , seriesId ) {
if ( ! this . libraryFilterData [ libraryId ] ) {
return this . seriesModel . checkExistsById ( seriesId )
}
2024-06-22 16:42:13 -05:00
return this . libraryFilterData [ libraryId ] . series . some ( ( se ) => se . id === seriesId )
2023-08-18 14:40:36 -05:00
}
2023-08-20 13:16:53 -05:00
2024-03-11 17:07:03 -05:00
/ * *
* Get author id for library by name . Uses library filter data if available
2024-06-22 16:42:13 -05:00
*
* @ param { string } libraryId
* @ param { string } authorName
* @ returns { Promise < string > } author id or null if not found
2024-03-11 17:07:03 -05:00
* /
async getAuthorIdByName ( libraryId , authorName ) {
2024-03-09 11:59:50 +02:00
if ( ! this . libraryFilterData [ libraryId ] ) {
2024-08-31 13:27:48 -05:00
return ( await this . authorModel . getByNameAndLibrary ( authorName , libraryId ) ) ? . id || null
2024-03-09 11:59:50 +02:00
}
2024-06-22 16:42:13 -05:00
return this . libraryFilterData [ libraryId ] . authors . find ( ( au ) => au . name === authorName ) ? . id || null
2024-03-09 11:59:50 +02:00
}
2024-03-11 17:07:03 -05:00
/ * *
* Get series id for library by name . Uses library filter data if available
2024-06-22 16:42:13 -05:00
*
* @ param { string } libraryId
* @ param { string } seriesName
2024-03-11 17:07:03 -05:00
* @ returns { Promise < string > } series id or null if not found
* /
async getSeriesIdByName ( libraryId , seriesName ) {
2024-03-09 11:59:50 +02:00
if ( ! this . libraryFilterData [ libraryId ] ) {
2024-09-01 15:08:56 -05:00
return ( await this . seriesModel . getByNameAndLibrary ( seriesName , libraryId ) ) ? . id || null
2024-03-09 11:59:50 +02:00
}
2024-06-22 16:42:13 -05:00
return this . libraryFilterData [ libraryId ] . series . find ( ( se ) => se . name === seriesName ) ? . id || null
2024-03-11 17:07:03 -05:00
}
2024-03-09 11:59:50 +02:00
2023-08-20 13:16:53 -05:00
/ * *
* Reset numIssues for library
2024-06-22 16:42:13 -05:00
* @ param { string } libraryId
2023-08-20 13:16:53 -05:00
* /
async resetLibraryIssuesFilterData ( libraryId ) {
if ( ! this . libraryFilterData [ libraryId ] ) return // Do nothing if filter data is not set
this . libraryFilterData [ libraryId ] . numIssues = await this . libraryItemModel . count ( {
where : {
libraryId ,
[ Sequelize . Op . or ] : [
{
isMissing : true
} ,
{
isInvalid : true
}
]
}
} )
}
2023-09-05 17:58:13 -05:00
/ * *
* Clean invalid records in database
* Series should have atleast one Book
2025-01-04 15:20:41 -06:00
* Book and Podcast must have an associated LibraryItem ( and vice versa )
2023-12-21 14:29:36 -06:00
* Remove playback sessions that are 3 seconds or less
2023-09-05 17:58:13 -05:00
* /
async cleanDatabase ( ) {
// Remove invalid Podcast records
const podcastsWithNoLibraryItem = await this . podcastModel . findAll ( {
2023-09-17 15:53:25 -05:00
include : {
model : this . libraryItemModel ,
required : false
} ,
where : { '$libraryItem.id$' : null }
2023-09-05 17:58:13 -05:00
} )
for ( const podcast of podcastsWithNoLibraryItem ) {
Logger . warn ( ` Found podcast " ${ podcast . title } " with no libraryItem - removing it ` )
await podcast . destroy ( )
}
// Remove invalid Book records
const booksWithNoLibraryItem = await this . bookModel . findAll ( {
2023-09-17 15:53:25 -05:00
include : {
model : this . libraryItemModel ,
required : false
} ,
where : { '$libraryItem.id$' : null }
2023-09-05 17:58:13 -05:00
} )
for ( const book of booksWithNoLibraryItem ) {
Logger . warn ( ` Found book " ${ book . title } " with no libraryItem - removing it ` )
await book . destroy ( )
}
2025-01-04 15:20:41 -06:00
// Remove invalid LibraryItem records
const libraryItemsWithNoMedia = await this . libraryItemModel . findAll ( {
include : [
{
model : this . bookModel ,
attributes : [ 'id' ]
} ,
{
model : this . podcastModel ,
attributes : [ 'id' ]
}
] ,
where : {
'$book.id$' : null ,
'$podcast.id$' : null
}
} )
for ( const libraryItem of libraryItemsWithNoMedia ) {
Logger . warn ( ` Found libraryItem " ${ libraryItem . id } " with no media - removing it ` )
await libraryItem . destroy ( )
}
2025-01-28 16:58:42 -06:00
// Remove invalid PlaylistMediaItem records
2025-01-03 12:06:20 -06:00
const playlistMediaItemsWithNoMediaItem = await this . playlistMediaItemModel . findAll ( {
include : [
{
model : this . bookModel ,
attributes : [ 'id' ]
} ,
{
model : this . podcastEpisodeModel ,
attributes : [ 'id' ]
}
] ,
where : {
'$book.id$' : null ,
'$podcastEpisode.id$' : null
}
} )
for ( const playlistMediaItem of playlistMediaItemsWithNoMediaItem ) {
Logger . warn ( ` Found playlistMediaItem with no book or podcastEpisode - removing it ` )
await playlistMediaItem . destroy ( )
}
2025-01-28 16:58:42 -06:00
// Remove invalid CollectionBook records
const collectionBooksWithNoBook = await this . collectionBookModel . findAll ( {
include : {
model : this . bookModel ,
required : false
} ,
where : { '$book.id$' : null }
} )
for ( const collectionBook of collectionBooksWithNoBook ) {
Logger . warn ( ` Found collectionBook with no book - removing it ` )
await collectionBook . destroy ( )
}
2023-09-05 17:58:13 -05:00
// Remove empty series
const emptySeries = await this . seriesModel . findAll ( {
2023-09-17 15:53:25 -05:00
include : {
model : this . bookSeriesModel ,
required : false
} ,
where : { '$bookSeries.id$' : null }
2023-09-05 17:58:13 -05:00
} )
for ( const series of emptySeries ) {
Logger . warn ( ` Found series " ${ series . name } " with no books - removing it ` )
await series . destroy ( )
}
2023-12-21 14:29:36 -06:00
// Remove playback sessions that were 3 seconds or less
const badSessionsRemoved = await this . playbackSessionModel . destroy ( {
where : {
timeListening : {
[ Op . lte ] : 3
}
}
} )
if ( badSessionsRemoved > 0 ) {
Logger . warn ( ` Removed ${ badSessionsRemoved } sessions that were 3 seconds or less ` )
}
2025-06-06 17:05:07 -05:00
// Remove mediaProgresses with duplicate mediaItemId (remove the oldest updatedAt)
2025-06-14 17:56:35 -05:00
// const [duplicateMediaProgresses] = await this.sequelize.query(`SELECT id, mediaItemId FROM mediaProgresses WHERE (mediaItemId, userId, updatedAt) IN (SELECT mediaItemId, userId, MIN(updatedAt) FROM mediaProgresses GROUP BY mediaItemId, userId HAVING COUNT(*) > 1)`)
// for (const duplicateMediaProgress of duplicateMediaProgresses) {
// Logger.warn(`Found duplicate mediaProgress for mediaItem "${duplicateMediaProgress.mediaItemId}" - removing it`)
// await this.mediaProgressModel.destroy({
// where: { id: duplicateMediaProgress.id }
// })
// }
2023-09-05 17:58:13 -05:00
}
2024-07-27 21:56:07 +03:00
2024-09-29 09:22:39 +03:00
async createTextSearchQuery ( query ) {
const textQuery = new this . TextSearchQuery ( this . sequelize , this . supportsUnaccent , query )
await textQuery . init ( )
return textQuery
2024-07-27 21:56:07 +03:00
}
2025-02-19 17:39:32 +02:00
/ * *
* This is used to create necessary triggers for new databases .
* It adds triggers to update libraryItems . title [ IgnorePrefix ] when ( books | podcasts ) . title [ IgnorePrefix ] is updated
* /
async addTriggers ( ) {
await this . addTriggerIfNotExists ( 'books' , 'title' , 'id' , 'libraryItems' , 'title' , 'mediaId' )
await this . addTriggerIfNotExists ( 'books' , 'titleIgnorePrefix' , 'id' , 'libraryItems' , 'titleIgnorePrefix' , 'mediaId' )
await this . addTriggerIfNotExists ( 'podcasts' , 'title' , 'id' , 'libraryItems' , 'title' , 'mediaId' )
await this . addTriggerIfNotExists ( 'podcasts' , 'titleIgnorePrefix' , 'id' , 'libraryItems' , 'titleIgnorePrefix' , 'mediaId' )
2025-03-18 00:09:49 +02:00
await this . addAuthorNamesTriggersIfNotExist ( )
2025-02-19 17:39:32 +02:00
}
async addTriggerIfNotExists ( sourceTable , sourceColumn , sourceIdColumn , targetTable , targetColumn , targetIdColumn ) {
const action = ` update_ ${ targetTable } _ ${ targetColumn } `
const fromSource = sourceTable === 'books' ? '' : ` _from_ ${ sourceTable } _ ${ sourceColumn } `
const triggerName = this . convertToSnakeCase ( ` ${ action } ${ fromSource } ` )
const [ [ { count } ] ] = await this . sequelize . query ( ` SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name=' ${ triggerName } ' ` )
if ( count > 0 ) return // Trigger already exists
Logger . info ( ` [Database] Adding trigger ${ triggerName } ` )
await this . sequelize . query ( `
CREATE TRIGGER $ { triggerName }
AFTER UPDATE OF $ { sourceColumn } ON $ { sourceTable }
FOR EACH ROW
BEGIN
UPDATE $ { targetTable }
SET $ { targetColumn } = NEW . $ { sourceColumn }
WHERE $ { targetTable } . $ { targetIdColumn } = NEW . $ { sourceIdColumn } ;
END ;
` )
}
2025-03-18 00:09:49 +02:00
async addAuthorNamesTriggersIfNotExist ( ) {
const libraryItems = 'libraryItems'
const bookAuthors = 'bookAuthors'
const authors = 'authors'
const columns = [
{ name : 'authorNamesFirstLast' , source : ` ${ authors } .name ` , spec : { type : Sequelize . STRING , allowNull : true } } ,
{ name : 'authorNamesLastFirst' , source : ` ${ authors } .lastFirst ` , spec : { type : Sequelize . STRING , allowNull : true } }
]
const authorsSort = ` ${ bookAuthors } .createdAt ASC `
const columnNames = columns . map ( ( column ) => column . name ) . join ( ', ' )
const columnSourcesExpression = columns . map ( ( column ) => ` GROUP_CONCAT( ${ column . source } , ', ' ORDER BY ${ authorsSort } ) ` ) . join ( ', ' )
const authorsJoin = ` ${ authors } JOIN ${ bookAuthors } ON ${ authors } .id = ${ bookAuthors } .authorId `
const addBookAuthorsTriggerIfNotExists = async ( action ) => {
const modifiedRecord = action === 'delete' ? 'OLD' : 'NEW'
const triggerName = this . convertToSnakeCase ( ` update_ ${ libraryItems } _authorNames_on_ ${ bookAuthors } _ ${ action } ` )
const authorNamesSubQuery = `
SELECT $ { columnSourcesExpression }
FROM $ { authorsJoin }
WHERE $ { bookAuthors } . bookId = $ { modifiedRecord } . bookId
`
const [ [ { count } ] ] = await this . sequelize . query ( ` SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name=' ${ triggerName } ' ` )
if ( count > 0 ) return // Trigger already exists
Logger . info ( ` [Database] Adding trigger ${ triggerName } ` )
await this . sequelize . query ( `
CREATE TRIGGER $ { triggerName }
AFTER $ { action } ON $ { bookAuthors }
FOR EACH ROW
BEGIN
UPDATE $ { libraryItems }
SET ( $ { columnNames } ) = ( $ { authorNamesSubQuery } )
WHERE mediaId = $ { modifiedRecord } . bookId ;
END ;
` )
}
const addAuthorsUpdateTriggerIfNotExists = async ( ) => {
const triggerName = this . convertToSnakeCase ( ` update_ ${ libraryItems } _authorNames_on_authors_update ` )
const authorNamesSubQuery = `
SELECT $ { columnSourcesExpression }
FROM $ { authorsJoin }
WHERE $ { bookAuthors } . bookId = $ { libraryItems } . mediaId
`
const [ [ { count } ] ] = await this . sequelize . query ( ` SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name=' ${ triggerName } ' ` )
if ( count > 0 ) return // Trigger already exists
Logger . info ( ` [Database] Adding trigger ${ triggerName } ` )
await this . sequelize . query ( `
CREATE TRIGGER $ { triggerName }
AFTER UPDATE OF name ON $ { authors }
FOR EACH ROW
BEGIN
UPDATE $ { libraryItems }
SET ( $ { columnNames } ) = ( $ { authorNamesSubQuery } )
WHERE mediaId IN ( SELECT bookId FROM $ { bookAuthors } WHERE authorId = NEW . id ) ;
END ;
` )
}
await addBookAuthorsTriggerIfNotExists ( 'insert' )
await addBookAuthorsTriggerIfNotExists ( 'delete' )
await addAuthorsUpdateTriggerIfNotExists ( )
}
2025-02-19 17:39:32 +02:00
convertToSnakeCase ( str ) {
return str . replace ( /([A-Z])/g , '_$1' ) . toLowerCase ( )
}
2024-09-29 09:22:39 +03:00
TextSearchQuery = class {
constructor ( sequelize , supportsUnaccent , query ) {
this . sequelize = sequelize
this . supportsUnaccent = supportsUnaccent
this . query = query
this . hasAccents = false
}
2024-07-27 21:56:07 +03:00
2024-09-29 09:22:39 +03:00
/ * *
* Returns a normalized ( accents - removed ) expression for the specified value .
*
* @ param { string } value
* @ returns { string }
* /
normalize ( value ) {
return ` unaccent( ${ value } ) `
}
/ * *
* Initialize the text query .
*
* /
async init ( ) {
if ( ! this . supportsUnaccent ) return
const escapedQuery = this . sequelize . escape ( this . query )
const normalizedQueryExpression = this . normalize ( escapedQuery )
const normalizedQueryResult = await this . sequelize . query ( ` SELECT ${ normalizedQueryExpression } as normalized_query ` )
const normalizedQuery = normalizedQueryResult [ 0 ] [ 0 ] . normalized _query
this . hasAccents = escapedQuery !== this . sequelize . escape ( normalizedQuery )
}
/ * *
* Get match expression for the specified column .
* If the query contains accents , match against the column as - is ( case - insensitive exact match ) .
* otherwise match against a normalized column ( case - insensitive match with accents removed ) .
*
* @ param { string } column
* @ returns { string }
* /
matchExpression ( column ) {
const pattern = this . sequelize . escape ( ` % ${ this . query } % ` )
if ( ! this . supportsUnaccent ) return ` ${ column } LIKE ${ pattern } `
const normalizedColumn = this . hasAccents ? column : this . normalize ( column )
return ` ${ normalizedColumn } LIKE ${ pattern } `
}
2024-07-27 21:56:07 +03:00
}
2023-07-04 18:14:44 -05:00
}
2024-06-22 16:42:13 -05:00
module . exports = new Database ( )