mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-09-10 17:58:02 +02:00
Merge c8bc1cb150
into 6ea70608a1
This commit is contained in:
commit
101bcb2819
33
.vscode/launch.json
vendored
33
.vscode/launch.json
vendored
@ -9,36 +9,33 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Debug server",
|
"name": "Debug server",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"args": [
|
"cwd": "${workspaceFolder}/client",
|
||||||
"run",
|
"args": ["run", "dev"],
|
||||||
"dev"
|
"skipFiles": ["<node_internals>/**"]
|
||||||
],
|
|
||||||
"skipFiles": [
|
|
||||||
"<node_internals>/**"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Prod server",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"args": ["run", "prod"],
|
||||||
|
"skipFiles": ["<node_internals>/**"]
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Debug client (nuxt)",
|
"name": "Debug client (nuxt)",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"args": [
|
"args": ["run", "dev"],
|
||||||
"run",
|
|
||||||
"dev"
|
|
||||||
],
|
|
||||||
"cwd": "${workspaceFolder}/client",
|
"cwd": "${workspaceFolder}/client",
|
||||||
"skipFiles": [
|
"skipFiles": ["${workspaceFolder}/<node_internals>/**"]
|
||||||
"${workspaceFolder}/<node_internals>/**"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"compounds": [
|
"compounds": [
|
||||||
{
|
{
|
||||||
"name": "Debug server and client (nuxt)",
|
"name": "Debug server and client (nuxt)",
|
||||||
"configurations": [
|
"configurations": ["Debug server", "Debug client (nuxt)"]
|
||||||
"Debug server",
|
|
||||||
"Debug client (nuxt)"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
962
package-lock.json
generated
962
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -63,6 +63,7 @@
|
|||||||
"mocha": "^10.2.0",
|
"mocha": "^10.2.0",
|
||||||
"nodemon": "^2.0.20",
|
"nodemon": "^2.0.20",
|
||||||
"nyc": "^15.1.0",
|
"nyc": "^15.1.0",
|
||||||
"sinon": "^17.0.1"
|
"sinon": "^21.0.0",
|
||||||
|
"rewire": "^9.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,14 +18,14 @@ class FolderWatcher extends EventEmitter {
|
|||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
/** @type {{id:string, name:string, libraryFolders:import('./models/Folder')[], paths:string[], watcher:Watcher[]}[]} */
|
/** @type {{id:string, name:string, libraryFolders:import('./models/LibraryFolder')[], paths:string[], watcher:Watcher[]}[]} */
|
||||||
this.libraryWatchers = []
|
this.libraryWatchers = []
|
||||||
/** @type {PendingFileUpdate[]} */
|
/** @type {PendingFileUpdate[]} */
|
||||||
this.pendingFileUpdates = []
|
this.pendingFileUpdates = []
|
||||||
this.pendingDelay = 10000
|
this.pendingDelay = 10000
|
||||||
/** @type {NodeJS.Timeout} */
|
/** @type {NodeJS.Timeout | null} */
|
||||||
this.pendingTimeout = null
|
this.pendingTimeout = null
|
||||||
/** @type {Task} */
|
/** @type {Task | null} */
|
||||||
this.pendingTask = null
|
this.pendingTask = null
|
||||||
|
|
||||||
this.filesBeingAdded = new Set()
|
this.filesBeingAdded = new Set()
|
||||||
@ -36,7 +36,7 @@ class FolderWatcher extends EventEmitter {
|
|||||||
this.ignoreDirs = []
|
this.ignoreDirs = []
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
this.pendingDirsToRemoveFromIgnore = []
|
this.pendingDirsToRemoveFromIgnore = []
|
||||||
/** @type {NodeJS.Timeout} */
|
/** @type {NodeJS.Timeout | null} */
|
||||||
this.removeFromIgnoreTimer = null
|
this.removeFromIgnoreTimer = null
|
||||||
|
|
||||||
this.disabled = false
|
this.disabled = false
|
||||||
|
@ -110,12 +110,12 @@ class LogManager {
|
|||||||
const exists = await fs.pathExists(fullPath)
|
const exists = await fs.pathExists(fullPath)
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
Logger.error(TAG, 'Invalid log dne ' + fullPath)
|
Logger.error(TAG, 'Invalid log dne ' + fullPath)
|
||||||
this.dailyLogFiles = this.dailyLogFiles.filter(dlf => dlf !== filename)
|
this.dailyLogFiles = this.dailyLogFiles.filter((dlf) => dlf !== filename)
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await fs.unlink(fullPath)
|
await fs.unlink(fullPath)
|
||||||
Logger.info(TAG, 'Removed daily log: ' + filename)
|
Logger.info(TAG, 'Removed daily log: ' + filename)
|
||||||
this.dailyLogFiles = this.dailyLogFiles.filter(dlf => dlf !== filename)
|
this.dailyLogFiles = this.dailyLogFiles.filter((dlf) => dlf !== filename)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(TAG, 'Failed to unlink log file ' + fullPath)
|
Logger.error(TAG, 'Failed to unlink log file ' + fullPath)
|
||||||
}
|
}
|
||||||
@ -161,7 +161,7 @@ class LogManager {
|
|||||||
const logsDir = Path.join(global.MetadataPath, 'logs')
|
const logsDir = Path.join(global.MetadataPath, 'logs')
|
||||||
await fs.ensureDir(logsDir)
|
await fs.ensureDir(logsDir)
|
||||||
const crashLogPath = Path.join(logsDir, 'crash_logs.txt')
|
const crashLogPath = Path.join(logsDir, 'crash_logs.txt')
|
||||||
return fs.writeFile(crashLogPath, line, { flag: "a+" }).catch((error) => {
|
return fs.writeFile(crashLogPath, line, { flag: 'a+' }).catch((error) => {
|
||||||
console.log('[LogManager] Appended crash log', error)
|
console.log('[LogManager] Appended crash log', error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -169,10 +169,10 @@ class LogManager {
|
|||||||
/**
|
/**
|
||||||
* Most recent 5000 daily logs
|
* Most recent 5000 daily logs
|
||||||
*
|
*
|
||||||
* @returns {string}
|
* @returns {LogObject[]}
|
||||||
*/
|
*/
|
||||||
getMostRecentCurrentDailyLogs() {
|
getMostRecentCurrentDailyLogs() {
|
||||||
return this.currentDailyLog?.logs.slice(-5000) || ''
|
return this.currentDailyLog?.logs.slice(-5000) || []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = LogManager
|
module.exports = LogManager
|
@ -16,3 +16,4 @@ Please add a record of every database migration that you create to this file. Th
|
|||||||
| v2.19.1 | v2.19.1-copy-title-to-library-items | Copies title and titleIgnorePrefix to the libraryItems table, creates update triggers and indices |
|
| v2.19.1 | v2.19.1-copy-title-to-library-items | Copies title and titleIgnorePrefix to the libraryItems table, creates update triggers and indices |
|
||||||
| v2.19.4 | v2.19.4-improve-podcast-queries | Adds numEpisodes to podcasts, adds podcastId to mediaProgresses, copies podcast title to libraryItems |
|
| v2.19.4 | v2.19.4-improve-podcast-queries | Adds numEpisodes to podcasts, adds podcastId to mediaProgresses, copies podcast title to libraryItems |
|
||||||
| v2.20.0 | v2.20.0-improve-author-sort-queries | Adds AuthorNames(FirstLast\|LastFirst) to libraryItems to improve author sort queries |
|
| v2.20.0 | v2.20.0-improve-author-sort-queries | Adds AuthorNames(FirstLast\|LastFirst) to libraryItems to improve author sort queries |
|
||||||
|
| v2.30.0 | v2.30.0-add-deviceId | Adds deviceId to libraryItems table to uniquely identify files in a filesystem |
|
||||||
|
193
server/migrations/v2.30.0-add-deviceId.js
Normal file
193
server/migrations/v2.30.0-add-deviceId.js
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
const util = require('util')
|
||||||
|
const { Sequelize, DataTypes } = require('sequelize')
|
||||||
|
const fileUtils = require('../utils/fileUtils')
|
||||||
|
const LibraryItem = require('../models/LibraryItem')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef MigrationContext
|
||||||
|
* @property {import('sequelize').QueryInterface} queryInterface - a sequelize QueryInterface object.
|
||||||
|
* @property {import('../Logger')} logger - a Logger object.
|
||||||
|
*
|
||||||
|
* @typedef MigrationOptions
|
||||||
|
* @property {MigrationContext} context - an object containing the migration context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const migrationVersion = '2.30.0'
|
||||||
|
const migrationName = `${migrationVersion}-add-deviceId`
|
||||||
|
const loggerPrefix = `[${migrationVersion} migration]`
|
||||||
|
|
||||||
|
// Migration constants
|
||||||
|
const libraryItemsTableName = 'libraryItems'
|
||||||
|
const columns = [{ name: 'deviceId', spec: { type: DataTypes.STRING, allowNull: true } }]
|
||||||
|
const columnNames = columns.map((column) => column.name).join(', ')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This upward migration adds a deviceId column to the libraryItems table and populates it.
|
||||||
|
* It also creates an index on the ino, deviceId columns.
|
||||||
|
*
|
||||||
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
*/
|
||||||
|
async function up({ context: { queryInterface, logger } }) {
|
||||||
|
const helper = new MigrationHelper(queryInterface, logger)
|
||||||
|
|
||||||
|
// Upwards migration script
|
||||||
|
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||||
|
|
||||||
|
// Add authorNames columns to libraryItems table
|
||||||
|
await helper.addColumns()
|
||||||
|
|
||||||
|
// Populate authorNames columns with the author names for each libraryItem
|
||||||
|
// TODO
|
||||||
|
await helper.populateColumnsFromSource()
|
||||||
|
|
||||||
|
// Create indexes on the authorNames columns
|
||||||
|
await helper.addIndexes()
|
||||||
|
|
||||||
|
// Add index on ino and deviceId to the podcastEpisodes table
|
||||||
|
await helper.addIndex('libraryItems', ['ino', 'deviceId'])
|
||||||
|
|
||||||
|
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This downward migration removes a deviceId column to the libraryItems table, *
|
||||||
|
* It also removes the index on ino and deviceId from the libraryItems table.
|
||||||
|
*
|
||||||
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
*/
|
||||||
|
async function down({ context: { queryInterface, logger } }) {
|
||||||
|
// Downward migration script
|
||||||
|
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||||
|
|
||||||
|
const helper = new MigrationHelper(queryInterface, logger)
|
||||||
|
|
||||||
|
// Remove index on publishedAt from the podcastEpisodes table
|
||||||
|
await helper.removeIndex('libraryItems', ['ino', 'deviceId'])
|
||||||
|
|
||||||
|
// Remove indexes on the authorNames columns
|
||||||
|
await helper.removeIndexes()
|
||||||
|
|
||||||
|
// Remove authorNames columns from libraryItems table
|
||||||
|
await helper.removeColumns()
|
||||||
|
|
||||||
|
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
class MigrationHelper {
|
||||||
|
constructor(queryInterface, logger) {
|
||||||
|
this.queryInterface = queryInterface
|
||||||
|
this.logger = logger
|
||||||
|
}
|
||||||
|
|
||||||
|
async addColumn(table, column, options) {
|
||||||
|
this.logger.info(`${loggerPrefix} adding column "${column}" to table "${table}"`)
|
||||||
|
const tableDescription = await this.queryInterface.describeTable(table)
|
||||||
|
if (!tableDescription[column]) {
|
||||||
|
await this.queryInterface.addColumn(table, column, options)
|
||||||
|
this.logger.info(`${loggerPrefix} added column "${column}" to table "${table}"`)
|
||||||
|
} else {
|
||||||
|
this.logger.info(`${loggerPrefix} column "${column}" already exists in table "${table}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addColumns() {
|
||||||
|
this.logger.info(`${loggerPrefix} adding ${columnNames} columns to ${libraryItemsTableName} table`)
|
||||||
|
for (const column of columns) {
|
||||||
|
await this.addColumn(libraryItemsTableName, column.name, column.spec)
|
||||||
|
}
|
||||||
|
this.logger.info(`${loggerPrefix} added ${columnNames} columns to ${libraryItemsTableName} table`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeColumn(table, column) {
|
||||||
|
this.logger.info(`${loggerPrefix} removing column "${column}" from table "${table}"`)
|
||||||
|
const tableDescription = await this.queryInterface.describeTable(table)
|
||||||
|
if (tableDescription[column]) {
|
||||||
|
await this.queryInterface.sequelize.query(`ALTER TABLE ${table} DROP COLUMN ${column}`)
|
||||||
|
this.logger.info(`${loggerPrefix} removed column "${column}" from table "${table}"`)
|
||||||
|
} else {
|
||||||
|
this.logger.info(`${loggerPrefix} column "${column}" does not exist in table "${table}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeColumns() {
|
||||||
|
this.logger.info(`${loggerPrefix} removing ${columnNames} columns from ${libraryItemsTableName} table`)
|
||||||
|
for (const column of columns) {
|
||||||
|
await this.removeColumn(libraryItemsTableName, column.name)
|
||||||
|
}
|
||||||
|
this.logger.info(`${loggerPrefix} removed ${columnNames} columns from ${libraryItemsTableName} table`)
|
||||||
|
}
|
||||||
|
// populate from existing files on filesystem
|
||||||
|
async populateColumnsFromSource() {
|
||||||
|
this.logger.info(`${loggerPrefix} populating ${columnNames} columns in ${libraryItemsTableName} table`)
|
||||||
|
|
||||||
|
// list all libraryItems
|
||||||
|
/** @type {[[LibraryItem], any]} */
|
||||||
|
const [libraryItems, metadata] = await this.queryInterface.sequelize.query('SELECT * FROM libraryItems')
|
||||||
|
// load file stats for all libraryItems
|
||||||
|
libraryItems.forEach(async (item) => {
|
||||||
|
const deviceId = await fileUtils.getDeviceId(item.path)
|
||||||
|
// set deviceId for each libraryItem
|
||||||
|
await this.queryInterface.sequelize.query(
|
||||||
|
`UPDATE :libraryItemsTableName
|
||||||
|
SET (deviceId) = (:deviceId)
|
||||||
|
WHERE id = :id`,
|
||||||
|
{
|
||||||
|
replacements: {
|
||||||
|
libraryItemsTableName: libraryItemsTableName,
|
||||||
|
deviceId: deviceId,
|
||||||
|
id: item.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.logger.info(`${loggerPrefix} populated ${columnNames} columns in ${libraryItems} table`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async addIndex(tableName, columns) {
|
||||||
|
const columnString = columns.map((column) => util.inspect(column)).join(', ')
|
||||||
|
const indexName = convertToSnakeCase(`${tableName}_${columns.map((column) => (typeof column === 'string' ? column : column.name)).join('_')}`)
|
||||||
|
try {
|
||||||
|
this.logger.info(`${loggerPrefix} adding index on [${columnString}] to table ${tableName}. index name: ${indexName}"`)
|
||||||
|
await this.queryInterface.addIndex(tableName, columns)
|
||||||
|
this.logger.info(`${loggerPrefix} added index on [${columnString}] to table ${tableName}. index name: ${indexName}"`)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'SequelizeDatabaseError' && error.message.includes('already exists')) {
|
||||||
|
this.logger.info(`${loggerPrefix} index [${columnString}] for table "${tableName}" already exists`)
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addIndexes() {
|
||||||
|
for (const column of columns) {
|
||||||
|
await this.addIndex(libraryItemsTableName, ['libraryId', 'mediaType', { name: column.name, collate: 'NOCASE' }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeIndex(tableName, columns) {
|
||||||
|
this.logger.info(`${loggerPrefix} removing index [${columns.join(', ')}] from table "${tableName}"`)
|
||||||
|
await this.queryInterface.removeIndex(tableName, columns)
|
||||||
|
this.logger.info(`${loggerPrefix} removed index [${columns.join(', ')}] from table "${tableName}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeIndexes() {
|
||||||
|
for (const column of columns) {
|
||||||
|
await this.removeIndex(libraryItemsTableName, ['libraryId', 'mediaType', column.name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Utility function to convert a string to snake case, e.g. "titleIgnorePrefix" -> "title_ignore_prefix"
|
||||||
|
*
|
||||||
|
* @param {string} str - the string to convert to snake case.
|
||||||
|
* @returns {string} - the string in snake case.
|
||||||
|
*/
|
||||||
|
function convertToSnakeCase(str) {
|
||||||
|
return str.replace(/([A-Z])/g, '_$1').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { up, down, migrationName }
|
@ -8,10 +8,11 @@ const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilter
|
|||||||
/**
|
/**
|
||||||
* @typedef EBookFileObject
|
* @typedef EBookFileObject
|
||||||
* @property {string} ino
|
* @property {string} ino
|
||||||
|
* @property {string} deviceId
|
||||||
* @property {string} ebookFormat
|
* @property {string} ebookFormat
|
||||||
* @property {number} addedAt
|
* @property {number} addedAt
|
||||||
* @property {number} updatedAt
|
* @property {number} updatedAt
|
||||||
* @property {{filename:string, ext:string, path:string, relPath:strFing, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
|
* @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -45,6 +46,7 @@ const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilter
|
|||||||
* @typedef AudioFileObject
|
* @typedef AudioFileObject
|
||||||
* @property {number} index
|
* @property {number} index
|
||||||
* @property {string} ino
|
* @property {string} ino
|
||||||
|
* @property {string} deviceId
|
||||||
* @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
|
* @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
|
||||||
* @property {number} addedAt
|
* @property {number} addedAt
|
||||||
* @property {number} updatedAt
|
* @property {number} updatedAt
|
||||||
|
@ -11,6 +11,7 @@ const Podcast = require('./Podcast')
|
|||||||
/**
|
/**
|
||||||
* @typedef LibraryFileObject
|
* @typedef LibraryFileObject
|
||||||
* @property {string} ino
|
* @property {string} ino
|
||||||
|
* @property {string} deviceId
|
||||||
* @property {boolean} isSupplementary
|
* @property {boolean} isSupplementary
|
||||||
* @property {number} addedAt
|
* @property {number} addedAt
|
||||||
* @property {number} updatedAt
|
* @property {number} updatedAt
|
||||||
@ -33,6 +34,8 @@ class LibraryItem extends Model {
|
|||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
this.ino
|
this.ino
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
|
this.deviceId
|
||||||
|
/** @type {string} */
|
||||||
this.path
|
this.path
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
this.relPath
|
this.relPath
|
||||||
@ -237,7 +240,7 @@ class LibraryItem extends Model {
|
|||||||
* @param {import('sequelize').WhereOptions} where
|
* @param {import('sequelize').WhereOptions} where
|
||||||
* @param {import('sequelize').BindOrReplacements} [replacements]
|
* @param {import('sequelize').BindOrReplacements} [replacements]
|
||||||
* @param {import('sequelize').IncludeOptions} [include]
|
* @param {import('sequelize').IncludeOptions} [include]
|
||||||
* @returns {Promise<LibraryItemExpanded>}
|
* @returns {Promise<LibraryItemExpanded | null>}
|
||||||
*/
|
*/
|
||||||
static async findOneExpanded(where, replacements = null, include = null) {
|
static async findOneExpanded(where, replacements = null, include = null) {
|
||||||
const libraryItem = await this.findOne({
|
const libraryItem = await this.findOne({
|
||||||
@ -289,7 +292,7 @@ class LibraryItem extends Model {
|
|||||||
* @param {import('./Library')} library
|
* @param {import('./Library')} library
|
||||||
* @param {import('./User')} user
|
* @param {import('./User')} user
|
||||||
* @param {object} options
|
* @param {object} options
|
||||||
* @returns {{ libraryItems:Object[], count:number }}
|
* @returns {Promise<{ libraryItems:Object[], count:number }>}
|
||||||
*/
|
*/
|
||||||
static async getByFilterAndSort(library, user, options) {
|
static async getByFilterAndSort(library, user, options) {
|
||||||
let start = Date.now()
|
let start = Date.now()
|
||||||
@ -670,6 +673,7 @@ class LibraryItem extends Model {
|
|||||||
primaryKey: true
|
primaryKey: true
|
||||||
},
|
},
|
||||||
ino: DataTypes.STRING,
|
ino: DataTypes.STRING,
|
||||||
|
deviceId: DataTypes.STRING,
|
||||||
path: DataTypes.STRING,
|
path: DataTypes.STRING,
|
||||||
relPath: DataTypes.STRING,
|
relPath: DataTypes.STRING,
|
||||||
mediaId: DataTypes.UUID,
|
mediaId: DataTypes.UUID,
|
||||||
@ -694,6 +698,9 @@ class LibraryItem extends Model {
|
|||||||
sequelize,
|
sequelize,
|
||||||
modelName: 'libraryItem',
|
modelName: 'libraryItem',
|
||||||
indexes: [
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['ino', 'deviceId']
|
||||||
|
},
|
||||||
{
|
{
|
||||||
fields: ['createdAt']
|
fields: ['createdAt']
|
||||||
},
|
},
|
||||||
|
@ -113,7 +113,7 @@ class Task {
|
|||||||
/**
|
/**
|
||||||
* Set task as finished
|
* Set task as finished
|
||||||
*
|
*
|
||||||
* @param {TaskString} [newDescriptionString] update description
|
* @param {TaskString | null} [newDescriptionString] update description
|
||||||
* @param {boolean} [clearDescription] clear description
|
* @param {boolean} [clearDescription] clear description
|
||||||
*/
|
*/
|
||||||
setFinished(newDescriptionString = null, clearDescription = false) {
|
setFinished(newDescriptionString = null, clearDescription = false) {
|
||||||
|
@ -6,6 +6,7 @@ class AudioFile {
|
|||||||
constructor(data) {
|
constructor(data) {
|
||||||
this.index = null
|
this.index = null
|
||||||
this.ino = null
|
this.ino = null
|
||||||
|
this.deviceId = null
|
||||||
/** @type {FileMetadata} */
|
/** @type {FileMetadata} */
|
||||||
this.metadata = null
|
this.metadata = null
|
||||||
this.addedAt = null
|
this.addedAt = null
|
||||||
@ -44,6 +45,7 @@ class AudioFile {
|
|||||||
return {
|
return {
|
||||||
index: this.index,
|
index: this.index,
|
||||||
ino: this.ino,
|
ino: this.ino,
|
||||||
|
deviceId: this.deviceId,
|
||||||
metadata: this.metadata.toJSON(),
|
metadata: this.metadata.toJSON(),
|
||||||
addedAt: this.addedAt,
|
addedAt: this.addedAt,
|
||||||
updatedAt: this.updatedAt,
|
updatedAt: this.updatedAt,
|
||||||
@ -69,9 +71,13 @@ class AudioFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ index: any; ino: any; deviceId: any; metadata: any; addedAt: any; updatedAt: any; manuallyVerified: any; exclude: any; error: null; trackNumFromMeta: any; discNumFromMeta: any; trackNumFromFilename: any; cdNumFromFilename: undefined; discNumFromFilename: any; format: any; duration: any; bitRate: any; language: any; codec: null; timeBase: any; channels: any; channelLayout: any; chapters: any[]; embeddedCoverArt: null; metaTags: any; }} data
|
||||||
|
*/
|
||||||
construct(data) {
|
construct(data) {
|
||||||
this.index = data.index
|
this.index = data.index
|
||||||
this.ino = data.ino
|
this.ino = data.ino
|
||||||
|
this.deviceId = data.deviceId
|
||||||
this.metadata = new FileMetadata(data.metadata || {})
|
this.metadata = new FileMetadata(data.metadata || {})
|
||||||
this.addedAt = data.addedAt
|
this.addedAt = data.addedAt
|
||||||
this.updatedAt = data.updatedAt
|
this.updatedAt = data.updatedAt
|
||||||
@ -112,6 +118,7 @@ class AudioFile {
|
|||||||
// New scanner creates AudioFile from AudioFileScanner
|
// New scanner creates AudioFile from AudioFileScanner
|
||||||
setDataFromProbe(libraryFile, probeData) {
|
setDataFromProbe(libraryFile, probeData) {
|
||||||
this.ino = libraryFile.ino || null
|
this.ino = libraryFile.ino || null
|
||||||
|
this.deviceId = libraryFile.deviceId || null
|
||||||
|
|
||||||
if (libraryFile.metadata instanceof FileMetadata) {
|
if (libraryFile.metadata instanceof FileMetadata) {
|
||||||
this.metadata = libraryFile.metadata.clone()
|
this.metadata = libraryFile.metadata.clone()
|
||||||
@ -137,7 +144,7 @@ class AudioFile {
|
|||||||
|
|
||||||
syncChapters(updatedChapters) {
|
syncChapters(updatedChapters) {
|
||||||
if (this.chapters.length !== updatedChapters.length) {
|
if (this.chapters.length !== updatedChapters.length) {
|
||||||
this.chapters = updatedChapters.map(ch => ({ ...ch }))
|
this.chapters = updatedChapters.map((ch) => ({ ...ch }))
|
||||||
return true
|
return true
|
||||||
} else if (updatedChapters.length === 0) {
|
} else if (updatedChapters.length === 0) {
|
||||||
if (this.chapters.length > 0) {
|
if (this.chapters.length > 0) {
|
||||||
@ -154,7 +161,7 @@ class AudioFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
this.chapters = updatedChapters.map(ch => ({ ...ch }))
|
this.chapters = updatedChapters.map((ch) => ({ ...ch }))
|
||||||
}
|
}
|
||||||
return hasUpdates
|
return hasUpdates
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
const FileMetadata = require('../metadata/FileMetadata')
|
const FileMetadata = require('../metadata/FileMetadata')
|
||||||
|
|
||||||
class EBookFile {
|
class EBookFile {
|
||||||
|
/**
|
||||||
|
* @param {{ ino: any; deviceId: any; isSupplementary?: boolean; addedAt?: number; updatedAt?: number; metadata?: { filename: string; ext: string; path: string; relPath: string; size: number; mtimeMs: number; ctimeMs: number; birthtimeMs: number; }; libraryFolderId?: any; libraryId?: any; mediaType?: any; mtimeMs?: any; ctimeMs?: any; birthtimeMs?: any; path?: any; relPath?: any; isFile?: any; mediaMetadata?: any; libraryFiles?: any; }} file
|
||||||
|
*/
|
||||||
constructor(file) {
|
constructor(file) {
|
||||||
this.ino = null
|
this.ino = null
|
||||||
|
this.deviceId = null
|
||||||
this.metadata = null
|
this.metadata = null
|
||||||
this.ebookFormat = null
|
this.ebookFormat = null
|
||||||
this.addedAt = null
|
this.addedAt = null
|
||||||
@ -13,8 +17,12 @@ class EBookFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ ino: any; deviceId: any; isSupplementary?: boolean | undefined; addedAt: any; updatedAt: any; metadata: any; libraryFolderId?: any; libraryId?: any; mediaType?: any; mtimeMs?: any; ctimeMs?: any; birthtimeMs?: any; path?: any; relPath?: any; isFile?: any; mediaMetadata?: any; libraryFiles?: any; ebookFormat?: any; }} file
|
||||||
|
*/
|
||||||
construct(file) {
|
construct(file) {
|
||||||
this.ino = file.ino
|
this.ino = file.ino
|
||||||
|
this.deviceId = file.deviceId
|
||||||
this.metadata = new FileMetadata(file.metadata)
|
this.metadata = new FileMetadata(file.metadata)
|
||||||
this.ebookFormat = file.ebookFormat || this.metadata.format
|
this.ebookFormat = file.ebookFormat || this.metadata.format
|
||||||
this.addedAt = file.addedAt
|
this.addedAt = file.addedAt
|
||||||
@ -24,6 +32,7 @@ class EBookFile {
|
|||||||
toJSON() {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
ino: this.ino,
|
ino: this.ino,
|
||||||
|
deviceId: this.deviceId,
|
||||||
metadata: this.metadata.toJSON(),
|
metadata: this.metadata.toJSON(),
|
||||||
ebookFormat: this.ebookFormat,
|
ebookFormat: this.ebookFormat,
|
||||||
addedAt: this.addedAt,
|
addedAt: this.addedAt,
|
||||||
@ -37,6 +46,7 @@ class EBookFile {
|
|||||||
|
|
||||||
setData(libraryFile) {
|
setData(libraryFile) {
|
||||||
this.ino = libraryFile.ino
|
this.ino = libraryFile.ino
|
||||||
|
this.deviceId = libraryFile.deviceId
|
||||||
this.metadata = libraryFile.metadata.clone()
|
this.metadata = libraryFile.metadata.clone()
|
||||||
this.ebookFormat = libraryFile.metadata.format
|
this.ebookFormat = libraryFile.metadata.format
|
||||||
this.addedAt = Date.now()
|
this.addedAt = Date.now()
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const { getFileTimestampsWithIno, filePathToPOSIX } = require('../../utils/fileUtils')
|
const fileUtils = require('../../utils/fileUtils')
|
||||||
const globals = require('../../utils/globals')
|
const globals = require('../../utils/globals')
|
||||||
const FileMetadata = require('../metadata/FileMetadata')
|
const FileMetadata = require('../metadata/FileMetadata')
|
||||||
|
|
||||||
class LibraryFile {
|
class LibraryFile {
|
||||||
|
/**
|
||||||
|
* @param {{ ino: any; deviceId: any; metadata?: { filename: any; ext: any; path: any; relPath: any; size: any; mtimeMs: any; ctimeMs: any; birthtimeMs: any; } | { filename: string; ext: string; path: string; relPath: string; size: number; mtimeMs: number; ctimeMs: number; birthtimeMs: number; } | null; isSupplementary?: any; addedAt?: any; updatedAt?: any; fileType?: string; libraryFolderId?: any; libraryId?: any; mediaType?: any; mtimeMs?: any; ctimeMs?: any; birthtimeMs?: any; path?: any; relPath?: any; isFile?: any; mediaMetadata?: any; libraryFiles?: any; } | undefined} [file]
|
||||||
|
*/
|
||||||
constructor(file) {
|
constructor(file) {
|
||||||
this.ino = null
|
this.ino = null
|
||||||
|
this.deviceId = null
|
||||||
this.metadata = null
|
this.metadata = null
|
||||||
this.isSupplementary = null
|
this.isSupplementary = null
|
||||||
this.addedAt = null
|
this.addedAt = null
|
||||||
@ -18,6 +22,7 @@ class LibraryFile {
|
|||||||
|
|
||||||
construct(file) {
|
construct(file) {
|
||||||
this.ino = file.ino
|
this.ino = file.ino
|
||||||
|
this.deviceId = file.deviceId
|
||||||
this.metadata = new FileMetadata(file.metadata)
|
this.metadata = new FileMetadata(file.metadata)
|
||||||
this.isSupplementary = file.isSupplementary === undefined ? null : file.isSupplementary
|
this.isSupplementary = file.isSupplementary === undefined ? null : file.isSupplementary
|
||||||
this.addedAt = file.addedAt
|
this.addedAt = file.addedAt
|
||||||
@ -27,7 +32,8 @@ class LibraryFile {
|
|||||||
toJSON() {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
ino: this.ino,
|
ino: this.ino,
|
||||||
metadata: this.metadata.toJSON(),
|
deviceId: this.deviceId,
|
||||||
|
metadata: this.metadata ? this.metadata.toJSON() : null,
|
||||||
isSupplementary: this.isSupplementary,
|
isSupplementary: this.isSupplementary,
|
||||||
addedAt: this.addedAt,
|
addedAt: this.addedAt,
|
||||||
updatedAt: this.updatedAt,
|
updatedAt: this.updatedAt,
|
||||||
@ -40,11 +46,13 @@ class LibraryFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get fileType() {
|
get fileType() {
|
||||||
|
if (this.metadata) {
|
||||||
if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image'
|
if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image'
|
||||||
if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio'
|
if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio'
|
||||||
if (globals.SupportedEbookTypes.includes(this.metadata.format)) return 'ebook'
|
if (globals.SupportedEbookTypes.includes(this.metadata.format)) return 'ebook'
|
||||||
if (globals.TextFileTypes.includes(this.metadata.format)) return 'text'
|
if (globals.TextFileTypes.includes(this.metadata.format)) return 'text'
|
||||||
if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata'
|
if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata'
|
||||||
|
}
|
||||||
return 'unknown'
|
return 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,14 +69,15 @@ class LibraryFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setDataFromPath(path, relPath) {
|
async setDataFromPath(path, relPath) {
|
||||||
var fileTsData = await getFileTimestampsWithIno(path)
|
var fileTsData = await fileUtils.getFileTimestampsWithIno(path)
|
||||||
var fileMetadata = new FileMetadata()
|
var fileMetadata = new FileMetadata()
|
||||||
fileMetadata.setData(fileTsData)
|
fileMetadata.setData(fileTsData)
|
||||||
fileMetadata.filename = Path.basename(relPath)
|
fileMetadata.filename = Path.basename(relPath)
|
||||||
fileMetadata.path = filePathToPOSIX(path)
|
fileMetadata.path = fileUtils.filePathToPOSIX(path)
|
||||||
fileMetadata.relPath = filePathToPOSIX(relPath)
|
fileMetadata.relPath = fileUtils.filePathToPOSIX(relPath)
|
||||||
fileMetadata.ext = Path.extname(relPath)
|
fileMetadata.ext = Path.extname(relPath)
|
||||||
this.ino = fileTsData.ino
|
this.ino = fileTsData.ino
|
||||||
|
this.deviceId = fileTsData.dev
|
||||||
this.metadata = fileMetadata
|
this.metadata = fileMetadata
|
||||||
this.addedAt = Date.now()
|
this.addedAt = Date.now()
|
||||||
this.updatedAt = Date.now()
|
this.updatedAt = Date.now()
|
||||||
|
@ -2,14 +2,17 @@ const packageJson = require('../../package.json')
|
|||||||
const { LogLevel } = require('../utils/constants')
|
const { LogLevel } = require('../utils/constants')
|
||||||
const LibraryItem = require('../models/LibraryItem')
|
const LibraryItem = require('../models/LibraryItem')
|
||||||
const globals = require('../utils/globals')
|
const globals = require('../utils/globals')
|
||||||
|
const LibraryFile = require('../objects/files/LibraryFile')
|
||||||
|
const LibraryScan = require('./LibraryScan')
|
||||||
|
const ScanLogger = require('./ScanLogger')
|
||||||
|
|
||||||
class LibraryItemScanData {
|
class LibraryItemScanData {
|
||||||
/**
|
/**
|
||||||
* @typedef LibraryFileModifiedObject
|
* @typedef {Object} LibraryFileModifiedObject
|
||||||
* @property {LibraryItem.LibraryFileObject} old
|
* @property {LibraryItem.LibraryFileObject} old
|
||||||
* @property {LibraryItem.LibraryFileObject} new
|
* @property {LibraryItem.LibraryFileObject} new
|
||||||
|
* @param {{ libraryFolderId: any; libraryId: any; mediaType: any; ino: any; deviceId: any; mtimeMs: any; ctimeMs: any; birthtimeMs: any; path: any; relPath: any; isFile: any; mediaMetadata: any; libraryFiles: any; }} data
|
||||||
*/
|
*/
|
||||||
|
|
||||||
constructor(data) {
|
constructor(data) {
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
this.libraryFolderId = data.libraryFolderId
|
this.libraryFolderId = data.libraryFolderId
|
||||||
@ -19,6 +22,8 @@ class LibraryItemScanData {
|
|||||||
this.mediaType = data.mediaType
|
this.mediaType = data.mediaType
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
this.ino = data.ino
|
this.ino = data.ino
|
||||||
|
/** @type {string} */
|
||||||
|
this.deviceId = data.deviceId
|
||||||
/** @type {number} */
|
/** @type {number} */
|
||||||
this.mtimeMs = data.mtimeMs
|
this.mtimeMs = data.mtimeMs
|
||||||
/** @type {number} */
|
/** @type {number} */
|
||||||
@ -54,9 +59,10 @@ class LibraryItemScanData {
|
|||||||
*/
|
*/
|
||||||
get libraryItemObject() {
|
get libraryItemObject() {
|
||||||
let size = 0
|
let size = 0
|
||||||
this.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))
|
this.libraryFiles.forEach((lf) => (size += !isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))
|
||||||
return {
|
return {
|
||||||
ino: this.ino,
|
ino: this.ino,
|
||||||
|
deviceId: this.deviceId,
|
||||||
path: this.path,
|
path: this.path,
|
||||||
relPath: this.relPath,
|
relPath: this.relPath,
|
||||||
mediaType: this.mediaType,
|
mediaType: this.mediaType,
|
||||||
@ -80,107 +86,107 @@ class LibraryItemScanData {
|
|||||||
|
|
||||||
/** @type {boolean} */
|
/** @type {boolean} */
|
||||||
get hasAudioFileChanges() {
|
get hasAudioFileChanges() {
|
||||||
return (this.audioLibraryFilesRemoved.length + this.audioLibraryFilesAdded.length + this.audioLibraryFilesModified.length) > 0
|
return this.audioLibraryFilesRemoved.length + this.audioLibraryFilesAdded.length + this.audioLibraryFilesModified.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {LibraryFileModifiedObject[]} */
|
/** @type {LibraryFileModifiedObject[]} */
|
||||||
get audioLibraryFilesModified() {
|
get audioLibraryFilesModified() {
|
||||||
return this.libraryFilesModified.filter(lf => globals.SupportedAudioTypes.includes(lf.old.metadata.ext?.slice(1).toLowerCase() || ''))
|
return this.libraryFilesModified.filter((lf) => globals.SupportedAudioTypes.includes(lf.old.metadata.ext?.slice(1).toLowerCase() || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {LibraryItem.LibraryFileObject[]} */
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
||||||
get audioLibraryFilesRemoved() {
|
get audioLibraryFilesRemoved() {
|
||||||
return this.libraryFilesRemoved.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
return this.libraryFilesRemoved.filter((lf) => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {LibraryItem.LibraryFileObject[]} */
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
||||||
get audioLibraryFilesAdded() {
|
get audioLibraryFilesAdded() {
|
||||||
return this.libraryFilesAdded.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
return this.libraryFilesAdded.filter((lf) => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {LibraryItem.LibraryFileObject[]} */
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
||||||
get audioLibraryFiles() {
|
get audioLibraryFiles() {
|
||||||
return this.libraryFiles.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
return this.libraryFiles.filter((lf) => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {LibraryFileModifiedObject[]} */
|
/** @type {LibraryFileModifiedObject[]} */
|
||||||
get imageLibraryFilesModified() {
|
get imageLibraryFilesModified() {
|
||||||
return this.libraryFilesModified.filter(lf => globals.SupportedImageTypes.includes(lf.old.metadata.ext?.slice(1).toLowerCase() || ''))
|
return this.libraryFilesModified.filter((lf) => globals.SupportedImageTypes.includes(lf.old.metadata.ext?.slice(1).toLowerCase() || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {LibraryItem.LibraryFileObject[]} */
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
||||||
get imageLibraryFilesRemoved() {
|
get imageLibraryFilesRemoved() {
|
||||||
return this.libraryFilesRemoved.filter(lf => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
return this.libraryFilesRemoved.filter((lf) => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {LibraryItem.LibraryFileObject[]} */
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
||||||
get imageLibraryFilesAdded() {
|
get imageLibraryFilesAdded() {
|
||||||
return this.libraryFilesAdded.filter(lf => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
return this.libraryFilesAdded.filter((lf) => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {LibraryItem.LibraryFileObject[]} */
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
||||||
get imageLibraryFiles() {
|
get imageLibraryFiles() {
|
||||||
return this.libraryFiles.filter(lf => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
return this.libraryFiles.filter((lf) => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {LibraryFileModifiedObject[]} */
|
/** @type {LibraryFileModifiedObject[]} */
|
||||||
get ebookLibraryFilesModified() {
|
get ebookLibraryFilesModified() {
|
||||||
return this.libraryFilesModified.filter(lf => globals.SupportedEbookTypes.includes(lf.old.metadata.ext?.slice(1).toLowerCase() || ''))
|
return this.libraryFilesModified.filter((lf) => globals.SupportedEbookTypes.includes(lf.old.metadata.ext?.slice(1).toLowerCase() || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {LibraryItem.LibraryFileObject[]} */
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
||||||
get ebookLibraryFilesRemoved() {
|
get ebookLibraryFilesRemoved() {
|
||||||
return this.libraryFilesRemoved.filter(lf => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
return this.libraryFilesRemoved.filter((lf) => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {LibraryItem.LibraryFileObject[]} */
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
||||||
get ebookLibraryFilesAdded() {
|
get ebookLibraryFilesAdded() {
|
||||||
return this.libraryFilesAdded.filter(lf => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
return this.libraryFilesAdded.filter((lf) => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {LibraryItem.LibraryFileObject[]} */
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
||||||
get ebookLibraryFiles() {
|
get ebookLibraryFiles() {
|
||||||
return this.libraryFiles.filter(lf => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
return this.libraryFiles.filter((lf) => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {LibraryItem.LibraryFileObject} */
|
/** @type {LibraryItem.LibraryFileObject} */
|
||||||
get descTxtLibraryFile() {
|
get descTxtLibraryFile() {
|
||||||
return this.libraryFiles.find(lf => lf.metadata.filename === 'desc.txt')
|
return this.libraryFiles.find((lf) => lf.metadata.filename === 'desc.txt')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {LibraryItem.LibraryFileObject} */
|
/** @type {LibraryItem.LibraryFileObject} */
|
||||||
get readerTxtLibraryFile() {
|
get readerTxtLibraryFile() {
|
||||||
return this.libraryFiles.find(lf => lf.metadata.filename === 'reader.txt')
|
return this.libraryFiles.find((lf) => lf.metadata.filename === 'reader.txt')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {LibraryItem.LibraryFileObject} */
|
/** @type {LibraryItem.LibraryFileObject} */
|
||||||
get metadataAbsLibraryFile() {
|
get metadataAbsLibraryFile() {
|
||||||
return this.libraryFiles.find(lf => lf.metadata.filename === 'metadata.abs')
|
return this.libraryFiles.find((lf) => lf.metadata.filename === 'metadata.abs')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {LibraryItem.LibraryFileObject} */
|
/** @type {LibraryItem.LibraryFileObject} */
|
||||||
get metadataJsonLibraryFile() {
|
get metadataJsonLibraryFile() {
|
||||||
return this.libraryFiles.find(lf => lf.metadata.filename === 'metadata.json')
|
return this.libraryFiles.find((lf) => lf.metadata.filename === 'metadata.json')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {LibraryItem.LibraryFileObject} */
|
/** @type {LibraryItem.LibraryFileObject} */
|
||||||
get metadataOpfLibraryFile() {
|
get metadataOpfLibraryFile() {
|
||||||
return this.libraryFiles.find(lf => lf.metadata.ext.toLowerCase() === '.opf')
|
return this.libraryFiles.find((lf) => lf.metadata.ext.toLowerCase() === '.opf')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {LibraryItem.LibraryFileObject} */
|
/** @type {LibraryItem.LibraryFileObject} */
|
||||||
get metadataNfoLibraryFile() {
|
get metadataNfoLibraryFile() {
|
||||||
return this.libraryFiles.find(lf => lf.metadata.ext.toLowerCase() === '.nfo')
|
return this.libraryFiles.find((lf) => lf.metadata.ext.toLowerCase() === '.nfo')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {LibraryItem} existingLibraryItem
|
* @param {LibraryItem} existingLibraryItem
|
||||||
* @param {import('./LibraryScan')} libraryScan
|
* @param {import('./LibraryScan') | import('./ScanLogger')} libraryScan
|
||||||
* @returns {boolean} true if changes found
|
* @returns {Promise<boolean>} true if changes found
|
||||||
*/
|
*/
|
||||||
async checkLibraryItemData(existingLibraryItem, libraryScan) {
|
async checkLibraryItemData(existingLibraryItem, libraryScan) {
|
||||||
const keysToCompare = ['libraryFolderId', 'ino', 'path', 'relPath', 'isFile']
|
const keysToCompare = ['libraryFolderId', 'ino', 'deviceId', 'path', 'relPath', 'isFile']
|
||||||
this.hasChanges = false
|
this.hasChanges = false
|
||||||
this.hasPathChange = false
|
this.hasPathChange = false
|
||||||
for (const key of keysToCompare) {
|
for (const key of keysToCompare) {
|
||||||
@ -219,28 +225,23 @@ class LibraryItemScanData {
|
|||||||
|
|
||||||
this.libraryFilesRemoved = []
|
this.libraryFilesRemoved = []
|
||||||
this.libraryFilesModified = []
|
this.libraryFilesModified = []
|
||||||
let libraryFilesAdded = this.libraryFiles.map(lf => lf)
|
let libraryFilesAdded = this.libraryFiles.map((lf) => lf)
|
||||||
|
|
||||||
for (const existingLibraryFile of existingLibraryItem.libraryFiles) {
|
for (const existingLibraryFile of existingLibraryItem.libraryFiles) {
|
||||||
// Find matching library file using path first and fallback to using inode value
|
// Find matching library file using path first and fallback to using inode value
|
||||||
let matchingLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === existingLibraryFile.metadata.path)
|
let matchingLibraryFile = this.findMatchingLibraryFileByPathOrInodeAndDeviceId(existingLibraryFile, libraryScan)
|
||||||
if (!matchingLibraryFile) {
|
|
||||||
matchingLibraryFile = this.libraryFiles.find(lf => lf.ino === existingLibraryFile.ino)
|
|
||||||
if (matchingLibraryFile) {
|
|
||||||
libraryScan.addLog(LogLevel.INFO, `Library file with path "${existingLibraryFile.metadata.path}" not found, but found file with matching inode value "${existingLibraryFile.ino}" at path "${matchingLibraryFile.metadata.path}"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!matchingLibraryFile) { // Library file removed
|
if (!matchingLibraryFile) {
|
||||||
|
// Library file removed
|
||||||
libraryScan.addLog(LogLevel.INFO, `Library file "${existingLibraryFile.metadata.path}" was removed from library item "${existingLibraryItem.relPath}"`)
|
libraryScan.addLog(LogLevel.INFO, `Library file "${existingLibraryFile.metadata.path}" was removed from library item "${existingLibraryItem.relPath}"`)
|
||||||
this.libraryFilesRemoved.push(existingLibraryFile)
|
this.libraryFilesRemoved.push(existingLibraryFile)
|
||||||
existingLibraryItem.libraryFiles = existingLibraryItem.libraryFiles.filter(lf => lf !== existingLibraryFile)
|
existingLibraryItem.libraryFiles = existingLibraryItem.libraryFiles.filter((lf) => lf !== existingLibraryFile)
|
||||||
this.hasChanges = true
|
this.hasChanges = true
|
||||||
} else {
|
} else {
|
||||||
libraryFilesAdded = libraryFilesAdded.filter(lf => lf !== matchingLibraryFile)
|
libraryFilesAdded = libraryFilesAdded.filter((lf) => lf !== matchingLibraryFile)
|
||||||
let existingLibraryFileBefore = structuredClone(existingLibraryFile)
|
let existingLibraryFileBefore = structuredClone(existingLibraryFile)
|
||||||
if (this.compareUpdateLibraryFile(existingLibraryItem.path, existingLibraryFile, matchingLibraryFile, libraryScan)) {
|
if (LibraryItemScanData.compareUpdateLibraryFile(existingLibraryItem.path, existingLibraryFile, matchingLibraryFile, libraryScan)) {
|
||||||
this.libraryFilesModified.push({old: existingLibraryFileBefore, new: existingLibraryFile})
|
this.libraryFilesModified.push({ old: existingLibraryFileBefore, new: existingLibraryFile })
|
||||||
this.hasChanges = true
|
this.hasChanges = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -263,7 +264,7 @@ class LibraryItemScanData {
|
|||||||
|
|
||||||
if (this.hasChanges) {
|
if (this.hasChanges) {
|
||||||
existingLibraryItem.size = 0
|
existingLibraryItem.size = 0
|
||||||
existingLibraryItem.libraryFiles.forEach((lf) => existingLibraryItem.size += lf.metadata.size)
|
existingLibraryItem.libraryFiles.forEach((lf) => (existingLibraryItem.size += lf.metadata.size))
|
||||||
|
|
||||||
existingLibraryItem.lastScan = Date.now()
|
existingLibraryItem.lastScan = Date.now()
|
||||||
existingLibraryItem.lastScanVersion = packageJson.version
|
existingLibraryItem.lastScanVersion = packageJson.version
|
||||||
@ -274,10 +275,9 @@ class LibraryItemScanData {
|
|||||||
existingLibraryItem.changed('libraryFiles', true)
|
existingLibraryItem.changed('libraryFiles', true)
|
||||||
}
|
}
|
||||||
await existingLibraryItem.save()
|
await existingLibraryItem.save()
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return this.hasChanges
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -285,14 +285,15 @@ class LibraryItemScanData {
|
|||||||
* @param {string} libraryItemPath
|
* @param {string} libraryItemPath
|
||||||
* @param {LibraryItem.LibraryFileObject} existingLibraryFile
|
* @param {LibraryItem.LibraryFileObject} existingLibraryFile
|
||||||
* @param {import('../objects/files/LibraryFile')} scannedLibraryFile
|
* @param {import('../objects/files/LibraryFile')} scannedLibraryFile
|
||||||
* @param {import('./LibraryScan')} libraryScan
|
* @param {import('./LibraryScan') | import('./ScanLogger')} libraryScan
|
||||||
* @returns {boolean} false if no changes
|
* @returns {boolean} false if no changes
|
||||||
*/
|
*/
|
||||||
compareUpdateLibraryFile(libraryItemPath, existingLibraryFile, scannedLibraryFile, libraryScan) {
|
static compareUpdateLibraryFile(libraryItemPath, existingLibraryFile, scannedLibraryFile, libraryScan) {
|
||||||
let hasChanges = false
|
let hasChanges = false
|
||||||
|
|
||||||
if (existingLibraryFile.ino !== scannedLibraryFile.ino) {
|
if (existingLibraryFile.ino !== scannedLibraryFile.ino && existingLibraryFile.deviceId !== scannedLibraryFile.deviceId) {
|
||||||
existingLibraryFile.ino = scannedLibraryFile.ino
|
existingLibraryFile.ino = scannedLibraryFile.ino
|
||||||
|
existingLibraryFile.deviceId = scannedLibraryFile.deviceId
|
||||||
hasChanges = true
|
hasChanges = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -315,6 +316,23 @@ class LibraryItemScanData {
|
|||||||
return hasChanges
|
return hasChanges
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {LibraryFile | undefined} if [existingLibraryFile] matches an existing libraryFile
|
||||||
|
* @param {LibraryItem.LibraryFileObject} [existingLibraryFile]
|
||||||
|
* @param {LibraryScan | ScanLogger} [libraryScan]
|
||||||
|
*/
|
||||||
|
findMatchingLibraryFileByPathOrInodeAndDeviceId(existingLibraryFile, libraryScan) {
|
||||||
|
if (!existingLibraryFile) return
|
||||||
|
let matchingLibraryFile = this.libraryFiles.find((lf) => lf.metadata.path === existingLibraryFile.metadata.path)
|
||||||
|
if (!matchingLibraryFile) {
|
||||||
|
matchingLibraryFile = this.libraryFiles.find((lf) => lf.ino === existingLibraryFile.ino && lf.deviceId === existingLibraryFile.deviceId)
|
||||||
|
if (matchingLibraryFile) {
|
||||||
|
libraryScan && libraryScan.addLog(LogLevel.INFO, `Library file with path "${existingLibraryFile.metadata.path}" not found, but found file with matching inode value "${existingLibraryFile.ino}" at path "${matchingLibraryFile.metadata.path}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matchingLibraryFile
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if existing audio file on Book was removed
|
* Check if existing audio file on Book was removed
|
||||||
* @param {import('../models/Book').AudioFileObject} existingAudioFile
|
* @param {import('../models/Book').AudioFileObject} existingAudioFile
|
||||||
@ -323,11 +341,11 @@ class LibraryItemScanData {
|
|||||||
checkAudioFileRemoved(existingAudioFile) {
|
checkAudioFileRemoved(existingAudioFile) {
|
||||||
if (!this.audioLibraryFilesRemoved.length) return false
|
if (!this.audioLibraryFilesRemoved.length) return false
|
||||||
// First check exact path
|
// First check exact path
|
||||||
if (this.audioLibraryFilesRemoved.some(af => af.metadata.path === existingAudioFile.metadata.path)) {
|
if (this.audioLibraryFilesRemoved.some((af) => af.metadata.path === existingAudioFile.metadata.path)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Fallback to check inode value
|
// Fallback to check inode value
|
||||||
return this.audioLibraryFilesRemoved.some(af => af.ino === existingAudioFile.ino)
|
return this.audioLibraryFilesRemoved.some((af) => af.ino === existingAudioFile.ino && af.deviceId === existingAudioFile.deviceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -336,13 +354,13 @@ class LibraryItemScanData {
|
|||||||
* @returns {boolean} true if ebook file was removed
|
* @returns {boolean} true if ebook file was removed
|
||||||
*/
|
*/
|
||||||
checkEbookFileRemoved(ebookFile) {
|
checkEbookFileRemoved(ebookFile) {
|
||||||
if (!this.ebookLibraryFiles.length) return true
|
if (!this.ebookLibraryFilesRemoved.length) return false
|
||||||
|
|
||||||
if (this.ebookLibraryFiles.some(lf => lf.metadata.path === ebookFile.metadata.path)) {
|
if (this.ebookLibraryFilesRemoved.some((lf) => lf.metadata.path === ebookFile.metadata.path)) {
|
||||||
return false
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return !this.ebookLibraryFiles.some(lf => lf.ino === ebookFile.ino)
|
return this.ebookLibraryFilesRemoved.some((lf) => lf.ino === ebookFile.ino && lf.deviceId === ebookFile.deviceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -23,7 +23,7 @@ class LibraryItemScanner {
|
|||||||
* Scan single library item
|
* Scan single library item
|
||||||
*
|
*
|
||||||
* @param {string} libraryItemId
|
* @param {string} libraryItemId
|
||||||
* @param {{relPath:string, path:string}} [updateLibraryItemDetails] used by watcher when item folder was renamed
|
* @param {{relPath:string, path:string, isFile: boolean}} [updateLibraryItemDetails] used by watcher when item folder was renamed
|
||||||
* @returns {number} ScanResult
|
* @returns {number} ScanResult
|
||||||
*/
|
*/
|
||||||
async scanLibraryItem(libraryItemId, updateLibraryItemDetails = null) {
|
async scanLibraryItem(libraryItemId, updateLibraryItemDetails = null) {
|
||||||
@ -139,24 +139,11 @@ class LibraryItemScanner {
|
|||||||
const newLibraryFile = new LibraryFile()
|
const newLibraryFile = new LibraryFile()
|
||||||
// fileItem.path is the relative path
|
// fileItem.path is the relative path
|
||||||
await newLibraryFile.setDataFromPath(fileItem.fullpath, fileItem.path)
|
await newLibraryFile.setDataFromPath(fileItem.fullpath, fileItem.path)
|
||||||
|
// TODO: BUGBUG - this is pushing the object, not a JSON string of the object like elsewhere
|
||||||
libraryFiles.push(newLibraryFile)
|
libraryFiles.push(newLibraryFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryItemStats = await fileUtils.getFileTimestampsWithIno(libraryItemData.path)
|
return await buildLibraryItemScanData(libraryItemData, folder, library, isSingleMediaItem, libraryFiles)
|
||||||
return new LibraryItemScanData({
|
|
||||||
libraryFolderId: folder.id,
|
|
||||||
libraryId: library.id,
|
|
||||||
mediaType: library.mediaType,
|
|
||||||
ino: libraryItemStats.ino,
|
|
||||||
mtimeMs: libraryItemStats.mtimeMs || 0,
|
|
||||||
ctimeMs: libraryItemStats.ctimeMs || 0,
|
|
||||||
birthtimeMs: libraryItemStats.birthtimeMs || 0,
|
|
||||||
path: libraryItemData.path,
|
|
||||||
relPath: libraryItemData.relPath,
|
|
||||||
isFile: isSingleMediaItem,
|
|
||||||
mediaMetadata: libraryItemData.mediaMetadata || null,
|
|
||||||
libraryFiles
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -201,7 +188,7 @@ class LibraryItemScanner {
|
|||||||
* @param {import('../models/Library')} library
|
* @param {import('../models/Library')} library
|
||||||
* @param {import('../models/LibraryFolder')} folder
|
* @param {import('../models/LibraryFolder')} folder
|
||||||
* @param {boolean} isSingleMediaItem
|
* @param {boolean} isSingleMediaItem
|
||||||
* @returns {Promise<LibraryItem>} ScanResult
|
* @returns {Promise<LibraryItem | null>} ScanResult
|
||||||
*/
|
*/
|
||||||
async scanPotentialNewLibraryItem(libraryItemPath, library, folder, isSingleMediaItem) {
|
async scanPotentialNewLibraryItem(libraryItemPath, library, folder, isSingleMediaItem) {
|
||||||
const libraryItemScanData = await this.getLibraryItemScanData(libraryItemPath, library, folder, isSingleMediaItem)
|
const libraryItemScanData = await this.getLibraryItemScanData(libraryItemPath, library, folder, isSingleMediaItem)
|
||||||
@ -219,3 +206,29 @@ class LibraryItemScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = new LibraryItemScanner()
|
module.exports = new LibraryItemScanner()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ path?: any; relPath?: any; mediaMetadata?: any; }} libraryItemData
|
||||||
|
* @param {import("../models/LibraryFolder")} folder
|
||||||
|
* @param {import("../models/Library")} library
|
||||||
|
* @param {boolean} isSingleMediaItem
|
||||||
|
* @param {LibraryFile[]} libraryFiles
|
||||||
|
*/
|
||||||
|
async function buildLibraryItemScanData(libraryItemData, folder, library, isSingleMediaItem, libraryFiles) {
|
||||||
|
const libraryItemStats = await fileUtils.getFileTimestampsWithIno(libraryItemData.path)
|
||||||
|
return new LibraryItemScanData({
|
||||||
|
libraryFolderId: folder.id,
|
||||||
|
libraryId: library.id,
|
||||||
|
mediaType: library.mediaType,
|
||||||
|
ino: libraryItemStats.ino,
|
||||||
|
deviceId: libraryItemStats.dev,
|
||||||
|
mtimeMs: libraryItemStats.mtimeMs || 0,
|
||||||
|
ctimeMs: libraryItemStats.ctimeMs || 0,
|
||||||
|
birthtimeMs: libraryItemStats.birthtimeMs || 0,
|
||||||
|
path: libraryItemData.path,
|
||||||
|
relPath: libraryItemData.relPath,
|
||||||
|
isFile: isSingleMediaItem,
|
||||||
|
mediaMetadata: libraryItemData.mediaMetadata || null,
|
||||||
|
libraryFiles
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -297,7 +297,7 @@ class LibraryScanner {
|
|||||||
* Get scan data for library folder
|
* Get scan data for library folder
|
||||||
* @param {import('../models/Library')} library
|
* @param {import('../models/Library')} library
|
||||||
* @param {import('../models/LibraryFolder')} folder
|
* @param {import('../models/LibraryFolder')} folder
|
||||||
* @returns {LibraryItemScanData[]}
|
* @returns {Promise<LibraryItemScanData[]>}
|
||||||
*/
|
*/
|
||||||
async scanFolder(library, folder) {
|
async scanFolder(library, folder) {
|
||||||
const folderPath = fileUtils.filePathToPOSIX(folder.path)
|
const folderPath = fileUtils.filePathToPOSIX(folder.path)
|
||||||
@ -344,22 +344,7 @@ class LibraryScanner {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push(
|
items.push(createLibraryItemScanData(folder, library, libraryItemFolderStats, libraryItemData, isFile, fileObjs))
|
||||||
new LibraryItemScanData({
|
|
||||||
libraryFolderId: folder.id,
|
|
||||||
libraryId: folder.libraryId,
|
|
||||||
mediaType: library.mediaType,
|
|
||||||
ino: libraryItemFolderStats.ino,
|
|
||||||
mtimeMs: libraryItemFolderStats.mtimeMs || 0,
|
|
||||||
ctimeMs: libraryItemFolderStats.ctimeMs || 0,
|
|
||||||
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
|
|
||||||
path: libraryItemData.path,
|
|
||||||
relPath: libraryItemData.relPath,
|
|
||||||
isFile,
|
|
||||||
mediaMetadata: libraryItemData.mediaMetadata || null,
|
|
||||||
libraryFiles: fileObjs
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
@ -642,12 +627,25 @@ class LibraryScanner {
|
|||||||
}
|
}
|
||||||
module.exports = new LibraryScanner()
|
module.exports = new LibraryScanner()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("../models/LibraryItem") | LibraryItemScanData} libraryItem1
|
||||||
|
* @param {import("../models/LibraryItem") | LibraryItemScanData} libraryItem2
|
||||||
|
*/
|
||||||
function ItemToFileInoMatch(libraryItem1, libraryItem2) {
|
function ItemToFileInoMatch(libraryItem1, libraryItem2) {
|
||||||
return libraryItem1.isFile && libraryItem2.libraryFiles.some((lf) => lf.ino === libraryItem1.ino)
|
return (
|
||||||
|
libraryItem1.isFile &&
|
||||||
|
libraryItem2.libraryFiles.some((lf) => {
|
||||||
|
return lf.ino === libraryItem1.ino && lf.deviceId === libraryItem1.deviceId
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {LibraryItemScanData} libraryItem1
|
||||||
|
* @param {import("../models/LibraryItem")} libraryItem2
|
||||||
|
*/
|
||||||
function ItemToItemInoMatch(libraryItem1, libraryItem2) {
|
function ItemToItemInoMatch(libraryItem1, libraryItem2) {
|
||||||
return libraryItem1.ino === libraryItem2.ino
|
return libraryItem1.ino === libraryItem2.ino && libraryItem1.deviceId === libraryItem2.deviceId
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasAudioFiles(fileUpdateGroup, itemDir) {
|
function hasAudioFiles(fileUpdateGroup, itemDir) {
|
||||||
@ -658,54 +656,111 @@ function isSingleMediaFile(fileUpdateGroup, itemDir) {
|
|||||||
return itemDir === fileUpdateGroup[itemDir]
|
return itemDir === fileUpdateGroup[itemDir]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {UUIDV4} libraryId
|
||||||
|
* @param {string} fullPath
|
||||||
|
* @returns {Promise<import('../models/LibraryItem').LibraryItemExpanded | null>} library item that matches
|
||||||
|
*/
|
||||||
async function findLibraryItemByItemToItemInoMatch(libraryId, fullPath) {
|
async function findLibraryItemByItemToItemInoMatch(libraryId, fullPath) {
|
||||||
const ino = await fileUtils.getIno(fullPath)
|
const ino = await fileUtils.getIno(fullPath)
|
||||||
|
const deviceId = await fileUtils.getDeviceId(fullPath)
|
||||||
if (!ino) return null
|
if (!ino) return null
|
||||||
const existingLibraryItem = await Database.libraryItemModel.findOneExpanded({
|
const existingLibraryItem = await Database.libraryItemModel.findOneExpanded({
|
||||||
libraryId: libraryId,
|
libraryId: libraryId,
|
||||||
ino: ino
|
ino: ino,
|
||||||
|
deviceId: deviceId
|
||||||
})
|
})
|
||||||
if (existingLibraryItem) Logger.debug(`[LibraryScanner] Found library item with matching inode "${ino}" at path "${existingLibraryItem.path}"`)
|
if (existingLibraryItem) Logger.debug(`[LibraryScanner] Found library item with matching inode "${ino}" at path "${existingLibraryItem.path}"`)
|
||||||
return existingLibraryItem
|
return existingLibraryItem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {UUIDV4} libraryId
|
||||||
|
* @param {string} fullPath
|
||||||
|
* @param {boolean} isSingleMedia
|
||||||
|
* @returns {Promise<import('../models/LibraryItem').LibraryItemExpanded | null>} library item that matches
|
||||||
|
*/
|
||||||
async function findLibraryItemByItemToFileInoMatch(libraryId, fullPath, isSingleMedia) {
|
async function findLibraryItemByItemToFileInoMatch(libraryId, fullPath, isSingleMedia) {
|
||||||
if (!isSingleMedia) return null
|
if (!isSingleMedia) return null
|
||||||
// check if it was moved from another folder by comparing the ino to the library files
|
// check if it was moved from another folder by comparing the ino to the library files
|
||||||
const ino = await fileUtils.getIno(fullPath)
|
const ino = await fileUtils.getIno(fullPath)
|
||||||
|
const deviceId = await fileUtils.getDeviceId(fullPath)
|
||||||
if (!ino) return null
|
if (!ino) return null
|
||||||
const existingLibraryItem = await Database.libraryItemModel.findOneExpanded(
|
const existingLibraryItem = await Database.libraryItemModel.findOneExpanded(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
libraryId: libraryId
|
libraryId: libraryId
|
||||||
},
|
},
|
||||||
sequelize.where(sequelize.literal('(SELECT count(*) FROM json_each(libraryFiles) WHERE json_valid(json_each.value) AND json_each.value->>"$.ino" = :inode)'), {
|
sequelize.where(sequelize.literal('(SELECT count(*) FROM json_each(libraryFiles) WHERE json_valid(json_each.value) AND json_each.value->>"$.ino" = :inode AND json_each.value->>"$.deviceId" = :deviceId)'), {
|
||||||
[sequelize.Op.gt]: 0
|
[sequelize.Op.gt]: 0
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
inode: ino
|
inode: ino,
|
||||||
|
deviceId: deviceId
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (existingLibraryItem) Logger.debug(`[LibraryScanner] Found library item with a library file matching inode "${ino}" at path "${existingLibraryItem.path}"`)
|
if (existingLibraryItem) Logger.debug(`[LibraryScanner] Found library item with a library file matching inode "${ino}" at path "${existingLibraryItem.path}"`)
|
||||||
return existingLibraryItem
|
return existingLibraryItem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {UUIDV4} libraryId
|
||||||
|
* @param {string} fullPath
|
||||||
|
* @param {boolean} isSingleMedia
|
||||||
|
* @param {string[]} itemFiles
|
||||||
|
* @returns {Promise<import('../models/LibraryItem').LibraryItemExpanded | null>} library item that matches
|
||||||
|
*/
|
||||||
async function findLibraryItemByFileToItemInoMatch(libraryId, fullPath, isSingleMedia, itemFiles) {
|
async function findLibraryItemByFileToItemInoMatch(libraryId, fullPath, isSingleMedia, itemFiles) {
|
||||||
if (isSingleMedia) return null
|
if (isSingleMedia) return null
|
||||||
// check if it was moved from the root folder by comparing the ino to the ino of the scanned files
|
// check if it was moved from the root folder by comparing the ino and deviceId to the ino and deviceId of the scanned files
|
||||||
let itemFileInos = []
|
let itemFileInos = []
|
||||||
for (const itemFile of itemFiles) {
|
for (const itemFile of itemFiles) {
|
||||||
const ino = await fileUtils.getIno(Path.posix.join(fullPath, itemFile))
|
const ino = await fileUtils.getIno(Path.posix.join(fullPath, itemFile))
|
||||||
if (ino) itemFileInos.push(ino)
|
const deviceId = await fileUtils.getDeviceId(Path.posix.join(fullPath, itemFile))
|
||||||
|
if (ino && deviceId) itemFileInos.push({ ino: ino, deviceId: deviceId })
|
||||||
}
|
}
|
||||||
if (!itemFileInos.length) return null
|
if (!itemFileInos.length) return null
|
||||||
const existingLibraryItem = await Database.libraryItemModel.findOneExpanded({
|
/** @type {import('../models/LibraryItem').LibraryItemExpanded | null} */
|
||||||
|
let existingLibraryItem = null
|
||||||
|
for (let item in itemFileInos) {
|
||||||
|
existingLibraryItem = await Database.libraryItemModel.findOneExpanded({
|
||||||
libraryId: libraryId,
|
libraryId: libraryId,
|
||||||
ino: {
|
[sequelize.Op.or]: itemFileInos
|
||||||
[sequelize.Op.in]: itemFileInos
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (existingLibraryItem) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (existingLibraryItem) Logger.debug(`[LibraryScanner] Found library item with inode matching one of "${itemFileInos.join(',')}" at path "${existingLibraryItem.path}"`)
|
if (existingLibraryItem) Logger.debug(`[LibraryScanner] Found library item with inode matching one of "${itemFileInos.join(',')}" at path "${existingLibraryItem.path}"`)
|
||||||
return existingLibraryItem
|
return existingLibraryItem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ id: any; libraryId: any; }} folder
|
||||||
|
* @param {{ mediaType: any; }} library
|
||||||
|
* @param {{ ino: any; dev: any; mtimeMs: any; ctimeMs: any; birthtimeMs: any; }} libraryItemFolderStats
|
||||||
|
* @param {{ path: any; relPath: any; mediaMetadata: any; }} libraryItemData
|
||||||
|
* @param {any} isFile
|
||||||
|
* @param {any} fileObjs
|
||||||
|
* @returns {LibraryItemScanData} new object
|
||||||
|
*/
|
||||||
|
function createLibraryItemScanData(folder, library, libraryItemFolderStats, libraryItemData, isFile, fileObjs) {
|
||||||
|
return new LibraryItemScanData({
|
||||||
|
libraryFolderId: folder.id,
|
||||||
|
libraryId: folder.libraryId,
|
||||||
|
mediaType: library.mediaType,
|
||||||
|
ino: libraryItemFolderStats.ino,
|
||||||
|
deviceId: libraryItemFolderStats.dev,
|
||||||
|
mtimeMs: libraryItemFolderStats.mtimeMs || 0,
|
||||||
|
ctimeMs: libraryItemFolderStats.ctimeMs || 0,
|
||||||
|
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
|
||||||
|
path: libraryItemData.path,
|
||||||
|
relPath: libraryItemData.relPath,
|
||||||
|
isFile,
|
||||||
|
mediaMetadata: libraryItemData.mediaMetadata || null,
|
||||||
|
libraryFiles: fileObjs
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -366,7 +366,7 @@ class PodcastScanner {
|
|||||||
* @param {PodcastEpisode[]} podcastEpisodes Not the models for new podcasts
|
* @param {PodcastEpisode[]} podcastEpisodes Not the models for new podcasts
|
||||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||||
* @param {import('./LibraryScan')} libraryScan
|
* @param {import('./LibraryScan')} libraryScan
|
||||||
* @param {string} [existingLibraryItemId]
|
* @param {string | null} [existingLibraryItemId]
|
||||||
* @returns {Promise<PodcastMetadataObject>}
|
* @returns {Promise<PodcastMetadataObject>}
|
||||||
*/
|
*/
|
||||||
async getPodcastMetadataFromScanData(podcastEpisodes, libraryItemData, libraryScan, existingLibraryItemId = null) {
|
async getPodcastMetadataFromScanData(podcastEpisodes, libraryItemData, libraryScan, existingLibraryItemId = null) {
|
||||||
|
@ -47,6 +47,10 @@ function getFileStat(path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} path
|
||||||
|
* @returns {Promise<object | null>}
|
||||||
|
*/
|
||||||
async function getFileTimestampsWithIno(path) {
|
async function getFileTimestampsWithIno(path) {
|
||||||
try {
|
try {
|
||||||
var stat = await fs.stat(path, { bigint: true })
|
var stat = await fs.stat(path, { bigint: true })
|
||||||
@ -55,11 +59,12 @@ async function getFileTimestampsWithIno(path) {
|
|||||||
mtimeMs: Number(stat.mtimeMs),
|
mtimeMs: Number(stat.mtimeMs),
|
||||||
ctimeMs: Number(stat.ctimeMs),
|
ctimeMs: Number(stat.ctimeMs),
|
||||||
birthtimeMs: Number(stat.birthtimeMs),
|
birthtimeMs: Number(stat.birthtimeMs),
|
||||||
ino: String(stat.ino)
|
ino: String(stat.ino),
|
||||||
|
deviceId: String(stat.dev)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Logger.error(`[fileUtils] Failed to getFileTimestampsWithIno for path "${path}"`, err)
|
Logger.error(`[fileUtils] Failed to getFileTimestampsWithIno for path "${path}"`, err)
|
||||||
return false
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports.getFileTimestampsWithIno = getFileTimestampsWithIno
|
module.exports.getFileTimestampsWithIno = getFileTimestampsWithIno
|
||||||
@ -92,7 +97,7 @@ module.exports.getFileMTimeMs = async (path) => {
|
|||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} filepath
|
* @param {string} filepath
|
||||||
* @returns {boolean}
|
* @returns {Promise<boolean>} isFile
|
||||||
*/
|
*/
|
||||||
async function checkPathIsFile(filepath) {
|
async function checkPathIsFile(filepath) {
|
||||||
try {
|
try {
|
||||||
@ -104,6 +109,10 @@ async function checkPathIsFile(filepath) {
|
|||||||
}
|
}
|
||||||
module.exports.checkPathIsFile = checkPathIsFile
|
module.exports.checkPathIsFile = checkPathIsFile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} path
|
||||||
|
* @returns {string | null} inode
|
||||||
|
*/
|
||||||
function getIno(path) {
|
function getIno(path) {
|
||||||
return fs
|
return fs
|
||||||
.stat(path, { bigint: true })
|
.stat(path, { bigint: true })
|
||||||
@ -115,10 +124,25 @@ function getIno(path) {
|
|||||||
}
|
}
|
||||||
module.exports.getIno = getIno
|
module.exports.getIno = getIno
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} path
|
||||||
|
* @returns {Promise<string | null>} deviceId
|
||||||
|
*/
|
||||||
|
async function getDeviceId(path) {
|
||||||
|
try {
|
||||||
|
var data = await fs.stat(path)
|
||||||
|
return String(data.dev)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[Utils] Failed to get device Id for path "${path}": ${error}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports.getDeviceId = getDeviceId
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read contents of file
|
* Read contents of file
|
||||||
* @param {string} path
|
* @param {string} path
|
||||||
* @returns {string}
|
* @returns {Promise<string>} file contents
|
||||||
*/
|
*/
|
||||||
async function readTextFile(path) {
|
async function readTextFile(path) {
|
||||||
try {
|
try {
|
||||||
@ -135,7 +159,7 @@ module.exports.readTextFile = readTextFile
|
|||||||
* Check if file or directory should be ignored. Returns a string of the reason to ignore, or null if not ignored
|
* Check if file or directory should be ignored. Returns a string of the reason to ignore, or null if not ignored
|
||||||
*
|
*
|
||||||
* @param {string} path
|
* @param {string} path
|
||||||
* @returns {string}
|
* @returns {string | null} reason to ignore
|
||||||
*/
|
*/
|
||||||
module.exports.shouldIgnoreFile = (path) => {
|
module.exports.shouldIgnoreFile = (path) => {
|
||||||
// Check if directory or file name starts with "."
|
// Check if directory or file name starts with "."
|
||||||
@ -178,8 +202,8 @@ module.exports.shouldIgnoreFile = (path) => {
|
|||||||
/**
|
/**
|
||||||
* Get array of files inside dir
|
* Get array of files inside dir
|
||||||
* @param {string} path
|
* @param {string} path
|
||||||
* @param {string} [relPathToReplace]
|
* @param {string | null} [relPathToReplace]
|
||||||
* @returns {FilePathItem[]}
|
* @returns {Promise<FilePathItem[]>}
|
||||||
*/
|
*/
|
||||||
module.exports.recurseFiles = async (path, relPathToReplace = null) => {
|
module.exports.recurseFiles = async (path, relPathToReplace = null) => {
|
||||||
path = filePathToPOSIX(path)
|
path = filePathToPOSIX(path)
|
||||||
@ -219,6 +243,8 @@ module.exports.recurseFiles = async (path, relPathToReplace = null) => {
|
|||||||
|
|
||||||
item.fullname = filePathToPOSIX(item.fullname)
|
item.fullname = filePathToPOSIX(item.fullname)
|
||||||
item.path = filePathToPOSIX(item.path)
|
item.path = filePathToPOSIX(item.path)
|
||||||
|
// BUGBUG: This is broken with symlinked directory /tmp -> /private/tmp. when library is in /tmp/testLibrary, it tries to replace /tmp/testLibrary with '' but in a canonical path (non-symlinked)
|
||||||
|
// TODO: find the commit that added relPathToReplace and figure out what it's trying to do and make it do that properly
|
||||||
const relpath = item.fullname.replace(relPathToReplace, '')
|
const relpath = item.fullname.replace(relPathToReplace, '')
|
||||||
let reldirname = Path.dirname(relpath)
|
let reldirname = Path.dirname(relpath)
|
||||||
if (reldirname === '.') reldirname = ''
|
if (reldirname === '.') reldirname = ''
|
||||||
@ -292,7 +318,7 @@ module.exports.getFilePathItemFromFileUpdate = (fileUpdate) => {
|
|||||||
*
|
*
|
||||||
* @param {string} url
|
* @param {string} url
|
||||||
* @param {string} filepath path to download the file to
|
* @param {string} filepath path to download the file to
|
||||||
* @param {Function} [contentTypeFilter] validate content type before writing
|
* @param {Function | null} [contentTypeFilter] validate content type before writing
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
module.exports.downloadFile = (url, filepath, contentTypeFilter = null) => {
|
module.exports.downloadFile = (url, filepath, contentTypeFilter = null) => {
|
||||||
|
@ -13,9 +13,9 @@ async function writeMetadataFileForItem(libraryItem) {
|
|||||||
const storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem && !libraryItem.isFile
|
const storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem && !libraryItem.isFile
|
||||||
const metadataPath = storeMetadataWithItem ? libraryItem.path : Path.join(global.MetadataPath, 'items', libraryItem.id)
|
const metadataPath = storeMetadataWithItem ? libraryItem.path : Path.join(global.MetadataPath, 'items', libraryItem.id)
|
||||||
const metadataFilepath = fileUtils.filePathToPOSIX(Path.join(metadataPath, 'metadata.json'))
|
const metadataFilepath = fileUtils.filePathToPOSIX(Path.join(metadataPath, 'metadata.json'))
|
||||||
if ((await fsExtra.pathExists(metadataFilepath))) {
|
if (await fsExtra.pathExists(metadataFilepath)) {
|
||||||
// Metadata file already exists do nothing
|
// Metadata file already exists do nothing
|
||||||
return null
|
return false
|
||||||
}
|
}
|
||||||
Logger.info(`[absMetadataMigration] metadata file not found at "${metadataFilepath}" - creating`)
|
Logger.info(`[absMetadataMigration] metadata file not found at "${metadataFilepath}" - creating`)
|
||||||
|
|
||||||
@ -27,7 +27,10 @@ async function writeMetadataFileForItem(libraryItem) {
|
|||||||
const metadataJson = libraryItem.media.getAbsMetadataJson()
|
const metadataJson = libraryItem.media.getAbsMetadataJson()
|
||||||
|
|
||||||
// Save to file
|
// Save to file
|
||||||
const success = await fsExtra.writeFile(metadataFilepath, JSON.stringify(metadataJson, null, 2)).then(() => true).catch((error) => {
|
const success = await fsExtra
|
||||||
|
.writeFile(metadataFilepath, JSON.stringify(metadataJson, null, 2))
|
||||||
|
.then(() => true)
|
||||||
|
.catch((error) => {
|
||||||
Logger.error(`[absMetadataMigration] failed to save metadata file at "${metadataFilepath}"`, error.message || error)
|
Logger.error(`[absMetadataMigration] failed to save metadata file at "${metadataFilepath}"`, error.message || error)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
@ -36,11 +39,12 @@ async function writeMetadataFileForItem(libraryItem) {
|
|||||||
if (!storeMetadataWithItem) return true // No need to do anything else
|
if (!storeMetadataWithItem) return true // No need to do anything else
|
||||||
|
|
||||||
// Safety check to make sure library file with the same path isnt already there
|
// Safety check to make sure library file with the same path isnt already there
|
||||||
libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== metadataFilepath)
|
libraryItem.libraryFiles = libraryItem.libraryFiles.filter((lf) => lf.metadata.path !== metadataFilepath)
|
||||||
|
|
||||||
// Put new library file in library item
|
// Put new library file in library item
|
||||||
const newLibraryFile = new LibraryFile()
|
const newLibraryFile = new LibraryFile()
|
||||||
await newLibraryFile.setDataFromPath(metadataFilepath, 'metadata.json')
|
await newLibraryFile.setDataFromPath(metadataFilepath, 'metadata.json')
|
||||||
|
// TODO: BUGBUG - this shouldn't be JSON and it may not be the right type LibraryFileObject
|
||||||
libraryItem.libraryFiles.push(newLibraryFile.toJSON())
|
libraryItem.libraryFiles.push(newLibraryFile.toJSON())
|
||||||
|
|
||||||
// Update library item timestamps and total size
|
// Update library item timestamps and total size
|
||||||
@ -49,12 +53,15 @@ async function writeMetadataFileForItem(libraryItem) {
|
|||||||
libraryItem.mtime = libraryItemDirTimestamps.mtimeMs
|
libraryItem.mtime = libraryItemDirTimestamps.mtimeMs
|
||||||
libraryItem.ctime = libraryItemDirTimestamps.ctimeMs
|
libraryItem.ctime = libraryItemDirTimestamps.ctimeMs
|
||||||
let size = 0
|
let size = 0
|
||||||
libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))
|
libraryItem.libraryFiles.forEach((lf) => (size += !isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))
|
||||||
libraryItem.size = size
|
libraryItem.size = size
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryItem.changed('libraryFiles', true)
|
libraryItem.changed('libraryFiles', true)
|
||||||
return libraryItem.save().then(() => true).catch((error) => {
|
return libraryItem
|
||||||
|
.save()
|
||||||
|
.then(() => true)
|
||||||
|
.catch((error) => {
|
||||||
Logger.error(`[absMetadataMigration] failed to save libraryItem "${libraryItem.id}"`, error.message || error)
|
Logger.error(`[absMetadataMigration] failed to save libraryItem "${libraryItem.id}"`, error.message || error)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
172
test/server/MockDatabase.js
Normal file
172
test/server/MockDatabase.js
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
const Database = require('../../server/Database')
|
||||||
|
const { Sequelize } = require('sequelize')
|
||||||
|
const LibraryFile = require('../../server/objects/files/LibraryFile')
|
||||||
|
const fileUtils = require('../../server/utils/fileUtils')
|
||||||
|
const FileMetadata = require('../../server/objects/metadata/FileMetadata')
|
||||||
|
const Path = require('path')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
|
||||||
|
async function loadTestDatabase(mockFileInfo) {
|
||||||
|
let libraryItem1Id, libraryItem2Id
|
||||||
|
|
||||||
|
let fileInfo = mockFileInfo || getMockFileInfo()
|
||||||
|
// mapping the keys() iterable to an explicit array so reduce() should work consistently.
|
||||||
|
let bookLibraryFiles = [...fileInfo.keys()].reduce((acc, key) => {
|
||||||
|
let bookfile = new LibraryFile()
|
||||||
|
bookfile.setDataFromPath(key, key)
|
||||||
|
acc.push(bookfile)
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
global.ServerSettings = {}
|
||||||
|
Database.sequelize = new Sequelize({
|
||||||
|
dialect: 'sqlite',
|
||||||
|
storage: ':memory:',
|
||||||
|
// Choose one of the logging options
|
||||||
|
logging: (...msg) => console.log(msg),
|
||||||
|
logQueryParameters: true
|
||||||
|
})
|
||||||
|
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||||
|
await Database.buildModels()
|
||||||
|
|
||||||
|
const newLibrary = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
||||||
|
const newLibraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: newLibrary.id })
|
||||||
|
const newLibraryFolder2 = await Database.libraryFolderModel.create({ path: '/mnt/drive', libraryId: newLibrary.id })
|
||||||
|
|
||||||
|
const newBook = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||||
|
const newLibraryItem = await Database.libraryItemModel.create(buildBookLibraryItemParams(bookLibraryFiles[0], newBook.id, newLibrary.id, newLibraryFolder.id))
|
||||||
|
libraryItem1Id = newLibraryItem.id
|
||||||
|
|
||||||
|
const newBook2 = await Database.bookModel.create({ title: 'Test Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||||
|
const newLibraryItem2 = await Database.libraryItemModel.create(buildBookLibraryItemParams(bookLibraryFiles[1], newBook2.id, newLibrary.id, newLibraryFolder2.id))
|
||||||
|
libraryItem2Id = newLibraryItem2.id
|
||||||
|
|
||||||
|
return newLibrary
|
||||||
|
}
|
||||||
|
exports.loadTestDatabase = loadTestDatabase
|
||||||
|
|
||||||
|
/** @returns {Map<string, import('fs').Stats>} */
|
||||||
|
function getMockFileInfo() {
|
||||||
|
// @ts-ignore
|
||||||
|
return new Map([
|
||||||
|
['/test/file.pdf', { path: '/test/file.pdf', isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '100' }],
|
||||||
|
['/mnt/drive/file-same-ino-different-dev.pdf', { path: '/mnt/drive/file-same-ino-different-dev.pdf', isDirectory: () => false, size: 42, mtimeMs: Date.now(), ino: '1', dev: '200' }]
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.getMockFileInfo = getMockFileInfo
|
||||||
|
/** @returns {Map<string, import('fs').Stats>} */
|
||||||
|
// this has the same data as above except one file has been renamed
|
||||||
|
function getRenamedMockFileInfo() {
|
||||||
|
// @ts-ignore
|
||||||
|
return new Map([
|
||||||
|
['/test/file-renamed.pdf', { path: '/test/file-renamed.pdf', isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '100' }],
|
||||||
|
['/mnt/drive/file-same-ino-different-dev.pdf', { path: '/mnt/drive/file-same-ino-different-dev.pdf', isDirectory: () => false, size: 42, mtimeMs: Date.now(), ino: '1', dev: '200' }]
|
||||||
|
])
|
||||||
|
}
|
||||||
|
exports.getRenamedMockFileInfo = getRenamedMockFileInfo
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {LibraryFile} libraryFile
|
||||||
|
* @param {any} bookId
|
||||||
|
* @param {string} libraryId
|
||||||
|
* @param {any} libraryFolderId
|
||||||
|
*/
|
||||||
|
function buildBookLibraryItemParams(libraryFile, bookId, libraryId, libraryFolderId) {
|
||||||
|
return {
|
||||||
|
path: libraryFile.metadata?.path,
|
||||||
|
isFile: true,
|
||||||
|
ino: libraryFile.ino,
|
||||||
|
deviceId: libraryFile.deviceId,
|
||||||
|
libraryFiles: [libraryFile.toJSON()],
|
||||||
|
mediaId: bookId,
|
||||||
|
mediaType: 'book',
|
||||||
|
libraryId: libraryId,
|
||||||
|
libraryFolderId: libraryFolderId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.buildBookLibraryItemParams = buildBookLibraryItemParams
|
||||||
|
|
||||||
|
function stubFileUtils(mockFileInfo = getMockFileInfo()) {
|
||||||
|
let getInoStub, getDeviceIdStub, getFileTimestampsWithInoStub
|
||||||
|
getInoStub = sinon.stub(fileUtils, 'getIno')
|
||||||
|
getInoStub.callsFake((path) => {
|
||||||
|
const normalizedPath = fileUtils.filePathToPOSIX(path).replace(/\/$/, '')
|
||||||
|
const stats = mockFileInfo.get(normalizedPath)
|
||||||
|
if (stats) {
|
||||||
|
return stats.ino
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
getDeviceIdStub = sinon.stub(fileUtils, 'getDeviceId')
|
||||||
|
getDeviceIdStub.callsFake(async (path) => {
|
||||||
|
const normalizedPath = fileUtils.filePathToPOSIX(path).replace(/\/$/, '')
|
||||||
|
const stats = mockFileInfo.get(normalizedPath)
|
||||||
|
if (stats) {
|
||||||
|
return stats.dev
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
getFileTimestampsWithInoStub = sinon.stub(fileUtils, 'getFileTimestampsWithIno')
|
||||||
|
getFileTimestampsWithInoStub.callsFake(async (path) => {
|
||||||
|
const normalizedPath = fileUtils.filePathToPOSIX(path).replace(/\/$/, '')
|
||||||
|
const stats = mockFileInfo.get(normalizedPath)
|
||||||
|
if (stats) {
|
||||||
|
return stats
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
exports.stubFileUtils = stubFileUtils
|
||||||
|
|
||||||
|
/** @returns {{ libraryFolderId: any; libraryId: any; mediaType: any; ino: any; deviceId: any; mtimeMs: any; ctimeMs: any; birthtimeMs: any; path: any; relPath: any; isFile: any; mediaMetadata: any; libraryFiles: any; }} */
|
||||||
|
function buildFileProperties(path = '/tmp/foo.epub', ino = '12345', deviceId = '9876', libraryFiles = []) {
|
||||||
|
const metadata = new FileMetadata()
|
||||||
|
metadata.filename = Path.basename(path)
|
||||||
|
metadata.path = path
|
||||||
|
metadata.relPath = path
|
||||||
|
metadata.ext = Path.extname(path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ino: ino,
|
||||||
|
deviceId: deviceId,
|
||||||
|
metadata: metadata,
|
||||||
|
isSupplementary: false,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
libraryFiles: [...libraryFiles.map((lf) => lf.toJSON())]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.buildFileProperties = buildFileProperties
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {import('../../server/models/LibraryItem').LibraryFileObject}
|
||||||
|
* @param {string} [path]
|
||||||
|
* @param {string} [ino]
|
||||||
|
* @param {string} [deviceId]
|
||||||
|
*/
|
||||||
|
function buildLibraryFileProperties(path, ino, deviceId) {
|
||||||
|
return {
|
||||||
|
ino: ino,
|
||||||
|
deviceId: deviceId,
|
||||||
|
isSupplementary: false,
|
||||||
|
addedAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
metadata: {
|
||||||
|
filename: Path.basename(path),
|
||||||
|
ext: Path.extname(path),
|
||||||
|
path: path,
|
||||||
|
relPath: path,
|
||||||
|
size: 0,
|
||||||
|
mtimeMs: 0,
|
||||||
|
ctimeMs: 0,
|
||||||
|
birthtimeMs: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.buildLibraryFileProperties = buildLibraryFileProperties
|
178
test/server/migrations/v2.30.0-add-deviceId.test.js
Normal file
178
test/server/migrations/v2.30.0-add-deviceId.test.js
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
const chai = require('chai')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
const { expect } = chai
|
||||||
|
|
||||||
|
const { DataTypes, Sequelize } = require('sequelize')
|
||||||
|
const Logger = require('../../../server/Logger')
|
||||||
|
|
||||||
|
const { up, down, migrationName } = require('../../../server/migrations/v2.30.0-add-deviceId')
|
||||||
|
const { stubFileUtils, getMockFileInfo } = require('../MockDatabase')
|
||||||
|
|
||||||
|
const normalizeWhitespaceAndBackticks = (str) => str.replace(/\s+/g, ' ').trim().replace(/`/g, '')
|
||||||
|
|
||||||
|
describe(`Migration ${migrationName}`, () => {
|
||||||
|
let sequelize
|
||||||
|
let queryInterface
|
||||||
|
let loggerInfoStub
|
||||||
|
let mockFileInfo, file1stats, file2stats
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||||
|
queryInterface = sequelize.getQueryInterface()
|
||||||
|
loggerInfoStub = sinon.stub(Logger, 'info')
|
||||||
|
|
||||||
|
mockFileInfo = getMockFileInfo()
|
||||||
|
file1stats = mockFileInfo.get('/test/file.pdf')
|
||||||
|
file2stats = mockFileInfo.get('/mnt/drive/file-same-ino-different-dev.pdf')
|
||||||
|
|
||||||
|
stubFileUtils(mockFileInfo)
|
||||||
|
|
||||||
|
await queryInterface.createTable('libraryItems', {
|
||||||
|
id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },
|
||||||
|
ino: { type: DataTypes.STRING },
|
||||||
|
path: { type: DataTypes.STRING },
|
||||||
|
mediaId: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
|
mediaType: { type: DataTypes.STRING, allowNull: false },
|
||||||
|
libraryId: { type: DataTypes.INTEGER, allowNull: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
await queryInterface.createTable('authors', {
|
||||||
|
id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },
|
||||||
|
name: { type: DataTypes.STRING, allowNull: false },
|
||||||
|
lastFirst: { type: DataTypes.STRING, allowNull: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
await queryInterface.createTable('bookAuthors', {
|
||||||
|
id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },
|
||||||
|
bookId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'libraryItems', key: 'id', onDelete: 'CASCADE' } },
|
||||||
|
authorId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'authors', key: 'id', onDelete: 'CASCADE' } },
|
||||||
|
createdAt: { type: DataTypes.DATE, allowNull: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
await queryInterface.createTable('podcastEpisodes', {
|
||||||
|
id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },
|
||||||
|
publishedAt: { type: DataTypes.DATE, allowNull: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
await queryInterface.bulkInsert('libraryItems', [
|
||||||
|
{ id: 1, ino: file1stats.ino, mediaId: 1, path: file1stats.path, mediaType: 'book', libraryId: 1 },
|
||||||
|
{ id: 2, ino: file2stats.ino, mediaId: 2, path: file2stats.path, mediaType: 'book', libraryId: 1 }
|
||||||
|
])
|
||||||
|
|
||||||
|
await queryInterface.bulkInsert('authors', [
|
||||||
|
{ id: 1, name: 'John Doe', lastFirst: 'Doe, John' },
|
||||||
|
{ id: 2, name: 'Jane Smith', lastFirst: 'Smith, Jane' },
|
||||||
|
{ id: 3, name: 'John Smith', lastFirst: 'Smith, John' }
|
||||||
|
])
|
||||||
|
|
||||||
|
await queryInterface.bulkInsert('bookAuthors', [
|
||||||
|
{ id: 1, bookId: 1, authorId: 1, createdAt: '2025-01-01 00:00:00.000 +00:00' },
|
||||||
|
{ id: 2, bookId: 2, authorId: 2, createdAt: '2025-01-02 00:00:00.000 +00:00' },
|
||||||
|
{ id: 3, bookId: 1, authorId: 3, createdAt: '2024-12-31 00:00:00.000 +00:00' }
|
||||||
|
])
|
||||||
|
|
||||||
|
await queryInterface.bulkInsert('podcastEpisodes', [
|
||||||
|
{ id: 1, publishedAt: '2025-01-01 00:00:00.000 +00:00' },
|
||||||
|
{ id: 2, publishedAt: '2025-01-02 00:00:00.000 +00:00' },
|
||||||
|
{ id: 3, publishedAt: '2025-01-03 00:00:00.000 +00:00' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('up', () => {
|
||||||
|
it('should add the deviceId column to the libraryItems table', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const libraryItems = await queryInterface.describeTable('libraryItems')
|
||||||
|
expect(libraryItems.deviceId).to.exist
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should populate the deviceId columns from the filesystem for each libraryItem', async function () {
|
||||||
|
this.timeout(0)
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems')
|
||||||
|
expect(libraryItems).to.deep.equal([
|
||||||
|
{ id: 1, ino: file1stats.ino, deviceId: file1stats.dev, mediaId: 1, path: file1stats.path, mediaType: 'book', libraryId: 1 },
|
||||||
|
{ id: 2, ino: file2stats.ino, deviceId: file2stats.dev, mediaId: 2, path: file2stats.path, mediaType: 'book', libraryId: 1 }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add an index on ino and deviceId to the libraryItems table', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const indexes = await queryInterface.sequelize.query(`SELECT * FROM sqlite_master WHERE type='index'`)
|
||||||
|
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_ino_device_id'`)
|
||||||
|
expect(count).to.equal(1)
|
||||||
|
|
||||||
|
const [[{ sql }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='index' AND name='library_items_ino_device_id'`)
|
||||||
|
expect(normalizeWhitespaceAndBackticks(sql)).to.equal(
|
||||||
|
normalizeWhitespaceAndBackticks(`
|
||||||
|
CREATE INDEX library_items_ino_device_id ON libraryItems (ino, deviceId)
|
||||||
|
`)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be idempotent', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const libraryItemsTable = await queryInterface.describeTable('libraryItems')
|
||||||
|
expect(libraryItemsTable.deviceId).to.exist
|
||||||
|
|
||||||
|
const [[{ count: count6 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_ino_device_id'`)
|
||||||
|
expect(count6).to.equal(1)
|
||||||
|
|
||||||
|
const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`)
|
||||||
|
expect(libraryItems).to.deep.equal([
|
||||||
|
{ id: 1, ino: file1stats.ino, deviceId: file1stats.dev, path: file1stats.path, mediaId: 1, mediaType: 'book', libraryId: 1 },
|
||||||
|
{ id: 2, ino: file2stats.ino, deviceId: file2stats.dev, path: file2stats.path, mediaId: 2, mediaType: 'book', libraryId: 1 }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('down', () => {
|
||||||
|
it('should remove the deviceId from the libraryItems table', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
await down({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const libraryItemsTable = await queryInterface.describeTable('libraryItems')
|
||||||
|
expect(libraryItemsTable.deviceId).to.not.exist
|
||||||
|
|
||||||
|
const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`)
|
||||||
|
expect(libraryItems).to.deep.equal([
|
||||||
|
{ id: 1, ino: file1stats.ino, mediaId: 1, path: file1stats.path, mediaType: 'book', libraryId: 1 },
|
||||||
|
{ id: 2, ino: file2stats.ino, mediaId: 2, path: file2stats.path, mediaType: 'book', libraryId: 1 }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove the index on ino, deviceId from the libraryItems table', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
await down({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_ino_device_id'`)
|
||||||
|
expect(count).to.equal(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be idempotent', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
await down({ context: { queryInterface, logger: Logger } })
|
||||||
|
await down({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const libraryItemsTable = await queryInterface.describeTable('libraryItems')
|
||||||
|
expect(libraryItemsTable.libraryItems).to.not.exist
|
||||||
|
|
||||||
|
const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`)
|
||||||
|
expect(libraryItems).to.deep.equal([
|
||||||
|
{ id: 1, ino: file1stats.ino, path: file1stats.path, mediaId: 1, mediaType: 'book', libraryId: 1 },
|
||||||
|
{ id: 2, ino: file2stats.ino, path: file2stats.path, mediaId: 2, mediaType: 'book', libraryId: 1 }
|
||||||
|
])
|
||||||
|
|
||||||
|
const [[{ count: count6 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_ino_device_id'`)
|
||||||
|
expect(count6).to.equal(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
183
test/server/objects/LibraryItemScanData.test.js
Normal file
183
test/server/objects/LibraryItemScanData.test.js
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
const chai = require('chai')
|
||||||
|
const expect = chai.expect
|
||||||
|
const Path = require('path')
|
||||||
|
|
||||||
|
const { buildFileProperties, buildLibraryFileProperties } = require('../MockDatabase')
|
||||||
|
|
||||||
|
const LibraryItemScanData = require('../../../server/scanner/LibraryItemScanData')
|
||||||
|
const LibraryFile = require('../../../server/objects/files/LibraryFile')
|
||||||
|
const LibraryScan = require('../../../server/scanner/LibraryScan')
|
||||||
|
const ScanLogger = require('../../../server/scanner/ScanLogger')
|
||||||
|
describe('LibraryItemScanData', () => {
|
||||||
|
// compareUpdateLibraryFile - returns false if no changes; true if changes
|
||||||
|
describe('compareUpdateLibraryFileWithDeviceId', () => {
|
||||||
|
it('fileChangeDetectedWhenInodeAndDeviceIdPairDiffers', () => {
|
||||||
|
const existing_lf = buildLibraryFileProperties('/tmp/file.pdf', '4432', '300')
|
||||||
|
const scanned_lf = new LibraryFile({
|
||||||
|
ino: '1',
|
||||||
|
deviceId: '100'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(existing_lf.ino).to.not.equal(scanned_lf.ino)
|
||||||
|
expect(existing_lf.deviceId).to.not.equal(scanned_lf.deviceId)
|
||||||
|
const changeDetected = LibraryItemScanData.compareUpdateLibraryFile('/file/path.pdf', existing_lf, scanned_lf, new LibraryScan())
|
||||||
|
expect(changeDetected).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fileChangeNotDetectedWhenInodeSameButDeviceIdDiffers', () => {
|
||||||
|
// Same inode on different deviceId does NOT mean these are the same file
|
||||||
|
const existing_lf = buildLibraryFileProperties('/tmp/file.pdf', '4432', '300')
|
||||||
|
const scanned_lf = new LibraryFile(buildLibraryFileProperties('/tmp/file.pdf', '4432', '100'))
|
||||||
|
|
||||||
|
expect(existing_lf.ino).to.equal(scanned_lf.ino)
|
||||||
|
expect(existing_lf.deviceId).to.not.equal(scanned_lf.deviceId)
|
||||||
|
const changeDetected = LibraryItemScanData.compareUpdateLibraryFile('/file/path.pdf', existing_lf, scanned_lf, new LibraryScan())
|
||||||
|
expect(changeDetected).to.be.false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('findMatchingLibraryFileByPathOrInodeAndDeviceId', () => {
|
||||||
|
it('isMatchWhenInodeAndDeviceIdPairIsSame', () => {
|
||||||
|
const lisd = new LibraryItemScanData(buildFileProperties('/library/book/file.epub', '1', '1000', [new LibraryFile(buildLibraryFileProperties('/library/book/file.epub', '1', '1000'))]))
|
||||||
|
|
||||||
|
const scanned_lf_properties = buildLibraryFileProperties('/tmp/file.epub', '1', '1000')
|
||||||
|
|
||||||
|
const matchingFile = lisd.findMatchingLibraryFileByPathOrInodeAndDeviceId(scanned_lf_properties, new ScanLogger())
|
||||||
|
|
||||||
|
// don't want match based on filename
|
||||||
|
expect(lisd.path).to.not.equal(scanned_lf_properties.metadata.path)
|
||||||
|
expect(matchingFile).to.not.be.undefined
|
||||||
|
expect(matchingFile?.ino).to.equal(lisd.ino)
|
||||||
|
expect(matchingFile?.deviceId).to.equal(lisd.deviceId)
|
||||||
|
})
|
||||||
|
it('isNotMatchWhenInodeSameButDeviceIdDiffers', () => {
|
||||||
|
const lisd = new LibraryItemScanData(buildFileProperties('/library/book/file.epub', '1', '1000', [new LibraryFile(buildLibraryFileProperties('/library/book/file.epub', '1', '1000'))]))
|
||||||
|
|
||||||
|
const scanned_lf_properties = buildLibraryFileProperties('/tmp/file.epub', '1', '500')
|
||||||
|
|
||||||
|
// don't want match based on filename
|
||||||
|
expect(lisd.path).to.not.equal(scanned_lf_properties.metadata.path)
|
||||||
|
expect(lisd.deviceId).to.not.equal(scanned_lf_properties.ino)
|
||||||
|
|
||||||
|
const matchingFile = lisd.findMatchingLibraryFileByPathOrInodeAndDeviceId(scanned_lf_properties, new ScanLogger())
|
||||||
|
|
||||||
|
expect(matchingFile).to.be.undefined
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('checkAudioFileRemoved', function () {
|
||||||
|
this.timeout(0)
|
||||||
|
it('doesNotDetectFileRemovedWhenInodeIsSameButDeviceIdDiffers', () => {
|
||||||
|
const lisd = new LibraryItemScanData(buildFileProperties('/library/book/file.mp3', '1', '1000'))
|
||||||
|
lisd.libraryFilesRemoved.push(buildLibraryFileProperties('/library/book/file.mp3', '1', '1000'))
|
||||||
|
const af_obj = buildAudioFileObject('/library/someotherbook/chapter1.mp3', '1', '200')
|
||||||
|
|
||||||
|
const fileRemoved = lisd.checkAudioFileRemoved(af_obj)
|
||||||
|
|
||||||
|
expect(fileRemoved).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detectsFileRemovedWhenNameDoesNotMatchButInodeAndDeviceIdMatch', () => {
|
||||||
|
const lisd = new LibraryItemScanData(buildFileProperties('/library/book/file.mp3', '1', '1000'))
|
||||||
|
lisd.libraryFilesRemoved.push(buildLibraryFileProperties('/library/book/file.mp3', '1', '1000'))
|
||||||
|
const af_obj = buildAudioFileObject('/library/someotherbook/chapter1.mp3', '1', '1000')
|
||||||
|
|
||||||
|
expect(lisd.path).to.not.equal(af_obj.metadata.path)
|
||||||
|
const fileRemoved = lisd.checkAudioFileRemoved(af_obj)
|
||||||
|
|
||||||
|
expect(fileRemoved).to.be.true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// checkEbookFileRemoved
|
||||||
|
describe('checkEbookFileRemoved', () => {
|
||||||
|
it('doesNotDetectFileRemovedWhenInodeIsSameButDeviceIdDiffers', () => {
|
||||||
|
const lisd = new LibraryItemScanData(buildFileProperties('/library/book/file.epub', '1', '1000', [new LibraryFile(buildLibraryFileProperties('/library/book/file.epub', '1', '1000'))]))
|
||||||
|
lisd.libraryFilesRemoved.push(buildLibraryFileProperties('/library/book/file.epub', '1', '1000')) // This is the file that was removed
|
||||||
|
const ebook_obj = buildEbookFileObject('/library/someotherbook/chapter1.epub', '1', '200') // this file was NOT removed
|
||||||
|
|
||||||
|
expect(lisd.path).to.not.equal(ebook_obj.metadata.path)
|
||||||
|
const fileRemoved = lisd.checkEbookFileRemoved(ebook_obj)
|
||||||
|
|
||||||
|
expect(fileRemoved).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detectsFileRemovedWhenInodeAndDeviceIdIsSame', () => {
|
||||||
|
const lisd = new LibraryItemScanData(buildFileProperties('/library/book/file.epub', '1', '1000', [new LibraryFile(buildLibraryFileProperties('/library/book/file.epub', '1', '1000'))]))
|
||||||
|
lisd.libraryFilesRemoved.push(buildLibraryFileProperties('/library/book/file.epub', '1', '1000')) // This is the file that was removed
|
||||||
|
const ebook_obj = buildEbookFileObject('/library/someotherbook/chapter1.epub', '1', '1000') // this file was removed
|
||||||
|
|
||||||
|
expect(lisd.path).to.not.equal(ebook_obj.metadata.path)
|
||||||
|
const fileRemoved = lisd.checkEbookFileRemoved(ebook_obj)
|
||||||
|
|
||||||
|
expect(fileRemoved).to.be.true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// libraryItemObject()
|
||||||
|
describe('libraryItemObject', () => {
|
||||||
|
it('setsDeviceIdOnLibraryObject', () => {
|
||||||
|
const lisd = new LibraryItemScanData(buildFileProperties('/library/book/file.epub', '1', '1000', [new LibraryFile(buildLibraryFileProperties('/library/book/file.epub', '1', '1000'))]))
|
||||||
|
expect(lisd.libraryItemObject.ino).to.equal(lisd.ino)
|
||||||
|
expect(lisd.libraryItemObject.deviceId).to.equal(lisd.deviceId)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @returns {import('../../../server/models/Book').AudioFileObject} */
|
||||||
|
function buildAudioFileObject(path = '/library/somebook/file.mp3', ino = '1', deviceId = '1000') {
|
||||||
|
return {
|
||||||
|
index: 0,
|
||||||
|
ino: ino,
|
||||||
|
deviceId: deviceId,
|
||||||
|
metadata: {
|
||||||
|
filename: Path.basename(path),
|
||||||
|
ext: Path.extname(path),
|
||||||
|
path: path,
|
||||||
|
relPath: path,
|
||||||
|
size: 0,
|
||||||
|
mtimeMs: 0,
|
||||||
|
ctimeMs: 0,
|
||||||
|
birthtimeMs: 0
|
||||||
|
},
|
||||||
|
addedAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
trackNumFromMeta: 0,
|
||||||
|
discNumFromMeta: 0,
|
||||||
|
trackNumFromFilename: 0,
|
||||||
|
discNumFromFilename: 0,
|
||||||
|
manuallyVerified: false,
|
||||||
|
format: '',
|
||||||
|
duration: 0,
|
||||||
|
bitRate: 0,
|
||||||
|
language: '',
|
||||||
|
codec: '',
|
||||||
|
timeBase: '',
|
||||||
|
channels: 0,
|
||||||
|
channelLayout: '',
|
||||||
|
chapters: [],
|
||||||
|
metaTags: undefined,
|
||||||
|
mimeType: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {import('../../../server/models/Book').EBookFileObject} */
|
||||||
|
function buildEbookFileObject(path = '/library/somebook/file.epub', ino = '100', deviceId = '1000') {
|
||||||
|
return {
|
||||||
|
ino: ino,
|
||||||
|
deviceId: deviceId,
|
||||||
|
ebookFormat: Path.extname(path),
|
||||||
|
addedAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
metadata: {
|
||||||
|
filename: Path.basename(path),
|
||||||
|
ext: Path.extname(path),
|
||||||
|
path: path,
|
||||||
|
relPath: path,
|
||||||
|
size: 0,
|
||||||
|
mtimeMs: 0,
|
||||||
|
ctimeMs: 0,
|
||||||
|
birthtimeMs: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
97
test/server/objects/SimilarLibraryFileObjects.test.js
Normal file
97
test/server/objects/SimilarLibraryFileObjects.test.js
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
const chai = require('chai')
|
||||||
|
const expect = chai.expect
|
||||||
|
const sinon = require('sinon')
|
||||||
|
|
||||||
|
const Path = require('path')
|
||||||
|
const Database = require('../../../server/Database')
|
||||||
|
const { loadTestDatabase, stubFileUtils, getMockFileInfo, buildFileProperties } = require('../MockDatabase')
|
||||||
|
|
||||||
|
// TODO: all of these classes duplicate each other.
|
||||||
|
const LibraryFile = require('../../../server/objects/files/LibraryFile')
|
||||||
|
const EBookFile = require('../../../server/objects/files/EBookFile')
|
||||||
|
const AudioFile = require('../../../server/objects/files/AudioFile')
|
||||||
|
const LibraryItemScanData = require('../../../server/scanner/LibraryItemScanData')
|
||||||
|
|
||||||
|
const fileProperties = buildFileProperties()
|
||||||
|
const lf = new LibraryFile(fileProperties)
|
||||||
|
const ebf = new EBookFile(fileProperties)
|
||||||
|
const af = new AudioFile(fileProperties)
|
||||||
|
|
||||||
|
describe('SimilarLibraryFileObjects', () => {
|
||||||
|
describe('ObjectSetsDeviceIdWhenConstructed', function () {
|
||||||
|
this.timeout(0)
|
||||||
|
beforeEach(async () => {
|
||||||
|
stubFileUtils()
|
||||||
|
await loadTestDatabase()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
const lisd = new LibraryItemScanData(fileProperties)
|
||||||
|
|
||||||
|
const objects = [lf, ebf, af, lisd]
|
||||||
|
|
||||||
|
objects.forEach((obj) => {
|
||||||
|
it(`${obj.constructor.name}SetsDeviceIdWhenConstructed`, () => {
|
||||||
|
expect(obj.ino).to.equal(fileProperties.ino)
|
||||||
|
expect(obj.deviceId).to.equal(fileProperties.deviceId)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('LibraryItemSetsDeviceIdWhenConstructed', async () => {
|
||||||
|
const mockFileInfo = getMockFileInfo().get('/test/file.pdf')
|
||||||
|
|
||||||
|
/** @type {import('../../../server/models/LibraryItem') | null} */
|
||||||
|
const li = await Database.libraryItemModel.findOneExpanded({
|
||||||
|
path: '/test/file.pdf'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(li?.ino).to.equal(mockFileInfo?.ino)
|
||||||
|
expect(li?.deviceId).to.equal(mockFileInfo?.dev)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('LibraryFileJSONHasDeviceId', async () => {
|
||||||
|
const mockFileInfo = getMockFileInfo().get('/test/file.pdf')
|
||||||
|
|
||||||
|
/** @type {import('../../../server/models/LibraryItem') | null} */
|
||||||
|
const li = await Database.libraryItemModel.findOneExpanded({
|
||||||
|
path: '/test/file.pdf'
|
||||||
|
})
|
||||||
|
|
||||||
|
const lf_json = li?.libraryFiles[0]
|
||||||
|
expect(lf_json).to.not.be.null
|
||||||
|
expect(lf_json?.deviceId).to.equal(mockFileInfo?.dev)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ObjectSetsDeviceIdWhenSerialized', () => {
|
||||||
|
const objects = [lf, ebf, af]
|
||||||
|
objects.forEach((obj) => {
|
||||||
|
it(`${obj.constructor.name}SetsDeviceIdWhenSerialized`, () => {
|
||||||
|
const obj_json = obj.toJSON()
|
||||||
|
expect(obj_json.ino).to.equal(fileProperties.ino)
|
||||||
|
expect(obj_json.deviceId).to.equal(fileProperties.deviceId)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function buildLibraryItemProperties(fileProperties) {
|
||||||
|
return {
|
||||||
|
id: '7792E90F-D526-4636-8A38-EA8342E71FEE',
|
||||||
|
path: fileProperties.path,
|
||||||
|
relPath: fileProperties.path,
|
||||||
|
isFile: true,
|
||||||
|
ino: fileProperties.ino,
|
||||||
|
deviceId: fileProperties.dev,
|
||||||
|
libraryFiles: [],
|
||||||
|
mediaId: '7195803A-9974-46E4-A7D1-7A6E1AD7FD4B',
|
||||||
|
mediaType: 'book',
|
||||||
|
libraryId: '907DA361-67E4-47CF-9C67-C8E2E5CA1B15',
|
||||||
|
libraryFolderId: 'E2216F60-8ABF-4E55-BA83-AD077EB907F3',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
}
|
72
test/server/scanner/LibraryItemScanner.test.js
Normal file
72
test/server/scanner/LibraryItemScanner.test.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
const chai = require('chai')
|
||||||
|
const expect = chai.expect
|
||||||
|
const sinon = require('sinon')
|
||||||
|
const rewire = require('rewire')
|
||||||
|
const Path = require('path')
|
||||||
|
|
||||||
|
const { stubFileUtils, getMockFileInfo, loadTestDatabase } = require('../MockDatabase')
|
||||||
|
|
||||||
|
const LibraryFile = require('../../../server/objects/files/LibraryFile')
|
||||||
|
const FileMetadata = require('../../../server/objects/metadata/FileMetadata')
|
||||||
|
const LibraryFolder = require('../../../server/models/LibraryFolder')
|
||||||
|
|
||||||
|
describe('LibraryItemScanner', () => {
|
||||||
|
describe('buildLibraryItemScanData', () => {
|
||||||
|
let testLibrary = null
|
||||||
|
beforeEach(async () => {
|
||||||
|
stubFileUtils()
|
||||||
|
testLibrary = await loadTestDatabase()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setsDeviceId', async () => {
|
||||||
|
const libraryItemScanner = rewire('../../../server/scanner/LibraryItemScanner')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ path?: any; relPath?: any; mediaMetadata?: any; }} libraryItemData
|
||||||
|
* @param {import("../../../server/models/LibraryFolder")} folder
|
||||||
|
* @param {import("../../../server/models/Library")} library
|
||||||
|
* @param {boolean} isSingleMediaItem
|
||||||
|
* @param {LibraryFile[]} libraryFiles
|
||||||
|
* @return {import('../../../server/scanner/LibraryItemScanData') | null}
|
||||||
|
* */
|
||||||
|
const buildLibraryItemScanData = libraryItemScanner.__get__('buildLibraryItemScanData')
|
||||||
|
|
||||||
|
const mockFileInfo = getMockFileInfo().get('/test/file.pdf')
|
||||||
|
const lf = new LibraryFile()
|
||||||
|
var fileMetadata = new FileMetadata()
|
||||||
|
fileMetadata.setData(mockFileInfo)
|
||||||
|
fileMetadata.filename = Path.basename(mockFileInfo?.path)
|
||||||
|
fileMetadata.path = mockFileInfo?.path
|
||||||
|
fileMetadata.relPath = mockFileInfo?.path
|
||||||
|
fileMetadata.ext = Path.extname(mockFileInfo?.path)
|
||||||
|
lf.ino = mockFileInfo?.ino
|
||||||
|
lf.deviceId = mockFileInfo?.dev
|
||||||
|
lf.metadata = fileMetadata
|
||||||
|
lf.addedAt = Date.now()
|
||||||
|
lf.updatedAt = Date.now()
|
||||||
|
lf.metadata = fileMetadata
|
||||||
|
|
||||||
|
const libraryItemData = {
|
||||||
|
path: mockFileInfo?.path, // full path
|
||||||
|
relPath: mockFileInfo?.path, // only filename
|
||||||
|
mediaMetadata: {
|
||||||
|
title: Path.basename(mockFileInfo?.path, Path.extname(mockFileInfo?.path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scanData = await buildLibraryItemScanData(libraryItemData, buildLibraryFolder(), testLibrary, true, [lf.toJSON()])
|
||||||
|
|
||||||
|
expect(scanData).to.not.be.null
|
||||||
|
expect(scanData.deviceId).to.equal(mockFileInfo?.dev)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @return {import("../../../server/models/LibraryFolder")} folder */
|
||||||
|
function buildLibraryFolder() {
|
||||||
|
return new LibraryFolder()
|
||||||
|
}
|
328
test/server/scanner/LibraryScanner.test.js
Normal file
328
test/server/scanner/LibraryScanner.test.js
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
const chai = require('chai')
|
||||||
|
const expect = chai.expect
|
||||||
|
const sinon = require('sinon')
|
||||||
|
const rewire = require('rewire')
|
||||||
|
const fileUtils = require('../../../server/utils/fileUtils')
|
||||||
|
const LibraryFile = require('../../../server/objects/files/LibraryFile')
|
||||||
|
const LibraryItem = require('../../../server/models/LibraryItem')
|
||||||
|
const FileMetadata = require('../../../server/objects/metadata/FileMetadata')
|
||||||
|
const Path = require('path')
|
||||||
|
const Database = require('../../../server/Database')
|
||||||
|
const { stubFileUtils, loadTestDatabase, getMockFileInfo, getRenamedMockFileInfo, buildBookLibraryItemParams, buildFileProperties, buildLibraryFileProperties } = require('../MockDatabase')
|
||||||
|
const libraryScannerInstance = require('../../../server/scanner/LibraryScanner')
|
||||||
|
const LibraryScan = require('../../../server/scanner/LibraryScan')
|
||||||
|
|
||||||
|
describe('LibraryScanner', () => {
|
||||||
|
let LibraryScanner, testLibrary
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
stubFileUtils()
|
||||||
|
|
||||||
|
LibraryScanner = rewire('../../../server/scanner/LibraryScanner')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findsByInodeAndDeviceId', async function () {
|
||||||
|
// this.timeout(50000) // Note: don't use arrow function or timeout for debugging doesn't work
|
||||||
|
let findLibraryItemByItemToFileInoMatch = LibraryScanner.__get__('findLibraryItemByItemToFileInoMatch')
|
||||||
|
let fullPath = '/test/file.pdf'
|
||||||
|
|
||||||
|
let mockFileInfo = getMockFileInfo()
|
||||||
|
testLibrary = await loadTestDatabase(mockFileInfo)
|
||||||
|
|
||||||
|
const fileInfo = mockFileInfo.get(fullPath)
|
||||||
|
|
||||||
|
/** @type {Promise<import('../../../server/models/LibraryItem') | null>} */
|
||||||
|
const result = await findLibraryItemByItemToFileInoMatch(testLibrary.id, fullPath, true)
|
||||||
|
expect(result).to.not.be.null
|
||||||
|
expect(result.libraryFiles[0].metadata.path).to.equal(fullPath)
|
||||||
|
expect(result.libraryFiles[0].deviceId).to.equal(fileInfo.dev)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findsTheCorrectItemByInodeAndDeviceIdWhenThereAreDuplicateInodes', async () => {
|
||||||
|
let findLibraryItemByItemToFileInoMatch = LibraryScanner.__get__('findLibraryItemByItemToFileInoMatch')
|
||||||
|
let fullPath = '/mnt/drive/file-same-ino-different-dev.pdf'
|
||||||
|
|
||||||
|
let mockFileInfo = getMockFileInfo()
|
||||||
|
testLibrary = await loadTestDatabase(mockFileInfo)
|
||||||
|
|
||||||
|
const fileInfo = mockFileInfo.get(fullPath)
|
||||||
|
|
||||||
|
/** @type {Promise<import('../../../server/models/LibraryItem') | null>} */
|
||||||
|
const result = await findLibraryItemByItemToFileInoMatch(testLibrary.id, fullPath, true)
|
||||||
|
expect(result).to.not.be.null
|
||||||
|
expect(result.libraryFiles[0].metadata.path).to.equal(fullPath)
|
||||||
|
expect(result.libraryFiles[0].deviceId).to.equal(fileInfo.dev)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findLibraryItemByItemToItemInoMatch', async function () {
|
||||||
|
this.timeout(0)
|
||||||
|
// findLibraryItemByItemToItemInoMatch(libraryId, fullPath)
|
||||||
|
let findLibraryItemByItemToItemInoMatch = LibraryScanner.__get__('findLibraryItemByItemToItemInoMatch')
|
||||||
|
|
||||||
|
let fullPath = '/test/file.pdf'
|
||||||
|
|
||||||
|
let mockFileInfo = getMockFileInfo()
|
||||||
|
testLibrary = await loadTestDatabase(mockFileInfo)
|
||||||
|
|
||||||
|
const fileInfo = mockFileInfo.get(fullPath)
|
||||||
|
|
||||||
|
/** @returns {Promise<import('../../../server/models/LibraryItem') | null>} */
|
||||||
|
const result = await findLibraryItemByItemToItemInoMatch(testLibrary.id, fullPath)
|
||||||
|
expect(result).to.not.be.null
|
||||||
|
expect(result.libraryFiles[0].metadata.path).to.equal(fullPath)
|
||||||
|
expect(result.libraryFiles[0].deviceId).to.equal(fileInfo.dev)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findLibraryItemByFileToItemInoMatch-matchesRenamedFileByInoAndDeviceId', async function () {
|
||||||
|
this.timeout(0)
|
||||||
|
let mockFileInfo = getMockBookFileInfo()
|
||||||
|
sinon.restore()
|
||||||
|
stubFileUtils(mockFileInfo)
|
||||||
|
testLibrary = await loadTestDatabase(mockFileInfo)
|
||||||
|
|
||||||
|
// findLibraryItemByFileToItemInoMatch(libraryId, fullPath, isSingleMedia, itemFiles)
|
||||||
|
let findLibraryItemByItemToItemInoMatch = LibraryScanner.__get__('findLibraryItemByFileToItemInoMatch')
|
||||||
|
|
||||||
|
let bookFolderPath = '/test/bookfolder'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {UUIDV4} libraryId
|
||||||
|
* @param {string} fullPath
|
||||||
|
* @param {boolean} isSingleMedia
|
||||||
|
* @param {string[]} itemFiles
|
||||||
|
* @returns {Promise<import('../models/LibraryItem').LibraryItemExpanded | null>} library item that matches
|
||||||
|
*/
|
||||||
|
const existingItem = await findLibraryItemByItemToItemInoMatch(testLibrary.id, bookFolderPath, false, ['file.epub', 'file-renamed.epub', 'file.opf'])
|
||||||
|
|
||||||
|
expect(existingItem).to.not.be.null
|
||||||
|
expect(existingItem.ino).to.equal('1')
|
||||||
|
expect(existingItem.deviceId).to.equal('100')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findLibraryItemByFileToItemInoMatch-DoesNotMatchByInoAndDifferentDeviceId', async function () {
|
||||||
|
this.timeout(0)
|
||||||
|
testLibrary = await loadTestDatabase()
|
||||||
|
|
||||||
|
// findLibraryItemByFileToItemInoMatch(libraryId, fullPath, isSingleMedia, itemFiles)
|
||||||
|
let findLibraryItemByItemToItemInoMatch = LibraryScanner.__get__('findLibraryItemByFileToItemInoMatch')
|
||||||
|
|
||||||
|
let bookFolderPath = '/test/bookfolder'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {UUIDV4} libraryId
|
||||||
|
* @param {string} fullPath
|
||||||
|
* @param {boolean} isSingleMedia
|
||||||
|
* @param {string[]} itemFiles
|
||||||
|
* @returns {Promise<import('../models/LibraryItem').LibraryItemExpanded | null>} library item that matches
|
||||||
|
*/
|
||||||
|
const existingItem = await findLibraryItemByItemToItemInoMatch(testLibrary.id, bookFolderPath, false, ['file.epub', 'different-file.epub', 'file.opf'])
|
||||||
|
|
||||||
|
expect(existingItem).to.be.null
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @returns {Map<string, import('fs').Stats>} */
|
||||||
|
function getMockBookFileInfo() {
|
||||||
|
// @ts-ignore
|
||||||
|
return new Map([
|
||||||
|
['/test/bookfolder/file-renamed.epub', { path: '/test/bookfolder/file-renamed.epub', isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '100' }],
|
||||||
|
['/test/bookfolder/file.epub', { path: '/test/bookfolder/file.epub', isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '100' }],
|
||||||
|
['/test/bookfolder/different-file.epub', { path: '/test/bookfolder/different-file.epub', isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '200' }],
|
||||||
|
['/test/bookfolder/file.opf', { path: '/test/bookfolder/file.opf', isDirectory: () => false, size: 42, mtimeMs: Date.now(), ino: '2', dev: '100' }]
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItemToFileInoMatch
|
||||||
|
it('ItemToFileInoMatch-ItemMatchesSelf', async function () {
|
||||||
|
this.timeout(0)
|
||||||
|
/**
|
||||||
|
* @param {import("../../../server/models/LibraryItem") | import("../../../server/scanner/LibraryItemScanData")} libraryItem1
|
||||||
|
* @param {import("../../../server/models/LibraryItem") | import("../../../server/scanner/LibraryItemScanData")} libraryItem2
|
||||||
|
*/
|
||||||
|
let ItemToFileInoMatch = LibraryScanner.__get__('ItemToFileInoMatch')
|
||||||
|
|
||||||
|
// this compares the inode from the first library item to the second library item's library file inode
|
||||||
|
let mockFileInfo = getMockFileInfo()
|
||||||
|
testLibrary = await loadTestDatabase(mockFileInfo)
|
||||||
|
|
||||||
|
const fileInfo = mockFileInfo.get('/test/file.pdf')
|
||||||
|
|
||||||
|
let item1 = await Database.libraryItemModel.findOneExpanded({
|
||||||
|
libraryId: testLibrary.id,
|
||||||
|
// @ts-ignore
|
||||||
|
ino: fileInfo.ino
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(ItemToFileInoMatch(item1, item1)).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ItemToFileInoMatch-TwoItemsWithSameInoButDifferentDeviceShouldNotMatch', async function () {
|
||||||
|
this.timeout(0)
|
||||||
|
/**
|
||||||
|
* @param {import("../../../server/models/LibraryItem") | import("../../../server/scanner/LibraryItemScanData")} libraryItem1
|
||||||
|
* @param {import("../../../server/models/LibraryItem") | import("../../../server/scanner/LibraryItemScanData")} libraryItem2
|
||||||
|
*/
|
||||||
|
let ItemToFileInoMatch = LibraryScanner.__get__('ItemToFileInoMatch')
|
||||||
|
|
||||||
|
let mockFileInfo = getMockFileInfo()
|
||||||
|
testLibrary = await loadTestDatabase(mockFileInfo)
|
||||||
|
|
||||||
|
// this compares the inode from the first library item to the second library item's library file inode
|
||||||
|
const item1 = await Database.libraryItemModel.findOneExpanded({
|
||||||
|
libraryId: testLibrary.id,
|
||||||
|
path: '/test/file.pdf'
|
||||||
|
})
|
||||||
|
|
||||||
|
const item2 = await Database.libraryItemModel.findOneExpanded({
|
||||||
|
libraryId: testLibrary.id,
|
||||||
|
path: '/mnt/drive/file-same-ino-different-dev.pdf'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(item1.path).to.not.equal(item2.path)
|
||||||
|
|
||||||
|
expect(ItemToFileInoMatch(item1, item2)).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ItemToFileInoMatch-RenamedFileShouldMatch', async function () {
|
||||||
|
this.timeout(0)
|
||||||
|
/**
|
||||||
|
* @param {import("../../../server/models/LibraryItem") | import("../../../server/scanner/LibraryItemScanData")} libraryItem1
|
||||||
|
* @param {import("../../../server/models/LibraryItem") | import("../../../server/scanner/LibraryItemScanData")} libraryItem2
|
||||||
|
*/
|
||||||
|
let ItemToFileInoMatch = LibraryScanner.__get__('ItemToFileInoMatch')
|
||||||
|
|
||||||
|
let mockFileInfo = getMockFileInfo()
|
||||||
|
testLibrary = await loadTestDatabase(mockFileInfo)
|
||||||
|
|
||||||
|
// this compares the inode from the first library item to the second library item's library file inode
|
||||||
|
const original = await Database.libraryItemModel.findOneExpanded({
|
||||||
|
libraryId: testLibrary.id,
|
||||||
|
path: '/test/file.pdf'
|
||||||
|
})
|
||||||
|
|
||||||
|
const renamedMockFileInfo = getRenamedMockFileInfo().get('/test/file-renamed.pdf')
|
||||||
|
const renamedFile = new LibraryFile()
|
||||||
|
var fileMetadata = new FileMetadata()
|
||||||
|
fileMetadata.setData(renamedMockFileInfo)
|
||||||
|
fileMetadata.filename = Path.basename(renamedMockFileInfo.path)
|
||||||
|
fileMetadata.path = fileUtils.filePathToPOSIX(renamedMockFileInfo.path)
|
||||||
|
fileMetadata.relPath = fileUtils.filePathToPOSIX(renamedMockFileInfo.path)
|
||||||
|
fileMetadata.ext = Path.extname(renamedMockFileInfo.path)
|
||||||
|
renamedFile.ino = renamedMockFileInfo.ino
|
||||||
|
renamedFile.deviceId = renamedMockFileInfo.dev
|
||||||
|
renamedFile.metadata = fileMetadata
|
||||||
|
renamedFile.addedAt = Date.now()
|
||||||
|
renamedFile.updatedAt = Date.now()
|
||||||
|
renamedFile.metadata = fileMetadata
|
||||||
|
|
||||||
|
const renamedItem = new LibraryItem(buildBookLibraryItemParams(renamedFile, null, testLibrary.id, null))
|
||||||
|
|
||||||
|
expect(ItemToFileInoMatch(original, renamedItem)).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
// ItemToItemInoMatch
|
||||||
|
it('ItemToItemInoMatch-ItemMatchesSelf', async function () {
|
||||||
|
this.timeout(0)
|
||||||
|
/**
|
||||||
|
* @param {import("../../../server/models/LibraryItem") | import("../../../server/scanner/LibraryItemScanData")} libraryItem1
|
||||||
|
* @param {import("../../../server/models/LibraryItem") | import("../../../server/scanner/LibraryItemScanData")} libraryItem2
|
||||||
|
*/
|
||||||
|
let ItemToItemInoMatch = LibraryScanner.__get__('ItemToItemInoMatch')
|
||||||
|
|
||||||
|
// this compares the inode from the first library item to the second library item's library file inode
|
||||||
|
let mockFileInfo = getMockFileInfo()
|
||||||
|
testLibrary = await loadTestDatabase(mockFileInfo)
|
||||||
|
|
||||||
|
const fileInfo = mockFileInfo.get('/test/file.pdf')
|
||||||
|
|
||||||
|
let item1 = await Database.libraryItemModel.findOneExpanded({
|
||||||
|
libraryId: testLibrary.id,
|
||||||
|
// @ts-ignore
|
||||||
|
ino: fileInfo.ino
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(ItemToItemInoMatch(item1, item1)).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ItemToItemInoMatch-TwoItemsWithSameInoButDifferentDeviceShouldNotMatch', async () => {
|
||||||
|
let ItemToItemInoMatch = LibraryScanner.__get__('ItemToItemInoMatch')
|
||||||
|
|
||||||
|
let mockFileInfo = getMockFileInfo()
|
||||||
|
testLibrary = await loadTestDatabase(mockFileInfo)
|
||||||
|
|
||||||
|
// this compares the inode from the first library item to the second library item's library file inode
|
||||||
|
const item1 = await Database.libraryItemModel.findOneExpanded({
|
||||||
|
libraryId: testLibrary.id,
|
||||||
|
path: '/test/file.pdf'
|
||||||
|
})
|
||||||
|
|
||||||
|
const item2 = await Database.libraryItemModel.findOneExpanded({
|
||||||
|
libraryId: testLibrary.id,
|
||||||
|
path: '/mnt/drive/file-same-ino-different-dev.pdf'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(item1.path).to.not.equal(item2.path)
|
||||||
|
|
||||||
|
expect(ItemToItemInoMatch(item1, item2)).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ItemToItemInoMatch-RenamedFileShouldMatch', async () => {
|
||||||
|
let ItemToItemInoMatch = LibraryScanner.__get__('ItemToItemInoMatch')
|
||||||
|
|
||||||
|
let mockFileInfo = getMockFileInfo()
|
||||||
|
testLibrary = await loadTestDatabase(mockFileInfo)
|
||||||
|
|
||||||
|
// this compares the inode from the first library item to the second library item's library file inode
|
||||||
|
const original = await Database.libraryItemModel.findOneExpanded({
|
||||||
|
libraryId: testLibrary.id,
|
||||||
|
path: '/test/file.pdf'
|
||||||
|
})
|
||||||
|
|
||||||
|
const renamedMockFileInfo = getRenamedMockFileInfo().get('/test/file-renamed.pdf')
|
||||||
|
const renamedFile = new LibraryFile()
|
||||||
|
var fileMetadata = new FileMetadata()
|
||||||
|
fileMetadata.setData(renamedMockFileInfo)
|
||||||
|
fileMetadata.filename = Path.basename(renamedMockFileInfo.path)
|
||||||
|
fileMetadata.path = fileUtils.filePathToPOSIX(renamedMockFileInfo.path)
|
||||||
|
fileMetadata.relPath = fileUtils.filePathToPOSIX(renamedMockFileInfo.path)
|
||||||
|
fileMetadata.ext = Path.extname(renamedMockFileInfo.path)
|
||||||
|
renamedFile.ino = renamedMockFileInfo.ino
|
||||||
|
renamedFile.deviceId = renamedMockFileInfo.dev
|
||||||
|
renamedFile.metadata = fileMetadata
|
||||||
|
renamedFile.addedAt = Date.now()
|
||||||
|
renamedFile.updatedAt = Date.now()
|
||||||
|
renamedFile.metadata = fileMetadata
|
||||||
|
|
||||||
|
const renamedItem = new LibraryItem(buildBookLibraryItemParams(renamedFile, null, testLibrary.id, null))
|
||||||
|
|
||||||
|
expect(ItemToItemInoMatch(original, renamedItem)).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createLibraryItemScanData', () => {
|
||||||
|
it('createLibraryItemScanDataSetsDeviceId', async () => {
|
||||||
|
/**
|
||||||
|
* @param {{ id: any; libraryId: any; }} folder
|
||||||
|
* @param {{ mediaType: any; }} library
|
||||||
|
* @param {{ ino: any; dev: any; mtimeMs: any; ctimeMs: any; birthtimeMs: any; }} libraryItemFolderStats
|
||||||
|
* @param {{ path: any; relPath: any; mediaMetadata: any; }} libraryItemData
|
||||||
|
* @param {any} isFile
|
||||||
|
* @param {any} fileObjs
|
||||||
|
* @returns {LibraryItemScanData} new object
|
||||||
|
*/
|
||||||
|
const createLibraryItemScanData = LibraryScanner.__get__('createLibraryItemScanData')
|
||||||
|
|
||||||
|
const liFolderStats = { path: '/library/book/file.epub', isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '1000' }
|
||||||
|
const lf_properties = buildLibraryFileProperties('/library/book/file.epub', '1', '1000')
|
||||||
|
const libraryFile = new LibraryFile(lf_properties)
|
||||||
|
|
||||||
|
const lisd = createLibraryItemScanData({ id: 'foo', libraryId: 'bar' }, { mediaType: 'ebook' }, liFolderStats, lf_properties, true, [libraryFile.toJSON()])
|
||||||
|
|
||||||
|
expect(lisd).to.not.be.null
|
||||||
|
expect(lisd.ino).to.equal(liFolderStats.ino)
|
||||||
|
expect(lisd.deviceId).to.equal(liFolderStats.dev)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
@ -3,8 +3,13 @@ const expect = chai.expect
|
|||||||
const sinon = require('sinon')
|
const sinon = require('sinon')
|
||||||
const fileUtils = require('../../../server/utils/fileUtils')
|
const fileUtils = require('../../../server/utils/fileUtils')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
|
const fsextra = require('../../../server/libs/fsExtra')
|
||||||
const Logger = require('../../../server/Logger')
|
const Logger = require('../../../server/Logger')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('../../../server/libs/fsExtra').fsExtra} fsextra
|
||||||
|
*/
|
||||||
|
|
||||||
describe('fileUtils', () => {
|
describe('fileUtils', () => {
|
||||||
it('shouldIgnoreFile', () => {
|
it('shouldIgnoreFile', () => {
|
||||||
global.isWin = process.platform === 'win32'
|
global.isWin = process.platform === 'win32'
|
||||||
@ -39,6 +44,46 @@ describe('fileUtils', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('fsextra', () => {
|
||||||
|
let statStub
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// two files with same indoe but different device ID
|
||||||
|
const mockStats = new Map([
|
||||||
|
['/test/file1.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '100' }],
|
||||||
|
['/mnt/other/file2.txt', { isDirectory: () => false, size: 512, mtimeMs: Date.now(), ino: '1', dev: '200' }]
|
||||||
|
])
|
||||||
|
|
||||||
|
statStub = sinon.stub(fsextra, 'stat')
|
||||||
|
statStub.callsFake((path) => {
|
||||||
|
const normalizedPath = fileUtils.filePathToPOSIX(path).replace(/\/$/, '')
|
||||||
|
const stats = mockStats.get(normalizedPath)
|
||||||
|
if (stats) {
|
||||||
|
return stats
|
||||||
|
} else {
|
||||||
|
new Error(`ENOENT: no such file or directory, stat '${normalizedPath}'`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
fsextra.stat.restore()
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shouldGetDeviceIdForFile', async () => {
|
||||||
|
const id = await fileUtils.getDeviceId('/test/file1.mp3')
|
||||||
|
|
||||||
|
expect(id).to.be.an('string')
|
||||||
|
|
||||||
|
const id2 = await fileUtils.getDeviceId('/mnt/other/file2.txt')
|
||||||
|
|
||||||
|
expect(id2).to.be.an('string')
|
||||||
|
|
||||||
|
expect(id).to.not.equal(id2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('recurseFiles', () => {
|
describe('recurseFiles', () => {
|
||||||
let readdirStub, realpathStub, statStub
|
let readdirStub, realpathStub, statStub
|
||||||
|
|
||||||
@ -53,7 +98,7 @@ describe('fileUtils', () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const mockStats = new Map([
|
const mockStats = new Map([
|
||||||
['/test/file1.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1' }],
|
['/test/file1.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '100' }],
|
||||||
['/test/subfolder', { isDirectory: () => true, size: 0, mtimeMs: Date.now(), ino: '2' }],
|
['/test/subfolder', { isDirectory: () => true, size: 0, mtimeMs: Date.now(), ino: '2' }],
|
||||||
['/test/subfolder/file2.m4b', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '3' }],
|
['/test/subfolder/file2.m4b', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '3' }],
|
||||||
['/test/ignoreme', { isDirectory: () => true, size: 0, mtimeMs: Date.now(), ino: '4' }],
|
['/test/ignoreme', { isDirectory: () => true, size: 0, mtimeMs: Date.now(), ino: '4' }],
|
||||||
@ -98,6 +143,9 @@ describe('fileUtils', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
fs.stat.restore()
|
||||||
|
fs.realpath.restore()
|
||||||
|
fs.readdir.restore()
|
||||||
sinon.restore()
|
sinon.restore()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -105,6 +153,7 @@ describe('fileUtils', () => {
|
|||||||
const files = await fileUtils.recurseFiles('/test')
|
const files = await fileUtils.recurseFiles('/test')
|
||||||
expect(files).to.be.an('array')
|
expect(files).to.be.an('array')
|
||||||
expect(files).to.have.lengthOf(3)
|
expect(files).to.have.lengthOf(3)
|
||||||
|
expect(statStub.called).to.be.true
|
||||||
|
|
||||||
expect(files[0]).to.deep.equal({
|
expect(files[0]).to.deep.equal({
|
||||||
name: 'file1.mp3',
|
name: 'file1.mp3',
|
||||||
|
Loading…
Reference in New Issue
Block a user