mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-09-10 17:58:02 +02:00
Added deviceId in addition to inode to uniquely identify files
This commit is contained in:
parent
d8f07eb956
commit
3a4aacb7bf
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",
|
||||
"nodemon": "^2.0.20",
|
||||
"nyc": "^15.1.0",
|
||||
"sinon": "^17.0.1"
|
||||
"sinon": "^21.0.0",
|
||||
"rewire": "^9.0.0"
|
||||
}
|
||||
}
|
||||
|
@ -169,10 +169,10 @@ class LogManager {
|
||||
/**
|
||||
* Most recent 5000 daily logs
|
||||
*
|
||||
* @returns {string}
|
||||
* @returns {LogObject[]}
|
||||
*/
|
||||
getMostRecentCurrentDailyLogs() {
|
||||
return this.currentDailyLog?.logs.slice(-5000) || ''
|
||||
return this.currentDailyLog?.logs.slice(-5000) || []
|
||||
}
|
||||
}
|
||||
module.exports = LogManager
|
||||
|
@ -11,6 +11,7 @@ const Podcast = require('./Podcast')
|
||||
/**
|
||||
* @typedef LibraryFileObject
|
||||
* @property {string} ino
|
||||
* @property {string} deviceId
|
||||
* @property {boolean} isSupplementary
|
||||
* @property {number} addedAt
|
||||
* @property {number} updatedAt
|
||||
@ -33,6 +34,8 @@ class LibraryItem extends Model {
|
||||
/** @type {string} */
|
||||
this.ino
|
||||
/** @type {string} */
|
||||
this.deviceId
|
||||
/** @type {string} */
|
||||
this.path
|
||||
/** @type {string} */
|
||||
this.relPath
|
||||
@ -237,7 +240,7 @@ class LibraryItem extends Model {
|
||||
* @param {import('sequelize').WhereOptions} where
|
||||
* @param {import('sequelize').BindOrReplacements} [replacements]
|
||||
* @param {import('sequelize').IncludeOptions} [include]
|
||||
* @returns {Promise<LibraryItemExpanded>}
|
||||
* @returns {Promise<LibraryItemExpanded | null>}
|
||||
*/
|
||||
static async findOneExpanded(where, replacements = null, include = null) {
|
||||
const libraryItem = await this.findOne({
|
||||
@ -289,7 +292,7 @@ class LibraryItem extends Model {
|
||||
* @param {import('./Library')} library
|
||||
* @param {import('./User')} user
|
||||
* @param {object} options
|
||||
* @returns {{ libraryItems:Object[], count:number }}
|
||||
* @returns {Promise<{ libraryItems:Object[], count:number }>}
|
||||
*/
|
||||
static async getByFilterAndSort(library, user, options) {
|
||||
let start = Date.now()
|
||||
@ -670,6 +673,7 @@ class LibraryItem extends Model {
|
||||
primaryKey: true
|
||||
},
|
||||
ino: DataTypes.STRING,
|
||||
deviceId: DataTypes.STRING,
|
||||
path: DataTypes.STRING,
|
||||
relPath: DataTypes.STRING,
|
||||
mediaId: DataTypes.UUID,
|
||||
|
@ -113,7 +113,7 @@ class Task {
|
||||
/**
|
||||
* Set task as finished
|
||||
*
|
||||
* @param {TaskString} [newDescriptionString] update description
|
||||
* @param {TaskString | null} [newDescriptionString] update description
|
||||
* @param {boolean} [clearDescription] clear description
|
||||
*/
|
||||
setFinished(newDescriptionString = null, clearDescription = false) {
|
||||
|
@ -6,6 +6,7 @@ class AudioFile {
|
||||
constructor(data) {
|
||||
this.index = null
|
||||
this.ino = null
|
||||
this.deviceId = null
|
||||
/** @type {FileMetadata} */
|
||||
this.metadata = null
|
||||
this.addedAt = null
|
||||
@ -44,6 +45,7 @@ class AudioFile {
|
||||
return {
|
||||
index: this.index,
|
||||
ino: this.ino,
|
||||
deviceId: this.deviceId,
|
||||
metadata: this.metadata.toJSON(),
|
||||
addedAt: this.addedAt,
|
||||
updatedAt: this.updatedAt,
|
||||
@ -72,6 +74,7 @@ class AudioFile {
|
||||
construct(data) {
|
||||
this.index = data.index
|
||||
this.ino = data.ino
|
||||
this.deviceId = data.dev
|
||||
this.metadata = new FileMetadata(data.metadata || {})
|
||||
this.addedAt = data.addedAt
|
||||
this.updatedAt = data.updatedAt
|
||||
@ -112,6 +115,7 @@ class AudioFile {
|
||||
// New scanner creates AudioFile from AudioFileScanner
|
||||
setDataFromProbe(libraryFile, probeData) {
|
||||
this.ino = libraryFile.ino || null
|
||||
this.deviceId = libraryFile.deviceId || null
|
||||
|
||||
if (libraryFile.metadata instanceof FileMetadata) {
|
||||
this.metadata = libraryFile.metadata.clone()
|
||||
@ -137,7 +141,7 @@ class AudioFile {
|
||||
|
||||
syncChapters(updatedChapters) {
|
||||
if (this.chapters.length !== updatedChapters.length) {
|
||||
this.chapters = updatedChapters.map(ch => ({ ...ch }))
|
||||
this.chapters = updatedChapters.map((ch) => ({ ...ch }))
|
||||
return true
|
||||
} else if (updatedChapters.length === 0) {
|
||||
if (this.chapters.length > 0) {
|
||||
@ -154,7 +158,7 @@ class AudioFile {
|
||||
}
|
||||
}
|
||||
if (hasUpdates) {
|
||||
this.chapters = updatedChapters.map(ch => ({ ...ch }))
|
||||
this.chapters = updatedChapters.map((ch) => ({ ...ch }))
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
@ -164,8 +168,8 @@ class AudioFile {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {AudioFile} scannedAudioFile
|
||||
*
|
||||
* @param {AudioFile} scannedAudioFile
|
||||
* @returns {boolean} true if updates were made
|
||||
*/
|
||||
updateFromScan(scannedAudioFile) {
|
||||
@ -196,4 +200,4 @@ class AudioFile {
|
||||
return hasUpdated
|
||||
}
|
||||
}
|
||||
module.exports = AudioFile
|
||||
module.exports = AudioFile
|
||||
|
@ -3,6 +3,7 @@ const FileMetadata = require('../metadata/FileMetadata')
|
||||
class EBookFile {
|
||||
constructor(file) {
|
||||
this.ino = null
|
||||
this.deviceId = null
|
||||
this.metadata = null
|
||||
this.ebookFormat = null
|
||||
this.addedAt = null
|
||||
@ -15,6 +16,7 @@ class EBookFile {
|
||||
|
||||
construct(file) {
|
||||
this.ino = file.ino
|
||||
this.deviceId = file.dev
|
||||
this.metadata = new FileMetadata(file.metadata)
|
||||
this.ebookFormat = file.ebookFormat || this.metadata.format
|
||||
this.addedAt = file.addedAt
|
||||
@ -24,6 +26,7 @@ class EBookFile {
|
||||
toJSON() {
|
||||
return {
|
||||
ino: this.ino,
|
||||
deviceId: this.deviceId,
|
||||
metadata: this.metadata.toJSON(),
|
||||
ebookFormat: this.ebookFormat,
|
||||
addedAt: this.addedAt,
|
||||
@ -37,6 +40,7 @@ class EBookFile {
|
||||
|
||||
setData(libraryFile) {
|
||||
this.ino = libraryFile.ino
|
||||
this.deviceId = libraryFile.deviceId
|
||||
this.metadata = libraryFile.metadata.clone()
|
||||
this.ebookFormat = libraryFile.metadata.format
|
||||
this.addedAt = Date.now()
|
||||
@ -58,4 +62,4 @@ class EBookFile {
|
||||
return hasUpdated
|
||||
}
|
||||
}
|
||||
module.exports = EBookFile
|
||||
module.exports = EBookFile
|
||||
|
@ -1,11 +1,12 @@
|
||||
const Path = require('path')
|
||||
const { getFileTimestampsWithIno, filePathToPOSIX } = require('../../utils/fileUtils')
|
||||
const fileUtils = require('../../utils/fileUtils')
|
||||
const globals = require('../../utils/globals')
|
||||
const FileMetadata = require('../metadata/FileMetadata')
|
||||
|
||||
class LibraryFile {
|
||||
constructor(file) {
|
||||
this.ino = null
|
||||
this.deviceId = null
|
||||
this.metadata = null
|
||||
this.isSupplementary = null
|
||||
this.addedAt = null
|
||||
@ -18,6 +19,7 @@ class LibraryFile {
|
||||
|
||||
construct(file) {
|
||||
this.ino = file.ino
|
||||
this.deviceId = file.deviceId
|
||||
this.metadata = new FileMetadata(file.metadata)
|
||||
this.isSupplementary = file.isSupplementary === undefined ? null : file.isSupplementary
|
||||
this.addedAt = file.addedAt
|
||||
@ -27,7 +29,8 @@ class LibraryFile {
|
||||
toJSON() {
|
||||
return {
|
||||
ino: this.ino,
|
||||
metadata: this.metadata.toJSON(),
|
||||
deviceId: this.deviceId,
|
||||
metadata: this.metadata ? this.metadata.toJSON() : null,
|
||||
isSupplementary: this.isSupplementary,
|
||||
addedAt: this.addedAt,
|
||||
updatedAt: this.updatedAt,
|
||||
@ -40,11 +43,13 @@ class LibraryFile {
|
||||
}
|
||||
|
||||
get fileType() {
|
||||
if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image'
|
||||
if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio'
|
||||
if (globals.SupportedEbookTypes.includes(this.metadata.format)) return 'ebook'
|
||||
if (globals.TextFileTypes.includes(this.metadata.format)) return 'text'
|
||||
if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata'
|
||||
if (this.metadata) {
|
||||
if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image'
|
||||
if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio'
|
||||
if (globals.SupportedEbookTypes.includes(this.metadata.format)) return 'ebook'
|
||||
if (globals.TextFileTypes.includes(this.metadata.format)) return 'text'
|
||||
if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
@ -61,14 +66,15 @@ class LibraryFile {
|
||||
}
|
||||
|
||||
async setDataFromPath(path, relPath) {
|
||||
var fileTsData = await getFileTimestampsWithIno(path)
|
||||
var fileTsData = await fileUtils.getFileTimestampsWithIno(path)
|
||||
var fileMetadata = new FileMetadata()
|
||||
fileMetadata.setData(fileTsData)
|
||||
fileMetadata.filename = Path.basename(relPath)
|
||||
fileMetadata.path = filePathToPOSIX(path)
|
||||
fileMetadata.relPath = filePathToPOSIX(relPath)
|
||||
fileMetadata.path = fileUtils.filePathToPOSIX(path)
|
||||
fileMetadata.relPath = fileUtils.filePathToPOSIX(relPath)
|
||||
fileMetadata.ext = Path.extname(relPath)
|
||||
this.ino = fileTsData.ino
|
||||
this.deviceId = fileTsData.dev
|
||||
this.metadata = fileMetadata
|
||||
this.addedAt = Date.now()
|
||||
this.updatedAt = Date.now()
|
||||
|
@ -19,6 +19,8 @@ class LibraryItemScanData {
|
||||
this.mediaType = data.mediaType
|
||||
/** @type {string} */
|
||||
this.ino = data.ino
|
||||
/** @type {string} */
|
||||
this.deviceId = data.dev
|
||||
/** @type {number} */
|
||||
this.mtimeMs = data.mtimeMs
|
||||
/** @type {number} */
|
||||
@ -54,9 +56,10 @@ class LibraryItemScanData {
|
||||
*/
|
||||
get libraryItemObject() {
|
||||
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 {
|
||||
ino: this.ino,
|
||||
deviceId: this.deviceId,
|
||||
path: this.path,
|
||||
relPath: this.relPath,
|
||||
mediaType: this.mediaType,
|
||||
@ -80,107 +83,107 @@ class LibraryItemScanData {
|
||||
|
||||
/** @type {boolean} */
|
||||
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[]} */
|
||||
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[]} */
|
||||
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[]} */
|
||||
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[]} */
|
||||
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[]} */
|
||||
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[]} */
|
||||
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[]} */
|
||||
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[]} */
|
||||
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[]} */
|
||||
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[]} */
|
||||
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[]} */
|
||||
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[]} */
|
||||
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} */
|
||||
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} */
|
||||
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} */
|
||||
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} */
|
||||
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} */
|
||||
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} */
|
||||
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
|
||||
* @returns {boolean} true if changes found
|
||||
*/
|
||||
async checkLibraryItemData(existingLibraryItem, libraryScan) {
|
||||
const keysToCompare = ['libraryFolderId', 'ino', 'path', 'relPath', 'isFile']
|
||||
const keysToCompare = ['libraryFolderId', 'ino', 'deviceId', 'path', 'relPath', 'isFile']
|
||||
this.hasChanges = false
|
||||
this.hasPathChange = false
|
||||
for (const key of keysToCompare) {
|
||||
@ -219,28 +222,29 @@ class LibraryItemScanData {
|
||||
|
||||
this.libraryFilesRemoved = []
|
||||
this.libraryFilesModified = []
|
||||
let libraryFilesAdded = this.libraryFiles.map(lf => lf)
|
||||
let libraryFilesAdded = this.libraryFiles.map((lf) => lf)
|
||||
|
||||
for (const existingLibraryFile of existingLibraryItem.libraryFiles) {
|
||||
// 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.libraryFiles.find((lf) => lf.metadata.path === existingLibraryFile.metadata.path)
|
||||
if (!matchingLibraryFile) {
|
||||
matchingLibraryFile = this.libraryFiles.find(lf => lf.ino === existingLibraryFile.ino)
|
||||
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}"`)
|
||||
this.libraryFilesRemoved.push(existingLibraryFile)
|
||||
existingLibraryItem.libraryFiles = existingLibraryItem.libraryFiles.filter(lf => lf !== existingLibraryFile)
|
||||
existingLibraryItem.libraryFiles = existingLibraryItem.libraryFiles.filter((lf) => lf !== existingLibraryFile)
|
||||
this.hasChanges = true
|
||||
} else {
|
||||
libraryFilesAdded = libraryFilesAdded.filter(lf => lf !== matchingLibraryFile)
|
||||
libraryFilesAdded = libraryFilesAdded.filter((lf) => lf !== matchingLibraryFile)
|
||||
let existingLibraryFileBefore = structuredClone(existingLibraryFile)
|
||||
if (this.compareUpdateLibraryFile(existingLibraryItem.path, existingLibraryFile, matchingLibraryFile, libraryScan)) {
|
||||
this.libraryFilesModified.push({old: existingLibraryFileBefore, new: existingLibraryFile})
|
||||
this.libraryFilesModified.push({ old: existingLibraryFileBefore, new: existingLibraryFile })
|
||||
this.hasChanges = true
|
||||
}
|
||||
}
|
||||
@ -263,7 +267,7 @@ class LibraryItemScanData {
|
||||
|
||||
if (this.hasChanges) {
|
||||
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.lastScanVersion = packageJson.version
|
||||
@ -283,16 +287,17 @@ class LibraryItemScanData {
|
||||
/**
|
||||
* Update existing library file with scanned in library file data
|
||||
* @param {string} libraryItemPath
|
||||
* @param {LibraryItem.LibraryFileObject} existingLibraryFile
|
||||
* @param {import('../objects/files/LibraryFile')} scannedLibraryFile
|
||||
* @param {LibraryItem.LibraryFileObject} existingLibraryFile
|
||||
* @param {import('../objects/files/LibraryFile')} scannedLibraryFile
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @returns {boolean} false if no changes
|
||||
*/
|
||||
compareUpdateLibraryFile(libraryItemPath, existingLibraryFile, scannedLibraryFile, libraryScan) {
|
||||
let hasChanges = false
|
||||
|
||||
if (existingLibraryFile.ino !== scannedLibraryFile.ino) {
|
||||
if (existingLibraryFile.ino !== scannedLibraryFile.ino && existingLibraryFile.deviceId !== scannedLibraryFile.deviceId) {
|
||||
existingLibraryFile.ino = scannedLibraryFile.ino
|
||||
existingLibraryFile.deviceId = scannedLibraryFile.deviceId
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
@ -317,38 +322,38 @@ class LibraryItemScanData {
|
||||
|
||||
/**
|
||||
* Check if existing audio file on Book was removed
|
||||
* @param {import('../models/Book').AudioFileObject} existingAudioFile
|
||||
* @param {import('../models/Book').AudioFileObject} existingAudioFile
|
||||
* @returns {boolean} true if audio file was removed
|
||||
*/
|
||||
checkAudioFileRemoved(existingAudioFile) {
|
||||
if (!this.audioLibraryFilesRemoved.length) return false
|
||||
// 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
|
||||
}
|
||||
// Fallback to check inode value
|
||||
return this.audioLibraryFilesRemoved.some(af => af.ino === existingAudioFile.ino)
|
||||
return this.audioLibraryFilesRemoved.some((af) => af.ino === existingAudioFile.ino)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if existing ebook file on Book was removed
|
||||
* @param {import('../models/Book').EBookFileObject} ebookFile
|
||||
* @param {import('../models/Book').EBookFileObject} ebookFile
|
||||
* @returns {boolean} true if ebook file was removed
|
||||
*/
|
||||
checkEbookFileRemoved(ebookFile) {
|
||||
if (!this.ebookLibraryFiles.length) return true
|
||||
|
||||
if (this.ebookLibraryFiles.some(lf => lf.metadata.path === ebookFile.metadata.path)) {
|
||||
if (this.ebookLibraryFiles.some((lf) => lf.metadata.path === ebookFile.metadata.path)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !this.ebookLibraryFiles.some(lf => lf.ino === ebookFile.ino)
|
||||
return !this.ebookLibraryFiles.some((lf) => lf.ino === ebookFile.ino)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set data parsed from filenames
|
||||
*
|
||||
* @param {Object} bookMetadata
|
||||
*
|
||||
* @param {Object} bookMetadata
|
||||
*/
|
||||
setBookMetadataFromFilenames(bookMetadata) {
|
||||
const keysToMap = ['title', 'subtitle', 'publishedYear', 'asin']
|
||||
@ -374,4 +379,4 @@ class LibraryItemScanData {
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = LibraryItemScanData
|
||||
module.exports = LibraryItemScanData
|
||||
|
@ -139,26 +139,15 @@ class LibraryItemScanner {
|
||||
const newLibraryFile = new LibraryFile()
|
||||
// fileItem.path is the relative 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)
|
||||
}
|
||||
|
||||
const libraryItemStats = await fileUtils.getFileTimestampsWithIno(libraryItemData.path)
|
||||
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
|
||||
})
|
||||
return await buildLibraryItemScanData(libraryItemData, folder, library, isSingleMediaItem, libraryFiles)
|
||||
}
|
||||
|
||||
async setDataFromPath(path) {}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} existingLibraryItem
|
||||
@ -219,3 +208,22 @@ class LibraryItemScanner {
|
||||
}
|
||||
}
|
||||
module.exports = new LibraryItemScanner()
|
||||
|
||||
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
|
||||
* @param {import('../models/Library')} library
|
||||
* @param {import('../models/LibraryFolder')} folder
|
||||
* @returns {LibraryItemScanData[]}
|
||||
* @returns {Promise<LibraryItemScanData[]>}
|
||||
*/
|
||||
async scanFolder(library, folder) {
|
||||
const folderPath = fileUtils.filePathToPOSIX(folder.path)
|
||||
@ -350,6 +350,7 @@ class LibraryScanner {
|
||||
libraryId: folder.libraryId,
|
||||
mediaType: library.mediaType,
|
||||
ino: libraryItemFolderStats.ino,
|
||||
deviceId: libraryItemFolderStats.dev,
|
||||
mtimeMs: libraryItemFolderStats.mtimeMs || 0,
|
||||
ctimeMs: libraryItemFolderStats.ctimeMs || 0,
|
||||
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
|
||||
@ -642,12 +643,25 @@ class LibraryScanner {
|
||||
}
|
||||
module.exports = new LibraryScanner()
|
||||
|
||||
/**
|
||||
* @param {import("../models/LibraryItem") | LibraryItemScanData} libraryItem1
|
||||
* @param {import("../models/LibraryItem") | LibraryItemScanData} 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) {
|
||||
return libraryItem1.ino === libraryItem2.ino
|
||||
return libraryItem1.ino === libraryItem2.ino && libraryItem1.deviceId === libraryItem2.deviceId
|
||||
}
|
||||
|
||||
function hasAudioFiles(fileUpdateGroup, itemDir) {
|
||||
@ -658,54 +672,85 @@ function isSingleMediaFile(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) {
|
||||
const ino = await fileUtils.getIno(fullPath)
|
||||
const deviceId = await fileUtils.getDeviceId(fullPath)
|
||||
if (!ino) return null
|
||||
const existingLibraryItem = await Database.libraryItemModel.findOneExpanded({
|
||||
libraryId: libraryId,
|
||||
ino: ino
|
||||
ino: ino,
|
||||
deviceId: deviceId
|
||||
})
|
||||
if (existingLibraryItem) Logger.debug(`[LibraryScanner] Found library item with matching inode "${ino}" at path "${existingLibraryItem.path}"`)
|
||||
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) {
|
||||
if (!isSingleMedia) return null
|
||||
// check if it was moved from another folder by comparing the ino to the library files
|
||||
const ino = await fileUtils.getIno(fullPath)
|
||||
const deviceId = await fileUtils.getDeviceId(fullPath)
|
||||
if (!ino) return null
|
||||
const existingLibraryItem = await Database.libraryItemModel.findOneExpanded(
|
||||
[
|
||||
{
|
||||
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
|
||||
})
|
||||
],
|
||||
{
|
||||
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}"`)
|
||||
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) {
|
||||
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 = []
|
||||
for (const itemFile of itemFiles) {
|
||||
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
|
||||
const existingLibraryItem = await Database.libraryItemModel.findOneExpanded({
|
||||
libraryId: libraryId,
|
||||
ino: {
|
||||
[sequelize.Op.in]: itemFileInos
|
||||
/** @type {import('../models/LibraryItem').LibraryItemExpanded | null} */
|
||||
let existingLibraryItem = null
|
||||
for (let item in itemFileInos) {
|
||||
existingLibraryItem = await Database.libraryItemModel.findOneExpanded({
|
||||
libraryId: libraryId,
|
||||
ino: {
|
||||
[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}"`)
|
||||
return existingLibraryItem
|
||||
}
|
||||
|
@ -47,6 +47,10 @@ function getFileStat(path) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @returns {Promise<object | null>}
|
||||
*/
|
||||
async function getFileTimestampsWithIno(path) {
|
||||
try {
|
||||
var stat = await fs.stat(path, { bigint: true })
|
||||
@ -55,11 +59,12 @@ async function getFileTimestampsWithIno(path) {
|
||||
mtimeMs: Number(stat.mtimeMs),
|
||||
ctimeMs: Number(stat.ctimeMs),
|
||||
birthtimeMs: Number(stat.birthtimeMs),
|
||||
ino: String(stat.ino)
|
||||
ino: String(stat.ino),
|
||||
deviceId: String(stat.dev)
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.error(`[fileUtils] Failed to getFileTimestampsWithIno for path "${path}"`, err)
|
||||
return false
|
||||
return null
|
||||
}
|
||||
}
|
||||
module.exports.getFileTimestampsWithIno = getFileTimestampsWithIno
|
||||
@ -92,7 +97,7 @@ module.exports.getFileMTimeMs = async (path) => {
|
||||
/**
|
||||
*
|
||||
* @param {string} filepath
|
||||
* @returns {boolean}
|
||||
* @returns {Promise<boolean>} isFile
|
||||
*/
|
||||
async function checkPathIsFile(filepath) {
|
||||
try {
|
||||
@ -104,6 +109,10 @@ async function checkPathIsFile(filepath) {
|
||||
}
|
||||
module.exports.checkPathIsFile = checkPathIsFile
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @returns {string | null} inode
|
||||
*/
|
||||
function getIno(path) {
|
||||
return fs
|
||||
.stat(path, { bigint: true })
|
||||
@ -115,10 +124,25 @@ function getIno(path) {
|
||||
}
|
||||
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
|
||||
* @param {string} path
|
||||
* @returns {string}
|
||||
* @returns {Promise<string>} file contents
|
||||
*/
|
||||
async function readTextFile(path) {
|
||||
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
|
||||
*
|
||||
* @param {string} path
|
||||
* @returns {string}
|
||||
* @returns {string | null} reason to ignore
|
||||
*/
|
||||
module.exports.shouldIgnoreFile = (path) => {
|
||||
// Check if directory or file name starts with "."
|
||||
@ -178,8 +202,8 @@ module.exports.shouldIgnoreFile = (path) => {
|
||||
/**
|
||||
* Get array of files inside dir
|
||||
* @param {string} path
|
||||
* @param {string} [relPathToReplace]
|
||||
* @returns {FilePathItem[]}
|
||||
* @param {string | null} [relPathToReplace]
|
||||
* @returns {Promise<FilePathItem[]>}
|
||||
*/
|
||||
module.exports.recurseFiles = async (path, relPathToReplace = null) => {
|
||||
path = filePathToPOSIX(path)
|
||||
@ -292,7 +316,7 @@ module.exports.getFilePathItemFromFileUpdate = (fileUpdate) => {
|
||||
*
|
||||
* @param {string} url
|
||||
* @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}
|
||||
*/
|
||||
module.exports.downloadFile = (url, filepath, contentTypeFilter = null) => {
|
||||
|
23
test/server/objects/LibraryItemScanData.test.js
Normal file
23
test/server/objects/LibraryItemScanData.test.js
Normal file
@ -0,0 +1,23 @@
|
||||
// TODO - need to check
|
||||
// compareUpdateLibraryFile
|
||||
// checkEbookFileRemoved
|
||||
// checkAudioFileRemoved
|
||||
// libraryItemObject()
|
||||
/*
|
||||
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
|
||||
})
|
||||
|
||||
*/
|
9
test/server/objects/SimilarLibraryFileObjects.test.js
Normal file
9
test/server/objects/SimilarLibraryFileObjects.test.js
Normal file
@ -0,0 +1,9 @@
|
||||
const LibraryFile = require('../../../server/objects/files/LibraryFile')
|
||||
const EBookFile = require('../../../server/objects/files/EBookFile')
|
||||
const AudioFile = require('../../../server/objects/files/AudioFile')
|
||||
const LibraryItem = require('../../../server/models/LibraryItem')
|
||||
const LibraryItemScanData = require('../../../server/scanner/LibraryItemScanData')
|
||||
|
||||
// TODO: all of these duplicate each other. Need to verify that deviceId is set on each when constructing. And that deviceId is populated when using toJSON()
|
||||
|
||||
// TODO: check that any libraryFiles properties set to JSON contain a LibraryFile which has a deviceId property
|
1
test/server/scanner/LibraryItemScanner.test.js
Normal file
1
test/server/scanner/LibraryItemScanner.test.js
Normal file
@ -0,0 +1 @@
|
||||
// TODO: test buildLibraryItemScanData
|
323
test/server/scanner/LibraryScanner.test.js
Normal file
323
test/server/scanner/LibraryScanner.test.js
Normal file
@ -0,0 +1,323 @@
|
||||
const chai = require('chai')
|
||||
const expect = chai.expect
|
||||
const sinon = require('sinon')
|
||||
const rewire = require('rewire')
|
||||
const fileUtils = require('../../../server/utils/fileUtils')
|
||||
const Database = require('../../../server/Database')
|
||||
const { Sequelize } = require('sequelize')
|
||||
const LibraryFile = require('../../../server/objects/files/LibraryFile')
|
||||
const LibraryItem = require('../../../server/models/LibraryItem')
|
||||
const FileMetadata = require('../../../server/objects/metadata/FileMetadata')
|
||||
const Path = require('path')
|
||||
|
||||
describe('LibraryScanner', () => {
|
||||
let getInoStub, getDeviceIdStub, getFileTimestampsWithInoStub, LibraryScanner, testLibrary
|
||||
|
||||
beforeEach(async () => {
|
||||
getInoStub = sinon.stub(fileUtils, 'getIno')
|
||||
getInoStub.callsFake((path) => {
|
||||
const normalizedPath = fileUtils.filePathToPOSIX(path).replace(/\/$/, '')
|
||||
const stats = getMockFileInfo().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 = getMockFileInfo().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 = getMockFileInfo().get(normalizedPath)
|
||||
if (stats) {
|
||||
return stats
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
// findLibraryItemByFileToItemInoMatch(libraryId, fullPath, isSingleMedia, itemFiles)
|
||||
let findLibraryItemByItemToItemInoMatch = LibraryScanner.__get__('findLibraryItemByItemToItemInoMatch')
|
||||
|
||||
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 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)
|
||||
})
|
||||
|
||||
// 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', () => {
|
||||
let ItemToItemInoMatch = LibraryScanner.__get__('ItemToItemInoMatch')
|
||||
})
|
||||
})
|
||||
|
||||
async function loadTestDatabase(mockFileInfo) {
|
||||
let libraryItem1Id, libraryItem2Id
|
||||
|
||||
let fileInfo = mockFileInfo || getMockFileInfo()
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
}
|
||||
|
||||
/** @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' }]
|
||||
])
|
||||
}
|
||||
|
||||
/** @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' }]
|
||||
])
|
||||
}
|
@ -3,8 +3,13 @@ const expect = chai.expect
|
||||
const sinon = require('sinon')
|
||||
const fileUtils = require('../../../server/utils/fileUtils')
|
||||
const fs = require('fs')
|
||||
const fsextra = require('../../../server/libs/fsExtra')
|
||||
const Logger = require('../../../server/Logger')
|
||||
|
||||
/**
|
||||
* @typedef {import('../../../server/libs/fsExtra').fsExtra} fsextra
|
||||
*/
|
||||
|
||||
describe('fileUtils', () => {
|
||||
it('shouldIgnoreFile', () => {
|
||||
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', () => {
|
||||
let readdirStub, realpathStub, statStub
|
||||
|
||||
@ -53,7 +98,7 @@ describe('fileUtils', () => {
|
||||
])
|
||||
|
||||
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/file2.m4b', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '3' }],
|
||||
['/test/ignoreme', { isDirectory: () => true, size: 0, mtimeMs: Date.now(), ino: '4' }],
|
||||
@ -98,6 +143,9 @@ describe('fileUtils', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.stat.restore()
|
||||
fs.realpath.restore()
|
||||
fs.readdir.restore()
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
@ -105,6 +153,7 @@ describe('fileUtils', () => {
|
||||
const files = await fileUtils.recurseFiles('/test')
|
||||
expect(files).to.be.an('array')
|
||||
expect(files).to.have.lengthOf(3)
|
||||
expect(statStub.called).to.be.true
|
||||
|
||||
expect(files[0]).to.deep.equal({
|
||||
name: 'file1.mp3',
|
||||
|
Loading…
Reference in New Issue
Block a user