Merge master

This commit is contained in:
advplyr 2023-11-01 08:58:48 -05:00
commit ab14b561f5
147 changed files with 4669 additions and 5036 deletions

View file

@ -1,3 +1,4 @@
const Path = require('path')
const uuidv4 = require("uuid").v4
const FeedMeta = require('./FeedMeta')
const FeedEpisode = require('./FeedEpisode')
@ -101,11 +102,13 @@ class Feed {
this.serverAddress = serverAddress
this.feedUrl = feedUrl
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
this.meta = new FeedMeta()
this.meta.title = mediaMetadata.title
this.meta.description = mediaMetadata.description
this.meta.author = author
this.meta.imageUrl = media.coverPath ? `${serverAddress}/feed/${slug}/cover` : `${serverAddress}/Logo.png`
this.meta.imageUrl = media.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png`
this.meta.feedUrl = feedUrl
this.meta.link = `${serverAddress}/item/${libraryItem.id}`
this.meta.explicit = !!mediaMetadata.explicit
@ -145,10 +148,12 @@ class Feed {
this.entityUpdatedAt = libraryItem.updatedAt
this.coverPath = media.coverPath || null
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
this.meta.title = mediaMetadata.title
this.meta.description = mediaMetadata.description
this.meta.author = author
this.meta.imageUrl = media.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover` : `${this.serverAddress}/Logo.png`
this.meta.imageUrl = media.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png`
this.meta.explicit = !!mediaMetadata.explicit
this.meta.type = mediaMetadata.type
this.meta.language = mediaMetadata.language
@ -174,7 +179,7 @@ class Feed {
this.xml = null
}
setFromCollection(userId, slug, collectionExpanded, serverAddress) {
setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
const feedUrl = `${serverAddress}/feed/${slug}`
const itemsWithTracks = collectionExpanded.books.filter(libraryItem => libraryItem.media.tracks.length)
@ -190,14 +195,19 @@ class Feed {
this.serverAddress = serverAddress
this.feedUrl = feedUrl
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
this.meta = new FeedMeta()
this.meta.title = collectionExpanded.name
this.meta.description = collectionExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover` : `${serverAddress}/Logo.png`
this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png`
this.meta.feedUrl = feedUrl
this.meta.link = `${serverAddress}/collection/${collectionExpanded.id}`
this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit
this.meta.preventIndexing = preventIndexing
this.meta.ownerName = ownerName
this.meta.ownerEmail = ownerEmail
this.episodes = []
@ -222,10 +232,12 @@ class Feed {
this.entityUpdatedAt = collectionExpanded.lastUpdate
this.coverPath = firstItemWithCover?.coverPath || null
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
this.meta.title = collectionExpanded.name
this.meta.description = collectionExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover` : `${this.serverAddress}/Logo.png`
this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png`
this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit
this.episodes = []
@ -244,7 +256,7 @@ class Feed {
this.xml = null
}
setFromSeries(userId, slug, seriesExpanded, serverAddress) {
setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
const feedUrl = `${serverAddress}/feed/${slug}`
let itemsWithTracks = seriesExpanded.books.filter(libraryItem => libraryItem.media.tracks.length)
@ -264,14 +276,19 @@ class Feed {
this.serverAddress = serverAddress
this.feedUrl = feedUrl
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
this.meta = new FeedMeta()
this.meta.title = seriesExpanded.name
this.meta.description = seriesExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover` : `${serverAddress}/Logo.png`
this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png`
this.meta.feedUrl = feedUrl
this.meta.link = `${serverAddress}/library/${libraryId}/series/${seriesExpanded.id}`
this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit
this.meta.preventIndexing = preventIndexing
this.meta.ownerName = ownerName
this.meta.ownerEmail = ownerEmail
this.episodes = []
@ -299,10 +316,12 @@ class Feed {
this.entityUpdatedAt = seriesExpanded.updatedAt
this.coverPath = firstItemWithCover?.coverPath || null
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
this.meta.title = seriesExpanded.name
this.meta.description = seriesExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover` : `${this.serverAddress}/Logo.png`
this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png`
this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit
this.episodes = []

View file

@ -1,3 +1,4 @@
const Path = require('path')
const uuidv4 = require("uuid").v4
const date = require('../libs/dateAndTime')
const { secondsToTimestamp } = require('../utils/index')
@ -69,7 +70,8 @@ class FeedEpisode {
}
setFromPodcastEpisode(libraryItem, serverAddress, slug, episode, meta) {
const contentUrl = `/feed/${slug}/item/${episode.id}/${episode.audioFile.metadata.filename}`
const contentFileExtension = Path.extname(episode.audioFile.metadata.filename)
const contentUrl = `/feed/${slug}/item/${episode.id}/media${contentFileExtension}`
const media = libraryItem.media
const mediaMetadata = media.metadata
@ -108,7 +110,8 @@ class FeedEpisode {
// e.g. Track 1 will have a pub date before Track 2
const audiobookPubDate = date.format(new Date(libraryItem.addedAt + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
const contentUrl = `/feed/${slug}/item/${episodeId}/${audioTrack.metadata.filename}`
const contentFileExtension = Path.extname(audioTrack.metadata.filename)
const contentUrl = `/feed/${slug}/item/${episodeId}/media${contentFileExtension}`
const media = libraryItem.media
const mediaMetadata = media.metadata

View file

@ -15,6 +15,9 @@ class Library {
this.provider = 'google'
this.lastScan = 0
this.lastScanVersion = null
this.lastScanMetadataPrecedence = null
this.settings = null
this.createdAt = null
@ -53,6 +56,10 @@ class Library {
this.settings.disableWatcher = !!library.disableWatcher
}
this.lastScan = library.lastScan
this.lastScanVersion = library.lastScanVersion
this.lastScanMetadataPrecedence = library.lastScanMetadataPrecedence
this.createdAt = library.createdAt
this.lastUpdate = library.lastUpdate
this.cleanOldValues() // mediaType changed for v2 and icon change for v2.2.2
@ -84,6 +91,8 @@ class Library {
mediaType: this.mediaType,
provider: this.provider,
settings: this.settings.toJSON(),
lastScan: this.lastScan,
lastScanVersion: this.lastScanVersion,
createdAt: this.createdAt,
lastUpdate: this.lastUpdate
}

View file

@ -1,15 +1,13 @@
const uuidv4 = require("uuid").v4
const fs = require('../libs/fsExtra')
const Path = require('path')
const { version } = require('../../package.json')
const Logger = require('../Logger')
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
const LibraryFile = require('./files/LibraryFile')
const Book = require('./mediaTypes/Book')
const Podcast = require('./mediaTypes/Podcast')
const Video = require('./mediaTypes/Video')
const Music = require('./mediaTypes/Music')
const { areEquivalent, copyValue, cleanStringForSearch } = require('../utils/index')
const { areEquivalent, copyValue } = require('../utils/index')
const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
class LibraryItem {
@ -180,34 +178,23 @@ class LibraryItem {
this.libraryFiles.forEach((lf) => total += lf.metadata.size)
return total
}
get audioFileTotalSize() {
let total = 0
this.libraryFiles.filter(lf => lf.fileType == 'audio').forEach((lf) => total += lf.metadata.size)
return total
}
get hasAudioFiles() {
return this.libraryFiles.some(lf => lf.fileType === 'audio')
}
get hasMediaEntities() {
return this.media.hasMediaEntities
}
get hasIssues() {
if (this.isMissing || this.isInvalid) return true
return this.media.hasIssues
}
// Data comes from scandir library item data
// TODO: Remove this function. Only used when creating a new podcast now
setData(libraryMediaType, payload) {
this.id = uuidv4()
this.mediaType = libraryMediaType
if (libraryMediaType === 'video') {
this.media = new Video()
} else if (libraryMediaType === 'podcast') {
if (libraryMediaType === 'podcast') {
this.media = new Podcast()
} else if (libraryMediaType === 'book') {
this.media = new Book()
} else if (libraryMediaType === 'music') {
this.media = new Music()
} else {
Logger.error(`[LibraryItem] setData called with unsupported media type "${libraryMediaType}"`)
return
}
this.media.id = uuidv4()
this.media.libraryItemId = this.id
@ -270,85 +257,13 @@ class LibraryItem {
this.updatedAt = Date.now()
}
setInvalid() {
this.isInvalid = true
this.updatedAt = Date.now()
}
setLastScan() {
this.lastScan = Date.now()
this.updatedAt = Date.now()
this.scanVersion = version
}
// Returns null if file not found, true if file was updated, false if up to date
// updates existing LibraryFile, AudioFile, EBookFile's
checkFileFound(fileFound) {
let hasUpdated = false
let existingFile = this.libraryFiles.find(lf => lf.ino === fileFound.ino)
let mediaFile = null
if (!existingFile) {
existingFile = this.libraryFiles.find(lf => lf.metadata.path === fileFound.metadata.path)
if (existingFile) {
// Update media file ino
mediaFile = this.media.findFileWithInode(existingFile.ino)
if (mediaFile) {
mediaFile.ino = fileFound.ino
}
// file inode was updated
existingFile.ino = fileFound.ino
hasUpdated = true
} else {
// file not found
return null
}
} else {
mediaFile = this.media.findFileWithInode(existingFile.ino)
}
if (existingFile.metadata.path !== fileFound.metadata.path) {
existingFile.metadata.path = fileFound.metadata.path
existingFile.metadata.relPath = fileFound.metadata.relPath
if (mediaFile) {
mediaFile.metadata.path = fileFound.metadata.path
mediaFile.metadata.relPath = fileFound.metadata.relPath
}
hasUpdated = true
}
// FileMetadata keys
['filename', 'ext', 'mtimeMs', 'ctimeMs', 'birthtimeMs', 'size'].forEach((key) => {
if (existingFile.metadata[key] !== fileFound.metadata[key]) {
// Add modified flag on file data object if exists and was changed
if (key === 'mtimeMs' && existingFile.metadata[key]) {
fileFound.metadata.wasModified = true
}
existingFile.metadata[key] = fileFound.metadata[key]
if (mediaFile) {
if (key === 'mtimeMs') mediaFile.metadata.wasModified = true
mediaFile.metadata[key] = fileFound.metadata[key]
}
hasUpdated = true
}
})
return hasUpdated
}
searchQuery(query) {
query = cleanStringForSearch(query)
return this.media.searchQuery(query)
}
getDirectPlayTracklist(episodeId) {
return this.media.getDirectPlayTracklist(episodeId)
}
/**
* Save metadata.json/metadata.abs file
* Save metadata.json file
* TODO: Move to new LibraryItem model
* @returns {Promise<LibraryFile>} null if not saved
*/
async saveMetadata() {
@ -366,91 +281,41 @@ class LibraryItem {
await fs.ensureDir(metadataPath)
}
const metadataFileFormat = global.ServerSettings.metadataFileFormat
const metadataFilePath = Path.join(metadataPath, `metadata.${metadataFileFormat}`)
if (metadataFileFormat === 'json') {
// Remove metadata.abs if it exists
if (await fs.pathExists(Path.join(metadataPath, `metadata.abs`))) {
Logger.debug(`[LibraryItem] Removing metadata.abs for item "${this.media.metadata.title}"`)
await fs.remove(Path.join(metadataPath, `metadata.abs`))
this.libraryFiles = this.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.abs`)))
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
return fs.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2)).then(async () => {
// Add metadata.json to libraryFiles array if it is new
let metadataLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))
if (storeMetadataWithItem) {
if (!metadataLibraryFile) {
metadataLibraryFile = new LibraryFile()
await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
this.libraryFiles.push(metadataLibraryFile)
} else {
const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)
if (fileTimestamps) {
metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs
metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs
metadataLibraryFile.metadata.size = fileTimestamps.size
metadataLibraryFile.ino = fileTimestamps.ino
}
}
const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path)
if (libraryItemDirTimestamps) {
this.mtimeMs = libraryItemDirTimestamps.mtimeMs
this.ctimeMs = libraryItemDirTimestamps.ctimeMs
}
}
return fs.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2)).then(async () => {
// Add metadata.json to libraryFiles array if it is new
let metadataLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))
if (storeMetadataWithItem) {
if (!metadataLibraryFile) {
metadataLibraryFile = new LibraryFile()
await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
this.libraryFiles.push(metadataLibraryFile)
} else {
const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)
if (fileTimestamps) {
metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs
metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs
metadataLibraryFile.metadata.size = fileTimestamps.size
metadataLibraryFile.ino = fileTimestamps.ino
}
}
const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path)
if (libraryItemDirTimestamps) {
this.mtimeMs = libraryItemDirTimestamps.mtimeMs
this.ctimeMs = libraryItemDirTimestamps.ctimeMs
}
}
Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`)
Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`)
return metadataLibraryFile
}).catch((error) => {
Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error)
return null
}).finally(() => {
this.isSavingMetadata = false
})
} else {
// Remove metadata.json if it exists
if (await fs.pathExists(Path.join(metadataPath, `metadata.json`))) {
Logger.debug(`[LibraryItem] Removing metadata.json for item "${this.media.metadata.title}"`)
await fs.remove(Path.join(metadataPath, `metadata.json`))
this.libraryFiles = this.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.json`)))
}
return abmetadataGenerator.generate(this, metadataFilePath).then(async (success) => {
if (!success) {
Logger.error(`[LibraryItem] Failed saving abmetadata to "${metadataFilePath}"`)
return null
}
// Add metadata.abs to libraryFiles array if it is new
let metadataLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))
if (storeMetadataWithItem) {
if (!metadataLibraryFile) {
metadataLibraryFile = new LibraryFile()
await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.abs`)
this.libraryFiles.push(metadataLibraryFile)
} else {
const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)
if (fileTimestamps) {
metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs
metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs
metadataLibraryFile.metadata.size = fileTimestamps.size
metadataLibraryFile.ino = fileTimestamps.ino
}
}
const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path)
if (libraryItemDirTimestamps) {
this.mtimeMs = libraryItemDirTimestamps.mtimeMs
this.ctimeMs = libraryItemDirTimestamps.ctimeMs
}
}
Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`)
return metadataLibraryFile
}).finally(() => {
this.isSavingMetadata = false
})
}
return metadataLibraryFile
}).catch((error) => {
Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error)
return null
}).finally(() => {
this.isSavingMetadata = false
})
}
removeLibraryFile(ino) {

View file

@ -101,7 +101,6 @@ class Stream extends EventEmitter {
return 'mpegts'
}
get segmentBasename() {
if (this.hlsSegmentType === 'fmp4') return 'output-%d.m4s'
return 'output-%d.ts'
}
get segmentStartNumber() {
@ -142,19 +141,21 @@ class Stream extends EventEmitter {
async checkSegmentNumberRequest(segNum) {
const segStartTime = segNum * this.segmentLength
if (this.startTime > segStartTime) {
Logger.warn(`[STREAM] Segment #${segNum} Request @${secondsToTimestamp(segStartTime)} is before start time (${secondsToTimestamp(this.startTime)}) - Reset Transcode`)
await this.reset(segStartTime - (this.segmentLength * 2))
if (this.segmentStartNumber > segNum) {
Logger.warn(`[STREAM] Segment #${segNum} Request is before starting segment number #${this.segmentStartNumber} - Reset Transcode`)
await this.reset(segStartTime - (this.segmentLength * 5))
return segStartTime
} else if (this.isTranscodeComplete) {
return false
}
const distanceFromFurthestSegment = segNum - this.furthestSegmentCreated
if (distanceFromFurthestSegment > 10) {
Logger.info(`Segment #${segNum} requested is ${distanceFromFurthestSegment} segments from latest (${secondsToTimestamp(segStartTime)}) - Reset Transcode`)
await this.reset(segStartTime - (this.segmentLength * 2))
return segStartTime
if (this.furthestSegmentCreated) {
const distanceFromFurthestSegment = segNum - this.furthestSegmentCreated
if (distanceFromFurthestSegment > 10) {
Logger.info(`Segment #${segNum} requested is ${distanceFromFurthestSegment} segments from latest (${secondsToTimestamp(segStartTime)}) - Reset Transcode`)
await this.reset(segStartTime - (this.segmentLength * 5))
return segStartTime
}
}
return false
@ -171,7 +172,7 @@ class Stream extends EventEmitter {
var files = await fs.readdir(this.streamPath)
files.forEach((file) => {
var extname = Path.extname(file)
if (extname === '.ts' || extname === '.m4s') {
if (extname === '.ts') {
var basename = Path.basename(file, extname)
var num_part = basename.split('-')[1]
var part_num = Number(num_part)
@ -251,6 +252,7 @@ class Stream extends EventEmitter {
Logger.info(`[STREAM] START STREAM - Num Segments: ${this.numSegments}`)
this.ffmpeg = Ffmpeg()
this.furthestSegmentCreated = 0
var adjustedStartTime = Math.max(this.startTime - this.maxSeekBackTime, 0)
var trackStartTime = await writeConcatFile(this.tracks, this.concatFilesPath, adjustedStartTime)
@ -339,9 +341,9 @@ class Stream extends EventEmitter {
} else {
Logger.error('Ffmpeg Err', '"' + err.message + '"')
// Temporary workaround for https://github.com/advplyr/audiobookshelf/issues/172
const aacErrorMsg = 'ffmpeg exited with code 1: Could not write header for output file #0 (incorrect codec parameters ?)'
if (audioCodec === 'copy' && this.isAACEncodable && err.message && err.message.startsWith(aacErrorMsg)) {
// Temporary workaround for https://github.com/advplyr/audiobookshelf/issues/172 and https://github.com/advplyr/audiobookshelf/issues/2157
const aacErrorMsg = 'ffmpeg exited with code 1:'
if (audioCodec === 'copy' && this.isAACEncodable && err.message?.startsWith(aacErrorMsg)) {
Logger.info(`[Stream] Re-attempting stream with AAC encode`)
this.transcodeOptions.forceAAC = true
this.reset(this.startTime)
@ -435,4 +437,4 @@ class Stream extends EventEmitter {
return newAudioTrack
}
}
module.exports = Stream
module.exports = Stream

View file

@ -2,19 +2,30 @@ const uuidv4 = require("uuid").v4
class Task {
constructor() {
/** @type {string} */
this.id = null
/** @type {string} */
this.action = null // e.g. embed-metadata, encode-m4b, etc
/** @type {Object} custom data */
this.data = null // additional info for the action like libraryItemId
/** @type {string} */
this.title = null
/** @type {string} */
this.description = null
/** @type {string} */
this.error = null
this.showSuccess = false // If true client side should keep the task visible after success
/** @type {boolean} client should keep the task visible after success */
this.showSuccess = false
/** @type {boolean} */
this.isFailed = false
/** @type {boolean} */
this.isFinished = false
/** @type {number} */
this.startedAt = null
/** @type {number} */
this.finishedAt = null
}
@ -34,6 +45,15 @@ class Task {
}
}
/**
* Set initial task data
*
* @param {string} action
* @param {string} title
* @param {string} description
* @param {boolean} showSuccess
* @param {Object} [data]
*/
setData(action, title, description, showSuccess, data = {}) {
this.id = uuidv4()
this.action = action
@ -44,6 +64,11 @@ class Task {
this.startedAt = Date.now()
}
/**
* Set task as failed
*
* @param {string} message error message
*/
setFailed(message) {
this.error = message
this.isFailed = true
@ -51,6 +76,11 @@ class Task {
this.setFinished()
}
/**
* Set task as finished
*
* @param {string} [newDescription] update description
*/
setFinished(newDescription = null) {
if (newDescription) {
this.description = newDescription

View file

@ -1,7 +1,5 @@
const uuidv4 = require("uuid").v4
const Path = require('path')
const Logger = require('../../Logger')
const { cleanStringForSearch, areEquivalent, copyValue } = require('../../utils/index')
const { areEquivalent, copyValue } = require('../../utils/index')
const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack')
@ -20,6 +18,7 @@ class PodcastEpisode {
this.subtitle = null
this.description = null
this.enclosure = null
this.guid = null
this.pubDate = null
this.chapters = []
@ -46,6 +45,7 @@ class PodcastEpisode {
this.subtitle = episode.subtitle
this.description = episode.description
this.enclosure = episode.enclosure ? { ...episode.enclosure } : null
this.guid = episode.guid || null
this.pubDate = episode.pubDate
this.chapters = episode.chapters?.map(ch => ({ ...ch })) || []
this.audioFile = new AudioFile(episode.audioFile)
@ -70,6 +70,7 @@ class PodcastEpisode {
subtitle: this.subtitle,
description: this.description,
enclosure: this.enclosure ? { ...this.enclosure } : null,
guid: this.guid,
pubDate: this.pubDate,
chapters: this.chapters.map(ch => ({ ...ch })),
audioFile: this.audioFile.toJSON(),
@ -93,6 +94,7 @@ class PodcastEpisode {
subtitle: this.subtitle,
description: this.description,
enclosure: this.enclosure ? { ...this.enclosure } : null,
guid: this.guid,
pubDate: this.pubDate,
chapters: this.chapters.map(ch => ({ ...ch })),
audioFile: this.audioFile.toJSON(),
@ -133,6 +135,7 @@ class PodcastEpisode {
this.pubDate = data.pubDate || ''
this.description = data.description || ''
this.enclosure = data.enclosure ? { ...data.enclosure } : null
this.guid = data.guid || null
this.season = data.season || ''
this.episode = data.episode || ''
this.episodeType = data.episodeType || 'full'
@ -141,19 +144,6 @@ class PodcastEpisode {
this.updatedAt = Date.now()
}
setDataFromAudioFile(audioFile, index) {
this.id = uuidv4()
this.audioFile = audioFile
this.title = Path.basename(audioFile.metadata.filename, Path.extname(audioFile.metadata.filename))
this.index = index
this.setDataFromAudioMetaTags(audioFile.metaTags, true)
this.chapters = audioFile.chapters?.map((c) => ({ ...c }))
this.addedAt = Date.now()
this.updatedAt = Date.now()
}
update(payload) {
let hasUpdates = false
for (const key in this.toJSON()) {
@ -187,80 +177,5 @@ class PodcastEpisode {
if (!this.enclosure || !this.enclosure.url) return false
return this.enclosure.url == url
}
searchQuery(query) {
return cleanStringForSearch(this.title).includes(query)
}
setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) {
if (!audioFileMetaTags) return false
const MetadataMapArray = [
{
tag: 'tagComment',
altTag: 'tagSubtitle',
key: 'description'
},
{
tag: 'tagSubtitle',
key: 'subtitle'
},
{
tag: 'tagDate',
key: 'pubDate'
},
{
tag: 'tagDisc',
key: 'season',
},
{
tag: 'tagTrack',
altTag: 'tagSeriesPart',
key: 'episode'
},
{
tag: 'tagTitle',
key: 'title'
},
{
tag: 'tagEpisodeType',
key: 'episodeType'
}
]
MetadataMapArray.forEach((mapping) => {
let value = audioFileMetaTags[mapping.tag]
let tagToUse = mapping.tag
if (!value && mapping.altTag) {
tagToUse = mapping.altTag
value = audioFileMetaTags[mapping.altTag]
}
if (value && typeof value === 'string') {
value = value.trim() // Trim whitespace
if (mapping.key === 'pubDate' && (!this.pubDate || overrideExistingDetails)) {
const pubJsDate = new Date(value)
if (pubJsDate && !isNaN(pubJsDate)) {
this.publishedAt = pubJsDate.valueOf()
this.pubDate = value
Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`)
} else {
Logger.warn(`[PodcastEpisode] Mapping pubDate with tag ${tagToUse} has invalid date "${value}"`)
}
} else if (mapping.key === 'episodeType' && (!this.episodeType || overrideExistingDetails)) {
if (['full', 'trailer', 'bonus'].includes(value)) {
this.episodeType = value
Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`)
} else {
Logger.warn(`[PodcastEpisode] Mapping episodeType with invalid value "${value}". Must be one of [full, trailer, bonus].`)
}
} else if (!this[mapping.key] || overrideExistingDetails) {
this[mapping.key] = value
Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`)
}
}
})
}
}
module.exports = PodcastEpisode

View file

@ -1,11 +1,7 @@
const Path = require('path')
const Logger = require('../../Logger')
const BookMetadata = require('../metadata/BookMetadata')
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
const { parseOpfMetadataXML } = require('../../utils/parsers/parseOpfMetadata')
const { parseOverdriveMediaMarkersAsChapters } = require('../../utils/parsers/parseOverdriveMediaMarkers')
const abmetadataGenerator = require('../../utils/generators/abmetadataGenerator')
const { readTextFile, filePathToPOSIX } = require('../../utils/fileUtils')
const { areEquivalent, copyValue } = require('../../utils/index')
const { filePathToPOSIX } = require('../../utils/fileUtils')
const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack')
const EBookFile = require('../files/EBookFile')
@ -98,7 +94,7 @@ class Book {
return {
tags: [...this.tags],
chapters: this.chapters.map(c => ({ ...c })),
metadata: this.metadata.toJSONForMetadataFile()
...this.metadata.toJSONForMetadataFile()
}
}
@ -113,23 +109,12 @@ class Book {
get hasMediaEntities() {
return !!this.tracks.length || this.ebookFile
}
get shouldSearchForCover() {
if (this.coverPath) return false
if (!this.lastCoverSearch || this.metadata.coverSearchQuery !== this.lastCoverSearchQuery) return true
return (Date.now() - this.lastCoverSearch) > 1000 * 60 * 60 * 24 * 7 // 7 day
}
get hasEmbeddedCoverArt() {
return this.audioFiles.some(af => af.embeddedCoverArt)
}
get invalidAudioFiles() {
return this.audioFiles.filter(af => af.invalid)
}
get includedAudioFiles() {
return this.audioFiles.filter(af => !af.exclude && !af.invalid)
}
get hasIssues() {
return this.missingParts.length || this.invalidAudioFiles.length
}
get tracks() {
let startOffset = 0
return this.includedAudioFiles.map((af) => {
@ -228,159 +213,6 @@ class Book {
return null
}
updateLastCoverSearch(coverWasFound) {
this.lastCoverSearch = coverWasFound ? null : Date.now()
this.lastCoverSearchQuery = coverWasFound ? null : this.metadata.coverSearchQuery
}
// Audio file metadata tags map to book details (will not overwrite)
setMetadataFromAudioFile(overrideExistingDetails = false) {
if (!this.audioFiles.length) return false
var audioFile = this.audioFiles[0]
if (!audioFile.metaTags) return false
return this.metadata.setDataFromAudioMetaTags(audioFile.metaTags, overrideExistingDetails)
}
setData(mediaPayload) {
this.metadata = new BookMetadata()
if (mediaPayload.metadata) {
this.metadata.setData(mediaPayload.metadata)
}
}
// Look for desc.txt, reader.txt, metadata.abs and opf file then update details if found
async syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) {
let metadataUpdatePayload = {}
let hasUpdated = false
const descTxt = textMetadataFiles.find(lf => lf.metadata.filename === 'desc.txt')
if (descTxt) {
const descriptionText = await readTextFile(descTxt.metadata.path)
if (descriptionText) {
Logger.debug(`[Book] "${this.metadata.title}" found desc.txt updating description with "${descriptionText.slice(0, 20)}..."`)
metadataUpdatePayload.description = descriptionText
}
}
const readerTxt = textMetadataFiles.find(lf => lf.metadata.filename === 'reader.txt')
if (readerTxt) {
const narratorText = await readTextFile(readerTxt.metadata.path)
if (narratorText) {
Logger.debug(`[Book] "${this.metadata.title}" found reader.txt updating narrator with "${narratorText}"`)
metadataUpdatePayload.narrators = this.metadata.parseNarratorsTag(narratorText)
}
}
const metadataIsJSON = global.ServerSettings.metadataFileFormat === 'json'
const metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs')
const metadataJson = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.json')
const metadataFile = metadataIsJSON ? metadataJson : metadataAbs
if (metadataFile) {
Logger.debug(`[Book] Found ${metadataFile.metadata.filename} file for "${this.metadata.title}"`)
const metadataText = await readTextFile(metadataFile.metadata.path)
const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'book', metadataIsJSON)
if (abmetadataUpdates && Object.keys(abmetadataUpdates).length) {
Logger.debug(`[Book] "${this.metadata.title}" changes found in metadata.abs file`, abmetadataUpdates)
if (abmetadataUpdates.tags) { // Set media tags if updated
this.tags = abmetadataUpdates.tags
hasUpdated = true
}
if (abmetadataUpdates.chapters) { // Set chapters if updated
this.chapters = abmetadataUpdates.chapters
hasUpdated = true
}
if (abmetadataUpdates.metadata) {
metadataUpdatePayload = {
...metadataUpdatePayload,
...abmetadataUpdates.metadata
}
}
}
} else if (metadataAbs || metadataJson) { // Has different metadata file format so mark as updated
Logger.debug(`[Book] Found different format metadata file ${(metadataAbs || metadataJson).metadata.filename}, expecting .${global.ServerSettings.metadataFileFormat} for "${this.metadata.title}"`)
hasUpdated = true
}
const metadataOpf = textMetadataFiles.find(lf => lf.isOPFFile || lf.metadata.filename === 'metadata.xml')
if (metadataOpf) {
const xmlText = await readTextFile(metadataOpf.metadata.path)
if (xmlText) {
const opfMetadata = await parseOpfMetadataXML(xmlText)
if (opfMetadata) {
for (const key in opfMetadata) {
if (key === 'tags') { // Add tags only if tags are empty
if (opfMetadata.tags.length && (!this.tags.length || opfMetadataOverrideDetails)) {
this.tags = opfMetadata.tags
hasUpdated = true
}
} else if (key === 'genres') { // Add genres only if genres are empty
if (opfMetadata.genres.length && (!this.metadata.genres.length || opfMetadataOverrideDetails)) {
metadataUpdatePayload[key] = opfMetadata.genres
}
} else if (key === 'authors') {
if (opfMetadata.authors && opfMetadata.authors.length && (!this.metadata.authors.length || opfMetadataOverrideDetails)) {
metadataUpdatePayload.authors = opfMetadata.authors.map(authorName => {
return {
id: `new-${Math.floor(Math.random() * 1000000)}`,
name: authorName
}
})
}
} else if (key === 'narrators') {
if (opfMetadata.narrators?.length && (!this.metadata.narrators.length || opfMetadataOverrideDetails)) {
metadataUpdatePayload.narrators = opfMetadata.narrators
}
} else if (key === 'series') {
if (opfMetadata.series && (!this.metadata.series.length || opfMetadataOverrideDetails)) {
metadataUpdatePayload.series = this.metadata.parseSeriesTag(opfMetadata.series, opfMetadata.sequence)
}
} else if (opfMetadata[key] && ((!this.metadata[key] && !metadataUpdatePayload[key]) || opfMetadataOverrideDetails)) {
metadataUpdatePayload[key] = opfMetadata[key]
}
}
}
}
}
if (Object.keys(metadataUpdatePayload).length) {
return this.metadata.update(metadataUpdatePayload) || hasUpdated
}
return hasUpdated
}
searchQuery(query) {
const payload = {
tags: this.tags.filter(t => cleanStringForSearch(t).includes(query)),
series: this.metadata.searchSeries(query),
authors: this.metadata.searchAuthors(query),
narrators: this.metadata.searchNarrators(query),
matchKey: null,
matchText: null
}
const metadataMatch = this.metadata.searchQuery(query)
if (metadataMatch) {
payload.matchKey = metadataMatch.matchKey
payload.matchText = metadataMatch.matchText
} else {
if (payload.authors.length) {
payload.matchKey = 'authors'
payload.matchText = this.metadata.authorName
} else if (payload.series.length) {
payload.matchKey = 'series'
payload.matchText = this.metadata.seriesName
} else if (payload.tags.length) {
payload.matchKey = 'tags'
payload.matchText = this.tags.join(', ')
} else if (payload.narrators.length) {
payload.matchKey = 'narrators'
payload.matchText = this.metadata.narratorName
}
}
return payload
}
/**
* Set the EBookFile from a LibraryFile
* If null then ebookFile will be removed from the book
@ -426,113 +258,6 @@ class Book {
Logger.debug(`[Book] Tracks being rebuilt...!`)
this.audioFiles.sort((a, b) => a.index - b.index)
this.missingParts = []
this.setChapters()
this.checkUpdateMissingTracks()
}
checkUpdateMissingTracks() {
var currMissingParts = (this.missingParts || []).join(',') || ''
var current_index = 1
var missingParts = []
for (let i = 0; i < this.tracks.length; i++) {
var _track = this.tracks[i]
if (_track.index > current_index) {
var num_parts_missing = _track.index - current_index
for (let x = 0; x < num_parts_missing && x < 9999; x++) {
missingParts.push(current_index + x)
}
}
current_index = _track.index + 1
}
this.missingParts = missingParts
var newMissingParts = (this.missingParts || []).join(',') || ''
var wasUpdated = newMissingParts !== currMissingParts
if (wasUpdated && this.missingParts.length) {
Logger.info(`[Audiobook] "${this.metadata.title}" has ${missingParts.length} missing parts`)
}
return wasUpdated
}
setChapters() {
const preferOverdriveMediaMarker = !!global.ServerSettings.scannerPreferOverdriveMediaMarker
// If 1 audio file without chapters, then no chapters will be set
const includedAudioFiles = this.audioFiles.filter(af => !af.exclude)
if (!includedAudioFiles.length) return
// If overdrive media markers are present and preferred, use those instead
if (preferOverdriveMediaMarker) {
const overdriveChapters = parseOverdriveMediaMarkersAsChapters(includedAudioFiles)
if (overdriveChapters) {
Logger.info('[Book] Overdrive Media Markers and preference found! Using these for chapter definitions')
this.chapters = overdriveChapters
return
}
}
// If first audio file has embedded chapters then use embedded chapters
if (includedAudioFiles[0].chapters?.length) {
// If all files chapters are the same, then only make chapters for the first file
if (
includedAudioFiles.length === 1 ||
includedAudioFiles.length > 1 &&
includedAudioFiles[0].chapters.length === includedAudioFiles[1].chapters?.length &&
includedAudioFiles[0].chapters.every((c, i) => c.title === includedAudioFiles[1].chapters[i].title)
) {
Logger.debug(`[Book] setChapters: Using embedded chapters in first audio file ${includedAudioFiles[0].metadata?.path}`)
this.chapters = includedAudioFiles[0].chapters.map((c) => ({ ...c }))
} else {
Logger.debug(`[Book] setChapters: Using embedded chapters from all audio files ${includedAudioFiles[0].metadata?.path}`)
this.chapters = []
let currChapterId = 0
let currStartTime = 0
includedAudioFiles.forEach((file) => {
if (file.duration) {
const chapters = file.chapters?.map((c) => ({
...c,
id: c.id + currChapterId,
start: c.start + currStartTime,
end: c.end + currStartTime,
})) ?? []
this.chapters = this.chapters.concat(chapters)
currChapterId += file.chapters?.length ?? 0
currStartTime += file.duration
}
})
}
} else if (includedAudioFiles.length > 1) {
const preferAudioMetadata = !!global.ServerSettings.scannerPreferAudioMetadata
// Build chapters from audio files
this.chapters = []
let currChapterId = 0
let currStartTime = 0
includedAudioFiles.forEach((file) => {
if (file.duration) {
let title = file.metadata.filename ? Path.basename(file.metadata.filename, Path.extname(file.metadata.filename)) : `Chapter ${currChapterId}`
// When prefer audio metadata server setting is set then use ID3 title tag as long as it is not the same as the book title
if (preferAudioMetadata && file.metaTags?.tagTitle && file.metaTags?.tagTitle !== this.metadata.title) {
title = file.metaTags.tagTitle
}
this.chapters.push({
id: currChapterId++,
start: currStartTime,
end: currStartTime + file.duration,
title
})
currStartTime += file.duration
}
})
}
}
// Only checks container format

View file

@ -65,15 +65,6 @@ class Music {
get hasMediaEntities() {
return !!this.audioFile
}
get shouldSearchForCover() {
return false
}
get hasEmbeddedCoverArt() {
return this.audioFile.embeddedCoverArt
}
get hasIssues() {
return false
}
get duration() {
return this.audioFile.duration || 0
}
@ -134,20 +125,6 @@ class Music {
this.audioFile = audioFile
}
setMetadataFromAudioFile(overrideExistingDetails = false) {
if (!this.audioFile) return false
if (!this.audioFile.metaTags) return false
return this.metadata.setDataFromAudioMetaTags(this.audioFile.metaTags, overrideExistingDetails)
}
syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) {
return false
}
searchQuery(query) {
return {}
}
// Only checks container format
checkCanDirectPlay(payload) {
return true

View file

@ -1,9 +1,8 @@
const Logger = require('../../Logger')
const PodcastEpisode = require('../entities/PodcastEpisode')
const PodcastMetadata = require('../metadata/PodcastMetadata')
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
const abmetadataGenerator = require('../../utils/generators/abmetadataGenerator')
const { readTextFile, filePathToPOSIX } = require('../../utils/fileUtils')
const { areEquivalent, copyValue } = require('../../utils/index')
const { filePathToPOSIX } = require('../../utils/fileUtils')
class Podcast {
constructor(podcast) {
@ -98,7 +97,19 @@ class Podcast {
toJSONForMetadataFile() {
return {
tags: [...this.tags],
metadata: this.metadata.toJSON()
title: this.metadata.title,
author: this.metadata.author,
description: this.metadata.description,
releaseDate: this.metadata.releaseDate,
genres: [...this.metadata.genres],
feedURL: this.metadata.feedUrl,
imageURL: this.metadata.imageUrl,
itunesPageURL: this.metadata.itunesPageUrl,
itunesId: this.metadata.itunesId,
itunesArtistId: this.metadata.itunesArtistId,
explicit: this.metadata.explicit,
language: this.metadata.language,
podcastType: this.metadata.type
}
}
@ -110,15 +121,6 @@ class Podcast {
get hasMediaEntities() {
return !!this.episodes.length
}
get shouldSearchForCover() {
return false
}
get hasEmbeddedCoverArt() {
return this.episodes.some(ep => ep.audioFile.embeddedCoverArt)
}
get hasIssues() {
return false
}
get duration() {
let total = 0
this.episodes.forEach((ep) => total += ep.duration)
@ -187,10 +189,6 @@ class Podcast {
return null
}
findEpisodeWithInode(inode) {
return this.episodes.find(ep => ep.audioFile.ino === inode)
}
setData(mediaData) {
this.metadata = new PodcastMetadata()
if (mediaData.metadata) {
@ -203,62 +201,6 @@ class Podcast {
this.lastEpisodeCheck = Date.now() // Makes sure new episodes are after this
}
async syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) {
let metadataUpdatePayload = {}
let tagsUpdated = false
const metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs' || lf.metadata.filename === 'metadata.json')
if (metadataAbs) {
const isJSON = metadataAbs.metadata.filename === 'metadata.json'
const metadataText = await readTextFile(metadataAbs.metadata.path)
const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'podcast', isJSON)
if (abmetadataUpdates && Object.keys(abmetadataUpdates).length) {
Logger.debug(`[Podcast] "${this.metadata.title}" changes found in metadata.abs file`, abmetadataUpdates)
if (abmetadataUpdates.tags) { // Set media tags if updated
this.tags = abmetadataUpdates.tags
tagsUpdated = true
}
if (abmetadataUpdates.metadata) {
metadataUpdatePayload = {
...metadataUpdatePayload,
...abmetadataUpdates.metadata
}
}
}
}
if (Object.keys(metadataUpdatePayload).length) {
return this.metadata.update(metadataUpdatePayload) || tagsUpdated
}
return tagsUpdated
}
searchEpisodes(query) {
return this.episodes.filter(ep => ep.searchQuery(query))
}
searchQuery(query) {
const payload = {
tags: this.tags.filter(t => cleanStringForSearch(t).includes(query)),
matchKey: null,
matchText: null
}
const metadataMatch = this.metadata.searchQuery(query)
if (metadataMatch) {
payload.matchKey = metadataMatch.matchKey
payload.matchText = metadataMatch.matchText
} else {
const matchingEpisodes = this.searchEpisodes(query)
if (matchingEpisodes.length) {
payload.matchKey = 'episode'
payload.matchText = matchingEpisodes[0].title
}
}
return payload
}
checkHasEpisode(episodeId) {
return this.episodes.some(ep => ep.id === episodeId)
}
@ -325,14 +267,6 @@ class Podcast {
return this.episodes.find(ep => ep.id == episodeId)
}
// Audio file metadata tags map to podcast details
setMetadataFromAudioFile(overrideExistingDetails = false) {
if (!this.episodes.length) return false
const audioFile = this.episodes[0].audioFile
if (!audioFile?.metaTags) return false
return this.metadata.setDataFromAudioMetaTags(audioFile.metaTags, overrideExistingDetails)
}
getChapters(episodeId) {
return this.getEpisode(episodeId)?.chapters?.map(ch => ({ ...ch })) || []
}

View file

@ -69,15 +69,6 @@ class Video {
get hasMediaEntities() {
return true
}
get shouldSearchForCover() {
return false
}
get hasEmbeddedCoverArt() {
return false
}
get hasIssues() {
return false
}
get duration() {
return 0
}

View file

@ -1,5 +1,5 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue, cleanStringForSearch, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
const parseNameString = require('../../utils/parsers/parseNameString')
class BookMetadata {
constructor(metadata) {
@ -144,20 +144,6 @@ class BookMetadata {
return `${se.name} #${se.sequence}`
}).join(', ')
}
get seriesNameIgnorePrefix() {
if (!this.series.length) return ''
return this.series.map(se => {
if (!se.sequence) return getTitleIgnorePrefix(se.name)
return `${getTitleIgnorePrefix(se.name)} #${se.sequence}`
}).join(', ')
}
get seriesNamePrefixAtEnd() {
if (!this.series.length) return ''
return this.series.map(se => {
if (!se.sequence) return getTitlePrefixAtEnd(se.name)
return `${getTitlePrefixAtEnd(se.name)} #${se.sequence}`
}).join(', ')
}
get firstSeriesName() {
if (!this.series.length) return ''
return this.series[0].name
@ -169,36 +155,15 @@ class BookMetadata {
get narratorName() {
return this.narrators.join(', ')
}
get coverSearchQuery() {
if (!this.authorName) return this.title
return this.title + '&' + this.authorName
}
hasAuthor(id) {
return !!this.authors.find(au => au.id == id)
}
hasSeries(seriesId) {
return !!this.series.find(se => se.id == seriesId)
}
hasNarrator(narratorName) {
return this.narrators.includes(narratorName)
}
getSeries(seriesId) {
return this.series.find(se => se.id == seriesId)
}
getFirstSeries() {
return this.series.length ? this.series[0] : null
}
getSeriesSequence(seriesId) {
const series = this.series.find(se => se.id == seriesId)
if (!series) return null
return series.sequence || ''
}
getSeriesSortTitle(series) {
if (!series) return ''
if (!series.sequence) return series.name
return `${series.name} #${series.sequence}`
}
update(payload) {
const json = this.toJSON()
@ -231,205 +196,5 @@ class BookMetadata {
name: newAuthor.name
})
}
/**
* Update narrator name if narrator is in book
* @param {String} oldNarratorName - Narrator name to get updated
* @param {String} newNarratorName - Updated narrator name
* @return {Boolean} True if narrator was updated
*/
updateNarrator(oldNarratorName, newNarratorName) {
if (!this.hasNarrator(oldNarratorName)) return false
this.narrators = this.narrators.filter(n => n !== oldNarratorName)
if (newNarratorName && !this.hasNarrator(newNarratorName)) {
this.narrators.push(newNarratorName)
}
return true
}
/**
* Remove narrator name if narrator is in book
* @param {String} narratorName - Narrator name to remove
* @return {Boolean} True if narrator was updated
*/
removeNarrator(narratorName) {
if (!this.hasNarrator(narratorName)) return false
this.narrators = this.narrators.filter(n => n !== narratorName)
return true
}
setData(scanMediaData = {}) {
this.title = scanMediaData.title || null
this.subtitle = scanMediaData.subtitle || null
this.narrators = this.parseNarratorsTag(scanMediaData.narrators)
this.publishedYear = scanMediaData.publishedYear || null
this.description = scanMediaData.description || null
this.isbn = scanMediaData.isbn || null
this.asin = scanMediaData.asin || null
this.language = scanMediaData.language || null
this.genres = []
this.explicit = !!scanMediaData.explicit
if (scanMediaData.author) {
this.authors = this.parseAuthorsTag(scanMediaData.author)
}
if (scanMediaData.series) {
this.series = this.parseSeriesTag(scanMediaData.series, scanMediaData.sequence)
}
}
setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) {
const MetadataMapArray = [
{
tag: 'tagComposer',
key: 'narrators'
},
{
tag: 'tagDescription',
altTag: 'tagComment',
key: 'description'
},
{
tag: 'tagPublisher',
key: 'publisher'
},
{
tag: 'tagDate',
key: 'publishedYear'
},
{
tag: 'tagSubtitle',
key: 'subtitle'
},
{
tag: 'tagAlbum',
altTag: 'tagTitle',
key: 'title',
},
{
tag: 'tagArtist',
altTag: 'tagAlbumArtist',
key: 'authors'
},
{
tag: 'tagGenre',
key: 'genres'
},
{
tag: 'tagSeries',
key: 'series'
},
{
tag: 'tagIsbn',
key: 'isbn'
},
{
tag: 'tagLanguage',
key: 'language'
},
{
tag: 'tagASIN',
key: 'asin'
}
]
const updatePayload = {}
// Metadata is only mapped to the book if it is empty
MetadataMapArray.forEach((mapping) => {
let value = audioFileMetaTags[mapping.tag]
// let tagToUse = mapping.tag
if (!value && mapping.altTag) {
value = audioFileMetaTags[mapping.altTag]
// tagToUse = mapping.altTag
}
if (value && typeof value === 'string') {
value = value.trim() // Trim whitespace
if (mapping.key === 'narrators' && (!this.narrators.length || overrideExistingDetails)) {
updatePayload.narrators = this.parseNarratorsTag(value)
} else if (mapping.key === 'authors' && (!this.authors.length || overrideExistingDetails)) {
updatePayload.authors = this.parseAuthorsTag(value)
} else if (mapping.key === 'genres' && (!this.genres.length || overrideExistingDetails)) {
updatePayload.genres = this.parseGenresTag(value)
} else if (mapping.key === 'series' && (!this.series.length || overrideExistingDetails)) {
const sequenceTag = audioFileMetaTags.tagSeriesPart || null
updatePayload.series = this.parseSeriesTag(value, sequenceTag)
} else if (!this[mapping.key] || overrideExistingDetails) {
updatePayload[mapping.key] = value
// Logger.debug(`[Book] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload[mapping.key]}`)
}
}
})
if (Object.keys(updatePayload).length) {
return this.update(updatePayload)
}
return false
}
// Returns array of names in First Last format
parseNarratorsTag(narratorsTag) {
const parsed = parseNameString.parse(narratorsTag)
return parsed ? parsed.names : []
}
// Return array of authors minified with placeholder id
parseAuthorsTag(authorsTag) {
const parsed = parseNameString.parse(authorsTag)
if (!parsed) return []
return (parsed.names || []).map((au) => {
const findAuthor = this.authors.find(_au => _au.name == au)
return {
id: findAuthor?.id || `new-${Math.floor(Math.random() * 1000000)}`,
name: au
}
})
}
parseGenresTag(genreTag) {
if (!genreTag || !genreTag.length) return []
const separators = ['/', '//', ';']
for (let i = 0; i < separators.length; i++) {
if (genreTag.includes(separators[i])) {
return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g)
}
}
return [genreTag]
}
// Return array with series with placeholder id
parseSeriesTag(seriesTag, sequenceTag) {
if (!seriesTag) return []
return [{
id: `new-${Math.floor(Math.random() * 1000000)}`,
name: seriesTag,
sequence: sequenceTag || ''
}]
}
searchSeries(query) {
return this.series.filter(se => cleanStringForSearch(se.name).includes(query))
}
searchAuthors(query) {
return this.authors.filter(au => cleanStringForSearch(au.name).includes(query))
}
searchNarrators(query) {
return this.narrators.filter(n => cleanStringForSearch(n).includes(query))
}
searchQuery(query) { // Returns key if match is found
const keysToCheck = ['title', 'asin', 'isbn', 'subtitle']
for (const key of keysToCheck) {
if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) {
return {
matchKey: key,
matchText: this[key]
}
}
}
return null
}
}
module.exports = BookMetadata

View file

@ -1,5 +1,5 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue, cleanStringForSearch, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
class MusicMetadata {
constructor(metadata) {
@ -133,19 +133,6 @@ class MusicMetadata {
return getTitlePrefixAtEnd(this.title)
}
searchQuery(query) { // Returns key if match is found
const keysToCheck = ['title', 'album']
for (const key of keysToCheck) {
if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) {
return {
matchKey: key,
matchText: this[key]
}
}
}
return null
}
setData(mediaMetadata = {}) {
this.title = mediaMetadata.title || null
this.artist = mediaMetadata.artist || null

View file

@ -1,5 +1,5 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue, cleanStringForSearch, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
class PodcastMetadata {
constructor(metadata) {
@ -91,19 +91,6 @@ class PodcastMetadata {
return getTitlePrefixAtEnd(this.title)
}
searchQuery(query) { // Returns key if match is found
const keysToCheck = ['title', 'author', 'itunesId', 'itunesArtistId']
for (const key of keysToCheck) {
if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) {
return {
matchKey: key,
matchText: this[key]
}
}
}
return null
}
setData(mediaMetadata = {}) {
this.title = mediaMetadata.title || null
this.author = mediaMetadata.author || null
@ -136,74 +123,5 @@ class PodcastMetadata {
}
return hasUpdates
}
setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) {
const MetadataMapArray = [
{
tag: 'tagAlbum',
altTag: 'tagSeries',
key: 'title'
},
{
tag: 'tagArtist',
key: 'author'
},
{
tag: 'tagGenre',
key: 'genres'
},
{
tag: 'tagLanguage',
key: 'language'
},
{
tag: 'tagItunesId',
key: 'itunesId'
},
{
tag: 'tagPodcastType',
key: 'type',
}
]
const updatePayload = {}
MetadataMapArray.forEach((mapping) => {
let value = audioFileMetaTags[mapping.tag]
let tagToUse = mapping.tag
if (!value && mapping.altTag) {
value = audioFileMetaTags[mapping.altTag]
tagToUse = mapping.altTag
}
if (value && typeof value === 'string') {
value = value.trim() // Trim whitespace
if (mapping.key === 'genres' && (!this.genres.length || overrideExistingDetails)) {
updatePayload.genres = this.parseGenresTag(value)
Logger.debug(`[Podcast] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload.genres.join(', ')}`)
} else if (!this[mapping.key] || overrideExistingDetails) {
updatePayload[mapping.key] = value
Logger.debug(`[Podcast] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload[mapping.key]}`)
}
}
})
if (Object.keys(updatePayload).length) {
return this.update(updatePayload)
}
return false
}
parseGenresTag(genreTag) {
if (!genreTag || !genreTag.length) return []
const separators = ['/', '//', ';']
for (let i = 0; i < separators.length; i++) {
if (genreTag.includes(separators[i])) {
return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g)
}
}
return [genreTag]
}
}
module.exports = PodcastMetadata

View file

@ -55,19 +55,6 @@ class VideoMetadata {
return getTitlePrefixAtEnd(this.title)
}
searchQuery(query) { // Returns key if match is found
var keysToCheck = ['title']
for (var key of keysToCheck) {
if (this[key] && String(this[key]).toLowerCase().includes(query)) {
return {
matchKey: key,
matchText: this[key]
}
}
}
return null
}
setData(mediaMetadata = {}) {
this.title = mediaMetadata.title || null
this.description = mediaMetadata.description || null

View file

@ -1,6 +1,14 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue, isNullOrNaN } = require('../../utils')
/**
* @typedef EreaderDeviceObject
* @property {string} name
* @property {string} email
* @property {string} availabilityOption
* @property {string[]} users
*/
// REF: https://nodemailer.com/smtp/
class EmailSettings {
constructor(settings = null) {
@ -13,7 +21,7 @@ class EmailSettings {
this.testAddress = null
this.fromAddress = null
// Array of { name:String, email:String }
/** @type {EreaderDeviceObject[]} */
this.ereaderDevices = []
if (settings) {
@ -57,6 +65,26 @@ class EmailSettings {
if (payload.ereaderDevices !== undefined && !Array.isArray(payload.ereaderDevices)) payload.ereaderDevices = undefined
if (payload.ereaderDevices?.length) {
// Validate ereader devices
payload.ereaderDevices = payload.ereaderDevices.map((device) => {
if (!device.name || !device.email) {
Logger.error(`[EmailSettings] Update ereader device is invalid`, device)
return null
}
if (!device.availabilityOption || !['adminOrUp', 'userOrUp', 'guestOrUp', 'specificUsers'].includes(device.availabilityOption)) {
device.availabilityOption = 'adminOrUp'
}
if (device.availabilityOption === 'specificUsers' && !device.users?.length) {
device.availabilityOption = 'adminOrUp'
}
if (device.availabilityOption !== 'specificUsers' && device.users?.length) {
device.users = []
}
return device
}).filter(d => d)
}
let hasUpdates = false
const json = this.toJSON()
@ -88,15 +116,40 @@ class EmailSettings {
return payload
}
getEReaderDevices(user) {
// Only accessible to admin or up
if (!user.isAdminOrUp) {
return []
/**
*
* @param {EreaderDeviceObject} device
* @param {import('../user/User')} user
* @returns {boolean}
*/
checkUserCanAccessDevice(device, user) {
let deviceAvailability = device.availabilityOption || 'adminOrUp'
if (deviceAvailability === 'adminOrUp' && user.isAdminOrUp) return true
if (deviceAvailability === 'userOrUp' && (user.isAdminOrUp || user.isUser)) return true
if (deviceAvailability === 'guestOrUp') return true
if (deviceAvailability === 'specificUsers') {
let deviceUsers = device.users || []
return deviceUsers.includes(user.id)
}
return this.ereaderDevices.map(d => ({ ...d }))
return false
}
/**
* Get ereader devices accessible to user
*
* @param {import('../user/User')} user
* @returns {EreaderDeviceObject[]}
*/
getEReaderDevices(user) {
return this.ereaderDevices.filter((device) => this.checkUserCanAccessDevice(device, user))
}
/**
* Get ereader device by name
*
* @param {string} deviceName
* @returns {EreaderDeviceObject}
*/
getEReaderDevice(deviceName) {
return this.ereaderDevices.find(d => d.name === deviceName)
}

View file

@ -9,6 +9,7 @@ class LibrarySettings {
this.autoScanCronExpression = null
this.audiobooksOnly = false
this.hideSingleBookSeries = false // Do not show series that only have 1 book
this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata']
if (settings) {
this.construct(settings)
@ -23,6 +24,12 @@ class LibrarySettings {
this.autoScanCronExpression = settings.autoScanCronExpression || null
this.audiobooksOnly = !!settings.audiobooksOnly
this.hideSingleBookSeries = !!settings.hideSingleBookSeries
if (settings.metadataPrecedence) {
this.metadataPrecedence = [...settings.metadataPrecedence]
} else {
// Added in v2.4.5
this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata']
}
}
toJSON() {
@ -33,14 +40,20 @@ class LibrarySettings {
skipMatchingMediaWithIsbn: this.skipMatchingMediaWithIsbn,
autoScanCronExpression: this.autoScanCronExpression,
audiobooksOnly: this.audiobooksOnly,
hideSingleBookSeries: this.hideSingleBookSeries
hideSingleBookSeries: this.hideSingleBookSeries,
metadataPrecedence: [...this.metadataPrecedence]
}
}
update(payload) {
let hasUpdates = false
for (const key in payload) {
if (this[key] !== payload[key]) {
if (key === 'metadataPrecedence') {
if (payload[key] && Array.isArray(payload[key]) && payload[key].join() !== this[key].join()) {
this[key] = payload[key]
hasUpdates = true
}
} else if (this[key] !== payload[key]) {
this[key] = payload[key]
hasUpdates = true
}

View file

@ -1,3 +1,4 @@
const packageJson = require('../../../package.json')
const { BookshelfView } = require('../../utils/constants')
const Logger = require('../../Logger')
@ -10,11 +11,8 @@ class ServerSettings {
this.scannerParseSubtitle = false
this.scannerFindCovers = false
this.scannerCoverProvider = 'google'
this.scannerPreferAudioMetadata = false
this.scannerPreferOpfMetadata = false
this.scannerPreferMatchedMetadata = false
this.scannerDisableWatcher = false
this.scannerPreferOverdriveMediaMarker = false
// Metadata - choose to store inside users library item folder
this.storeCoverWithItem = false
@ -53,7 +51,8 @@ class ServerSettings {
this.logLevel = Logger.logLevel
this.version = null
this.version = packageJson.version
this.buildNumber = packageJson.buildNumber
// Auth settings
// Active auth methodes
@ -82,11 +81,8 @@ class ServerSettings {
this.scannerFindCovers = !!settings.scannerFindCovers
this.scannerCoverProvider = settings.scannerCoverProvider || 'google'
this.scannerParseSubtitle = settings.scannerParseSubtitle
this.scannerPreferAudioMetadata = !!settings.scannerPreferAudioMetadata
this.scannerPreferOpfMetadata = !!settings.scannerPreferOpfMetadata
this.scannerPreferMatchedMetadata = !!settings.scannerPreferMatchedMetadata
this.scannerDisableWatcher = !!settings.scannerDisableWatcher
this.scannerPreferOverdriveMediaMarker = !!settings.scannerPreferOverdriveMediaMarker
this.storeCoverWithItem = !!settings.storeCoverWithItem
this.storeMetadataWithItem = !!settings.storeMetadataWithItem
@ -113,6 +109,7 @@ class ServerSettings {
this.language = settings.language || 'en-us'
this.logLevel = settings.logLevel || Logger.logLevel
this.version = settings.version || null
this.buildNumber = settings.buildNumber || 0 // Added v2.4.5
this.authActiveAuthMethods = settings.authActiveAuthMethods || ['local']
this.authGoogleOauth20ClientID = settings.authGoogleOauth20ClientID || ''
@ -173,9 +170,9 @@ class ServerSettings {
this.metadataFileFormat = 'abs'
}
// Validation
if (!['abs', 'json'].includes(this.metadataFileFormat)) {
Logger.error(`[ServerSettings] construct: Invalid metadataFileFormat ${this.metadataFileFormat}`)
// As of v2.4.5 only json is supported
if (this.metadataFileFormat !== 'json') {
Logger.warn(`[ServerSettings] Invalid metadataFileFormat ${this.metadataFileFormat} (as of v2.4.5 only json is supported)`)
this.metadataFileFormat = 'json'
}
@ -191,11 +188,8 @@ class ServerSettings {
scannerFindCovers: this.scannerFindCovers,
scannerCoverProvider: this.scannerCoverProvider,
scannerParseSubtitle: this.scannerParseSubtitle,
scannerPreferAudioMetadata: this.scannerPreferAudioMetadata,
scannerPreferOpfMetadata: this.scannerPreferOpfMetadata,
scannerPreferMatchedMetadata: this.scannerPreferMatchedMetadata,
scannerDisableWatcher: this.scannerDisableWatcher,
scannerPreferOverdriveMediaMarker: this.scannerPreferOverdriveMediaMarker,
storeCoverWithItem: this.storeCoverWithItem,
storeMetadataWithItem: this.storeMetadataWithItem,
metadataFileFormat: this.metadataFileFormat,
@ -217,6 +211,7 @@ class ServerSettings {
language: this.language,
logLevel: this.logLevel,
version: this.version,
buildNumber: this.buildNumber,
authActiveAuthMethods: this.authActiveAuthMethods,
authGoogleOauth20ClientID: this.authGoogleOauth20ClientID, // Do not return to client
authGoogleOauth20ClientSecret: this.authGoogleOauth20ClientSecret, // Do not return to client

View file

@ -7,6 +7,7 @@ class User {
this.id = null
this.oldUserId = null // TODO: Temp for keeping old access tokens
this.username = null
this.email = null
this.pash = null
this.type = null
this.token = null
@ -34,6 +35,9 @@ class User {
get isAdmin() {
return this.type === 'admin'
}
get isUser() {
return this.type === 'user'
}
get isGuest() {
return this.type === 'guest'
}
@ -76,6 +80,7 @@ class User {
id: this.id,
oldUserId: this.oldUserId,
username: this.username,
email: this.email,
pash: this.pash,
type: this.type,
token: this.token,
@ -97,6 +102,7 @@ class User {
id: this.id,
oldUserId: this.oldUserId,
username: this.username,
email: this.email,
type: this.type,
token: (this.type === 'root' && hideRootToken) ? '' : this.token,
mediaProgress: this.mediaProgress ? this.mediaProgress.map(li => li.toJSON()) : [],
@ -140,6 +146,7 @@ class User {
this.id = user.id
this.oldUserId = user.oldUserId
this.username = user.username
this.email = user.email || null
this.pash = user.pash
this.type = user.type
this.token = user.token
@ -184,7 +191,7 @@ class User {
update(payload) {
var hasUpdates = false
// Update the following keys:
const keysToCheck = ['pash', 'type', 'username', 'isActive']
const keysToCheck = ['pash', 'type', 'username', 'email', 'isActive']
keysToCheck.forEach((key) => {
if (payload[key] !== undefined) {
if (key === 'isActive' || payload[key]) { // pash, type, username must evaluate to true (cannot be null or empty)