mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 18:01:42 +00:00
When storeMetadataWithItem is enabled, the metadata file should be written for podcast libraries regardless of whether media changes detected. Previously, saveMetadataFile was only called inside the hasMediaChanges block, so podcast metadata was never written on subsequent scans if the media content hadn't changed. Fixes advplyr/audiobookshelf#5093
492 lines
20 KiB
JavaScript
492 lines
20 KiB
JavaScript
const uuidv4 = require('uuid').v4
|
|
const Path = require('path')
|
|
const { LogLevel } = require('../utils/constants')
|
|
const { getTitleIgnorePrefix } = require('../utils/index')
|
|
const AudioFileScanner = require('./AudioFileScanner')
|
|
const Database = require('../Database')
|
|
const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
|
|
const AudioFile = require('../objects/files/AudioFile')
|
|
const CoverManager = require('../managers/CoverManager')
|
|
const LibraryFile = require('../objects/files/LibraryFile')
|
|
const fsExtra = require('../libs/fsExtra')
|
|
const PodcastEpisode = require('../models/PodcastEpisode')
|
|
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
|
|
const htmlSanitizer = require('../utils/htmlSanitizer')
|
|
|
|
/**
|
|
* Metadata for podcasts pulled from files
|
|
* @typedef PodcastMetadataObject
|
|
* @property {string} title
|
|
* @property {string} titleIgnorePrefix
|
|
* @property {string} author
|
|
* @property {string} releaseDate
|
|
* @property {string} feedURL
|
|
* @property {string} imageURL
|
|
* @property {string} description
|
|
* @property {string} itunesPageURL
|
|
* @property {string} itunesId
|
|
* @property {string} language
|
|
* @property {string} podcastType
|
|
* @property {string[]} genres
|
|
* @property {string[]} tags
|
|
* @property {boolean} explicit
|
|
*/
|
|
|
|
class PodcastScanner {
|
|
constructor() {}
|
|
|
|
/**
|
|
* @param {import('../models/LibraryItem')} existingLibraryItem
|
|
* @param {import('./LibraryItemScanData')} libraryItemData
|
|
* @param {import('../models/Library').LibrarySettingsObject} librarySettings
|
|
* @param {import('./LibraryScan')} libraryScan
|
|
* @returns {Promise<{libraryItem:import('../models/LibraryItem'), wasUpdated:boolean}>}
|
|
*/
|
|
async rescanExistingPodcastLibraryItem(existingLibraryItem, libraryItemData, librarySettings, libraryScan) {
|
|
/** @type {import('../models/Podcast')} */
|
|
const media = await existingLibraryItem.getMedia({
|
|
include: [
|
|
{
|
|
model: Database.podcastEpisodeModel
|
|
}
|
|
]
|
|
})
|
|
|
|
/** @type {import('../models/PodcastEpisode')[]} */
|
|
let existingPodcastEpisodes = media.podcastEpisodes
|
|
|
|
/** @type {AudioFile[]} */
|
|
let newAudioFiles = []
|
|
|
|
if (libraryItemData.hasAudioFileChanges || libraryItemData.audioLibraryFiles.length !== existingPodcastEpisodes.length) {
|
|
// Filter out and destroy episodes that were removed
|
|
const episodesToRemove = []
|
|
existingPodcastEpisodes = existingPodcastEpisodes.filter((ep) => {
|
|
if (libraryItemData.checkAudioFileRemoved(ep.audioFile)) {
|
|
episodesToRemove.push(ep)
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
|
|
if (episodesToRemove.length) {
|
|
// Remove episodes from playlists and media progress
|
|
const episodeIds = episodesToRemove.map((ep) => ep.id)
|
|
await Database.playlistModel.removeMediaItemsFromPlaylists(episodeIds)
|
|
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
|
|
where: {
|
|
mediaItemId: episodeIds
|
|
}
|
|
})
|
|
if (mediaProgressRemoved) {
|
|
libraryScan.addLog(LogLevel.INFO, `Removed ${mediaProgressRemoved} media progress for episodes`)
|
|
}
|
|
|
|
// Remove episodes
|
|
await Promise.all(
|
|
episodesToRemove.map(async (ep) => {
|
|
await ep.destroy()
|
|
libraryScan.addLog(LogLevel.INFO, `Podcast episode "${ep.title}" audio file was removed`)
|
|
})
|
|
)
|
|
}
|
|
|
|
// Update audio files that were modified
|
|
if (libraryItemData.audioLibraryFilesModified.length) {
|
|
let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(
|
|
existingLibraryItem.mediaType,
|
|
libraryItemData,
|
|
libraryItemData.audioLibraryFilesModified.map((lf) => lf.new)
|
|
)
|
|
|
|
for (const podcastEpisode of existingPodcastEpisodes) {
|
|
let matchedScannedAudioFile = scannedAudioFiles.find((saf) => saf.metadata.path === podcastEpisode.audioFile.metadata.path)
|
|
if (!matchedScannedAudioFile) {
|
|
matchedScannedAudioFile = scannedAudioFiles.find((saf) => saf.ino === podcastEpisode.audioFile.ino)
|
|
}
|
|
|
|
if (matchedScannedAudioFile) {
|
|
scannedAudioFiles = scannedAudioFiles.filter((saf) => saf !== matchedScannedAudioFile)
|
|
const audioFile = new AudioFile(podcastEpisode.audioFile)
|
|
audioFile.updateFromScan(matchedScannedAudioFile)
|
|
podcastEpisode.audioFile = audioFile.toJSON()
|
|
podcastEpisode.changed('audioFile', true)
|
|
|
|
// Set metadata and save episode
|
|
AudioFileScanner.setPodcastEpisodeMetadataFromAudioMetaTags(podcastEpisode, libraryScan)
|
|
libraryScan.addLog(LogLevel.INFO, `Podcast episode "${podcastEpisode.title}" keys changed [${podcastEpisode.changed()?.join(', ')}]`)
|
|
await podcastEpisode.save()
|
|
}
|
|
}
|
|
|
|
// Modified audio files that were not found as a podcast episode
|
|
if (scannedAudioFiles.length) {
|
|
newAudioFiles.push(...scannedAudioFiles)
|
|
}
|
|
}
|
|
|
|
// Add new audio files scanned in
|
|
if (libraryItemData.audioLibraryFilesAdded.length) {
|
|
const scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, libraryItemData.audioLibraryFilesAdded)
|
|
newAudioFiles.push(...scannedAudioFiles)
|
|
}
|
|
|
|
// Create new podcast episodes from new found audio files
|
|
for (const newAudioFile of newAudioFiles) {
|
|
// Podcast episode audio files always have index 1
|
|
newAudioFile.index = 1
|
|
|
|
const newEpisode = {
|
|
title: newAudioFile.metaTags.tagTitle || newAudioFile.metadata.filenameNoExt,
|
|
subtitle: null,
|
|
season: null,
|
|
episode: null,
|
|
episodeType: null,
|
|
pubDate: null,
|
|
publishedAt: null,
|
|
description: null,
|
|
audioFile: newAudioFile.toJSON(),
|
|
chapters: newAudioFile.chapters || [],
|
|
podcastId: media.id
|
|
}
|
|
const newPodcastEpisode = Database.podcastEpisodeModel.build(newEpisode)
|
|
// Set metadata and save new episode
|
|
AudioFileScanner.setPodcastEpisodeMetadataFromAudioMetaTags(newPodcastEpisode, libraryScan)
|
|
libraryScan.addLog(LogLevel.INFO, `New Podcast episode "${newPodcastEpisode.title}" added`)
|
|
await newPodcastEpisode.save()
|
|
existingPodcastEpisodes.push(newPodcastEpisode)
|
|
}
|
|
}
|
|
|
|
let hasMediaChanges = false
|
|
if (existingPodcastEpisodes.length !== media.numEpisodes) {
|
|
media.numEpisodes = existingPodcastEpisodes.length
|
|
hasMediaChanges = true
|
|
}
|
|
|
|
// Check if cover was removed
|
|
if (media.coverPath && libraryItemData.imageLibraryFilesRemoved.some((lf) => lf.metadata.path === media.coverPath)) {
|
|
media.coverPath = null
|
|
hasMediaChanges = true
|
|
}
|
|
|
|
// Update cover if it was modified
|
|
if (media.coverPath && libraryItemData.imageLibraryFilesModified.length) {
|
|
let coverMatch = libraryItemData.imageLibraryFilesModified.find((iFile) => iFile.old.metadata.path === media.coverPath)
|
|
if (coverMatch) {
|
|
const coverPath = coverMatch.new.metadata.path
|
|
if (coverPath !== media.coverPath) {
|
|
libraryScan.addLog(LogLevel.DEBUG, `Updating podcast cover "${media.coverPath}" => "${coverPath}" for podcast "${media.title}"`)
|
|
media.coverPath = coverPath
|
|
media.changed('coverPath', true)
|
|
hasMediaChanges = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if cover is not set and image files were found
|
|
if (!media.coverPath && libraryItemData.imageLibraryFiles.length) {
|
|
// Prefer using a cover image with the name "cover" otherwise use the first image
|
|
const coverMatch = libraryItemData.imageLibraryFiles.find((iFile) => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
|
media.coverPath = coverMatch?.metadata.path || libraryItemData.imageLibraryFiles[0].metadata.path
|
|
hasMediaChanges = true
|
|
}
|
|
|
|
const podcastMetadata = await this.getPodcastMetadataFromScanData(existingPodcastEpisodes, libraryItemData, libraryScan, existingLibraryItem.id)
|
|
|
|
for (const key in podcastMetadata) {
|
|
// Ignore unset metadata and empty arrays
|
|
if (podcastMetadata[key] === undefined || (Array.isArray(podcastMetadata[key]) && !podcastMetadata[key].length)) continue
|
|
|
|
if (key === 'genres') {
|
|
const existingGenres = media.genres || []
|
|
if (podcastMetadata.genres.some((g) => !existingGenres.includes(g)) || existingGenres.some((g) => !podcastMetadata.genres.includes(g))) {
|
|
libraryScan.addLog(LogLevel.DEBUG, `Updating podcast genres "${existingGenres.join(',')}" => "${podcastMetadata.genres.join(',')}" for podcast "${podcastMetadata.title}"`)
|
|
media.genres = podcastMetadata.genres
|
|
media.changed('genres', true)
|
|
hasMediaChanges = true
|
|
}
|
|
} else if (key === 'tags') {
|
|
const existingTags = media.tags || []
|
|
if (podcastMetadata.tags.some((t) => !existingTags.includes(t)) || existingTags.some((t) => !podcastMetadata.tags.includes(t))) {
|
|
libraryScan.addLog(LogLevel.DEBUG, `Updating podcast tags "${existingTags.join(',')}" => "${podcastMetadata.tags.join(',')}" for podcast "${podcastMetadata.title}"`)
|
|
media.tags = podcastMetadata.tags
|
|
media.changed('tags', true)
|
|
hasMediaChanges = true
|
|
}
|
|
} else if (podcastMetadata[key] !== media[key]) {
|
|
libraryScan.addLog(LogLevel.DEBUG, `Updating podcast ${key} "${media[key]}" => "${podcastMetadata[key]}" for podcast "${podcastMetadata.title}"`)
|
|
media[key] = podcastMetadata[key]
|
|
hasMediaChanges = true
|
|
}
|
|
}
|
|
|
|
// If no cover then extract cover from audio file if available
|
|
if (!media.coverPath && existingPodcastEpisodes.length) {
|
|
const audioFiles = existingPodcastEpisodes.map((ep) => ep.audioFile)
|
|
const extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(audioFiles, existingLibraryItem.id, existingLibraryItem.path)
|
|
if (extractedCoverPath) {
|
|
libraryScan.addLog(LogLevel.DEBUG, `Updating podcast "${podcastMetadata.title}" extracted embedded cover art from audio file to path "${extractedCoverPath}"`)
|
|
media.coverPath = extractedCoverPath
|
|
hasMediaChanges = true
|
|
}
|
|
}
|
|
|
|
existingLibraryItem.media = media
|
|
|
|
let libraryItemUpdated = false
|
|
|
|
// Save Podcast changes to db
|
|
if (hasMediaChanges) {
|
|
await media.save()
|
|
await this.saveMetadataFile(existingLibraryItem, libraryScan)
|
|
libraryItemUpdated = global.ServerSettings.storeMetadataWithItem
|
|
} else if (global.ServerSettings.storeMetadataWithItem) {
|
|
// Always save metadata file when setting is enabled, even without media changes
|
|
await this.saveMetadataFile(existingLibraryItem, libraryScan)
|
|
libraryItemUpdated = true
|
|
}
|
|
|
|
if (libraryItemUpdated) {
|
|
existingLibraryItem.changed('libraryFiles', true)
|
|
await existingLibraryItem.save()
|
|
}
|
|
|
|
return {
|
|
libraryItem: existingLibraryItem,
|
|
wasUpdated: hasMediaChanges || libraryItemUpdated
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {import('./LibraryItemScanData')} libraryItemData
|
|
* @param {import('../models/Library').LibrarySettingsObject} librarySettings
|
|
* @param {import('./LibraryScan')} libraryScan
|
|
* @returns {Promise<import('../models/LibraryItem')>}
|
|
*/
|
|
async scanNewPodcastLibraryItem(libraryItemData, librarySettings, libraryScan) {
|
|
// Scan audio files found
|
|
let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(libraryItemData.mediaType, libraryItemData, libraryItemData.audioLibraryFiles)
|
|
|
|
// Do not add library items that have no valid audio files
|
|
if (!scannedAudioFiles.length) {
|
|
libraryScan.addLog(LogLevel.WARN, `Library item at path "${libraryItemData.relPath}" has no audio files - ignoring`)
|
|
return null
|
|
}
|
|
|
|
const newPodcastEpisodes = []
|
|
|
|
// Create podcast episodes from audio files
|
|
for (const audioFile of scannedAudioFiles) {
|
|
// Podcast episode audio files always have index 1
|
|
audioFile.index = 1
|
|
|
|
const newEpisode = {
|
|
title: audioFile.metaTags.tagTitle || audioFile.metadata.filenameNoExt,
|
|
subtitle: null,
|
|
season: null,
|
|
episode: null,
|
|
episodeType: null,
|
|
pubDate: null,
|
|
publishedAt: null,
|
|
description: null,
|
|
audioFile: audioFile.toJSON(),
|
|
chapters: audioFile.chapters || []
|
|
}
|
|
|
|
// Set metadata and save new episode
|
|
AudioFileScanner.setPodcastEpisodeMetadataFromAudioMetaTags(newEpisode, libraryScan)
|
|
libraryScan.addLog(LogLevel.INFO, `New Podcast episode "${newEpisode.title}" found`)
|
|
newPodcastEpisodes.push(newEpisode)
|
|
}
|
|
|
|
const podcastMetadata = await this.getPodcastMetadataFromScanData(newPodcastEpisodes, libraryItemData, libraryScan)
|
|
podcastMetadata.explicit = !!podcastMetadata.explicit // Ensure boolean
|
|
|
|
// Set cover image from library file
|
|
if (libraryItemData.imageLibraryFiles.length) {
|
|
// Prefer using a cover image with the name "cover" otherwise use the first image
|
|
const coverMatch = libraryItemData.imageLibraryFiles.find((iFile) => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
|
podcastMetadata.coverPath = coverMatch?.metadata.path || libraryItemData.imageLibraryFiles[0].metadata.path
|
|
}
|
|
|
|
// Set default podcastType to episodic
|
|
if (!podcastMetadata.podcastType) {
|
|
podcastMetadata.podcastType = 'episodic'
|
|
}
|
|
|
|
const podcastObject = {
|
|
...podcastMetadata,
|
|
autoDownloadEpisodes: false,
|
|
autoDownloadSchedule: '0 * * * *',
|
|
lastEpisodeCheck: 0,
|
|
maxEpisodesToKeep: 0,
|
|
maxNewEpisodesToDownload: 3,
|
|
podcastEpisodes: newPodcastEpisodes,
|
|
numEpisodes: newPodcastEpisodes.length
|
|
}
|
|
|
|
const libraryItemObj = libraryItemData.libraryItemObject
|
|
libraryItemObj.id = uuidv4() // Generate library item id ahead of time to use for saving extracted cover image
|
|
libraryItemObj.isMissing = false
|
|
libraryItemObj.isInvalid = false
|
|
libraryItemObj.extraData = {}
|
|
libraryItemObj.title = podcastObject.title
|
|
libraryItemObj.titleIgnorePrefix = getTitleIgnorePrefix(podcastObject.title)
|
|
|
|
// If cover was not found in folder then check embedded covers in audio files
|
|
if (!podcastObject.coverPath && scannedAudioFiles.length) {
|
|
// Extract and save embedded cover art
|
|
podcastObject.coverPath = await CoverManager.saveEmbeddedCoverArt(scannedAudioFiles, libraryItemObj.id, libraryItemObj.path)
|
|
}
|
|
|
|
libraryItemObj.podcast = podcastObject
|
|
const libraryItem = await Database.libraryItemModel.create(libraryItemObj, {
|
|
include: {
|
|
model: Database.podcastModel,
|
|
include: Database.podcastEpisodeModel
|
|
}
|
|
})
|
|
|
|
Database.addGenresToFilterData(libraryItemData.libraryId, libraryItem.podcast.genres)
|
|
Database.addTagsToFilterData(libraryItemData.libraryId, libraryItem.podcast.tags)
|
|
|
|
// Load for emitting to client
|
|
libraryItem.media = await libraryItem.getMedia({
|
|
include: Database.podcastEpisodeModel
|
|
})
|
|
|
|
await this.saveMetadataFile(libraryItem, libraryScan)
|
|
if (global.ServerSettings.storeMetadataWithItem) {
|
|
libraryItem.changed('libraryFiles', true)
|
|
await libraryItem.save()
|
|
}
|
|
|
|
return libraryItem
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {PodcastEpisode[]} podcastEpisodes Not the models for new podcasts
|
|
* @param {import('./LibraryItemScanData')} libraryItemData
|
|
* @param {import('./LibraryScan')} libraryScan
|
|
* @param {string} [existingLibraryItemId]
|
|
* @returns {Promise<PodcastMetadataObject>}
|
|
*/
|
|
async getPodcastMetadataFromScanData(podcastEpisodes, libraryItemData, libraryScan, existingLibraryItemId = null) {
|
|
const podcastMetadata = {
|
|
title: libraryItemData.mediaMetadata.title,
|
|
titleIgnorePrefix: undefined,
|
|
author: undefined,
|
|
releaseDate: undefined,
|
|
feedURL: undefined,
|
|
imageURL: undefined,
|
|
description: undefined,
|
|
itunesPageURL: undefined,
|
|
itunesId: undefined,
|
|
itunesArtistId: undefined,
|
|
language: undefined,
|
|
podcastType: undefined,
|
|
explicit: undefined,
|
|
tags: [],
|
|
genres: []
|
|
}
|
|
|
|
// Use audio meta tags
|
|
if (podcastEpisodes.length) {
|
|
AudioFileScanner.setPodcastMetadataFromAudioMetaTags(podcastEpisodes[0].audioFile, podcastMetadata, libraryScan)
|
|
}
|
|
|
|
// Use metadata.json file
|
|
await AbsMetadataFileScanner.scanPodcastMetadataFile(libraryScan, libraryItemData, podcastMetadata, existingLibraryItemId)
|
|
|
|
podcastMetadata.titleIgnorePrefix = getTitleIgnorePrefix(podcastMetadata.title)
|
|
|
|
if (typeof podcastMetadata.description === 'string' && podcastMetadata.description) {
|
|
podcastMetadata.description = htmlSanitizer.sanitize(podcastMetadata.description)
|
|
}
|
|
|
|
return podcastMetadata
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {import('../models/LibraryItem')} libraryItem
|
|
* @param {import('./LibraryScan')} libraryScan
|
|
* @returns {Promise}
|
|
*/
|
|
async saveMetadataFile(libraryItem, libraryScan) {
|
|
let metadataPath = Path.join(global.MetadataPath, 'items', libraryItem.id)
|
|
let storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem
|
|
if (storeMetadataWithItem) {
|
|
metadataPath = libraryItem.path
|
|
} else {
|
|
// Make sure metadata book dir exists
|
|
storeMetadataWithItem = false
|
|
await fsExtra.ensureDir(metadataPath)
|
|
}
|
|
|
|
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
|
|
|
|
/**
|
|
* Keys must match abmetadataGenerator.js
|
|
*/
|
|
const jsonObject = {
|
|
tags: libraryItem.media.tags || [],
|
|
title: libraryItem.media.title,
|
|
author: libraryItem.media.author,
|
|
description: libraryItem.media.description,
|
|
releaseDate: libraryItem.media.releaseDate,
|
|
genres: libraryItem.media.genres || [],
|
|
feedURL: libraryItem.media.feedURL,
|
|
imageURL: libraryItem.media.imageURL,
|
|
itunesPageURL: libraryItem.media.itunesPageURL,
|
|
itunesId: libraryItem.media.itunesId,
|
|
itunesArtistId: libraryItem.media.itunesArtistId,
|
|
asin: libraryItem.media.asin,
|
|
language: libraryItem.media.language,
|
|
explicit: !!libraryItem.media.explicit,
|
|
podcastType: libraryItem.media.podcastType
|
|
}
|
|
return fsExtra
|
|
.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2))
|
|
.then(async () => {
|
|
// Add metadata.json to libraryFiles array if it is new
|
|
let metadataLibraryFile = libraryItem.libraryFiles.find((lf) => lf.metadata.path === filePathToPOSIX(metadataFilePath))
|
|
if (storeMetadataWithItem) {
|
|
if (!metadataLibraryFile) {
|
|
const newLibraryFile = new LibraryFile()
|
|
await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
|
|
metadataLibraryFile = newLibraryFile.toJSON()
|
|
libraryItem.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(libraryItem.path)
|
|
if (libraryItemDirTimestamps) {
|
|
libraryItem.mtime = libraryItemDirTimestamps.mtimeMs
|
|
libraryItem.ctime = libraryItemDirTimestamps.ctimeMs
|
|
let size = 0
|
|
libraryItem.libraryFiles.forEach((lf) => (size += !isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))
|
|
libraryItem.size = size
|
|
}
|
|
}
|
|
|
|
libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`)
|
|
|
|
return metadataLibraryFile
|
|
})
|
|
.catch((error) => {
|
|
libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error)
|
|
return null
|
|
})
|
|
}
|
|
}
|
|
module.exports = new PodcastScanner()
|