From 5446aea910b4d51c5320236421aa9f668b65bdc4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 26 Mar 2022 14:29:49 -0500 Subject: [PATCH] Add Scanner support for podcasts --- server/controllers/LibraryController.js | 2 +- server/managers/CoverManager.js | 4 ++- server/objects/LibraryItem.js | 14 ++++----- server/objects/entities/PodcastEpisode.js | 9 ++++++ server/objects/mediaTypes/Podcast.js | 36 ++++++++++++++++++----- server/scanner/AudioFileScanner.js | 25 ++++++++++++++-- server/scanner/Scanner.js | 2 +- server/utils/scandir.js | 20 +++++++++---- 8 files changed, 84 insertions(+), 28 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 635f8557..e174f672 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -3,8 +3,8 @@ const fs = require('fs-extra') const filePerms = require('../utils/filePerms') const Logger = require('../Logger') const Library = require('../objects/Library') -const { sort, createNewSortInstance } = require('fast-sort') const libraryHelpers = require('../utils/libraryHelpers') +const { sort, createNewSortInstance } = require('fast-sort') const naturalSort = createNewSortInstance({ comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare }) diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js index d7407d1e..8d1bb4b3 100644 --- a/server/managers/CoverManager.js +++ b/server/managers/CoverManager.js @@ -226,7 +226,9 @@ class CoverManager { } async saveEmbeddedCoverArt(libraryItem) { - var audioFileWithCover = libraryItem.media.audioFiles.find(af => af.embeddedCoverArt) + var audioFileWithCover = null + if (libraryItem.mediaType === 'book') audioFileWithCover = libraryItem.media.audioFiles.find(af => af.embeddedCoverArt) + else audioFileWithCover = libraryItem.media.episodes.find(ep => ep.audioFile.embeddedCoverArt) if (!audioFileWithCover) return false var coverDirPath = this.getCoverDirectory(libraryItem) diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index 0c7f9b1c..72335283 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -166,7 +166,6 @@ class LibraryItem { } else { this.mediaType = 'book' this.media = new Book() - } @@ -235,6 +234,7 @@ class LibraryItem { saveMetadata() { } // Returns null if file not found, true if file was updated, false if up to date + // updates existing LibraryFile, AudioFile, EBookFile's checkFileFound(fileFound) { var hasUpdated = false @@ -270,8 +270,8 @@ class LibraryItem { hasUpdated = true } - var keysToCheck = ['filename', 'ext', 'mtimeMs', 'ctimeMs', 'birthtimeMs', 'size'] - keysToCheck.forEach((key) => { + // 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 @@ -319,8 +319,7 @@ class LibraryItem { hasUpdated = true } - var keysToCheck = ['mtimeMs', 'ctimeMs', 'birthtimeMs'] - keysToCheck.forEach((key) => { + ['mtimeMs', 'ctimeMs', 'birthtimeMs'].forEach((key) => { if (dataFound[key] != this[key]) { this[key] = dataFound[key] || 0 hasUpdated = true @@ -347,6 +346,7 @@ class LibraryItem { // Remove files not found (inodes will all be up to date at this point) this.libraryFiles = this.libraryFiles.filter(lf => { if (!dataFound.libraryFiles.find(_lf => _lf.ino === lf.ino)) { + // Check if removing cover path if (lf.metadata.path === this.media.coverPath) { Logger.debug(`[LibraryItem] "${this.media.metadata.title}" check scan cover removed`) this.media.updateCover('') @@ -395,10 +395,6 @@ class LibraryItem { } } - findLibraryFileWithIno(inode) { - return this.libraryFiles.find(lf => lf.ino === inode) - } - // Set metadata from files async syncFiles(preferOpfMetadata) { var hasUpdated = false diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js index 08a794e6..42243af8 100644 --- a/server/objects/entities/PodcastEpisode.js +++ b/server/objects/entities/PodcastEpisode.js @@ -86,6 +86,15 @@ class PodcastEpisode { this.updatedAt = Date.now() } + setDataFromAudioFile(audioFile, index) { + this.id = getId('ep') + this.audioFile = audioFile + this.title = audioFile.metadata.filename + this.index = index + this.addedAt = Date.now() + this.updatedAt = Date.now() + } + // Only checks container format checkCanDirectPlay(payload) { var supportedMimeTypes = payload.supportedMimeTypes || [] diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index 54259567..c285c295 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -1,6 +1,10 @@ const PodcastEpisode = require('../entities/PodcastEpisode') const PodcastMetadata = require('../metadata/PodcastMetadata') const { areEquivalent, copyValue } = require('../../utils/index') +const { createNewSortInstance } = require('fast-sort') +const naturalSort = createNewSortInstance({ + comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare +}) class Podcast { constructor(podcast) { @@ -69,7 +73,7 @@ class Podcast { return false } get hasEmbeddedCoverArt() { - return false + return this.episodes.some(ep => ep.audioFile.embeddedCoverArt) } get hasIssues() { return false @@ -111,11 +115,11 @@ class Podcast { } removeFileWithInode(inode) { - return false + this.episodes = this.episodes.filter(ep => ep.ino !== inode) } findFileWithInode(inode) { - return null + return this.episodes.find(ep => ep.audioFile.ino === inode) } setData(mediaMetadata) { @@ -137,10 +141,6 @@ class Podcast { return payload || {} } - addPodcastEpisode(podcastEpisode) { - this.episodes.push(podcastEpisode) - } - // Only checks container format checkCanDirectPlay(payload, epsiodeIndex = 0) { var episode = this.episodes[epsiodeIndex] @@ -151,5 +151,27 @@ class Podcast { var episode = this.episodes[episodeIndex] return episode.getDirectPlayTracklist(libraryItemId) } + + addPodcastEpisode(podcastEpisode) { + this.episodes.push(podcastEpisode) + } + + addNewEpisodeFromAudioFile(audioFile, index) { + var pe = new PodcastEpisode() + pe.setDataFromAudioFile(audioFile, index) + this.episodes.push(pe) + } + + reorderEpisodes() { + var hasUpdates = false + this.episodes = naturalSort(this.episodes).asc((ep) => ep.bestFilename) + for (let i = 0; i < this.episodes.length; i++) { + if (this.episodes[i].index !== (i + 1)) { + this.episodes[i].index = i + 1 + hasUpdates = true + } + } + return hasUpdates + } } module.exports = Podcast \ No newline at end of file diff --git a/server/scanner/AudioFileScanner.js b/server/scanner/AudioFileScanner.js index f4819dc9..f1735d4d 100644 --- a/server/scanner/AudioFileScanner.js +++ b/server/scanner/AudioFileScanner.js @@ -80,7 +80,7 @@ class AudioFileScanner { // Returns array of { AudioFile, elapsed, averageScanDuration } from audio file scan objects async executeAudioFileScans(mediaType, audioLibraryFiles, scanData) { - var mediaMetadataFromScan = scanData.mediaMetadata || null + var mediaMetadataFromScan = scanData.media.metadata || null var proms = [] for (let i = 0; i < audioLibraryFiles.length; i++) { proms.push(this.scan(mediaType, audioLibraryFiles[i], mediaMetadataFromScan)) @@ -150,7 +150,6 @@ class AudioFileScanner { trackKey = 'trackNumFromMeta' } - if (discKey !== null) { Logger.debug(`[AudioFileScanner] Smart track order for "${libraryItem.media.metadata.title}" using disc key ${discKey} and track key ${trackKey}`) audioFiles.sort((a, b) => { @@ -222,8 +221,28 @@ class AudioFileScanner { if (hasUpdated) { libraryItem.media.rebuildTracks() } - } // End Book media type + } else { // Podcast Media Type + var existingAudioFiles = audioScanResult.audioFiles.filter(af => libraryItem.media.findFileWithInode(af.ino)) + + if (newAudioFiles.length) { + var newIndex = libraryItem.media.episodes.length + 1 + newAudioFiles.forEach((newAudioFile) => { + libraryItem.media.addNewEpisodeFromAudioFile(newAudioFile, newIndex++) + }) + libraryItem.media.reorderEpisodes() + hasUpdated = true + } + + // Update audio file metadata for audio files already there + existingAudioFiles.forEach((af) => { + var peAudioFile = libraryItem.media.findFileWithInode(af.ino) + if (peAudioFile.updateFromScan && peAudioFile.updateFromScan(af)) { + hasUpdated = true + } + }) + } } + return hasUpdated } } diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 10f0ab71..2aa31270 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -172,7 +172,7 @@ class Scanner { if (this.cancelLibraryScan[libraryScan.libraryId]) return true - // Remove audiobooks with no inode + // Remove items with no inode libraryItemDataFound = libraryItemDataFound.filter(lid => lid.ino) var libraryItemsInLibrary = this.db.libraryItems.filter(li => li.libraryId === libraryScan.libraryId) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index 6c4fc994..26b20d74 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -5,11 +5,12 @@ const { recurseFiles, getFileTimestampsWithIno } = require('./fileUtils') const globals = require('./globals') const LibraryFile = require('../objects/files/LibraryFile') -function isMediaFile(path) { +function isMediaFile(mediaType, path) { if (!path) return false var ext = Path.extname(path) if (!ext) return false var extclean = ext.slice(1).toLowerCase() + if (mediaType === 'podcast') return globals.SupportedAudioTypes.includes(extclean) return globals.SupportedAudioTypes.includes(extclean) || globals.SupportedEbookTypes.includes(extclean) } @@ -60,7 +61,7 @@ module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths // Input: array of relative file items (see recurseFiles) // Output: map of files grouped into potential libarary item dirs -function groupFileItemsIntoLibraryItemDirs(fileItems) { +function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) { // Step 1: Filter out files in root dir (with depth of 0) var itemsFiltered = fileItems.filter(i => i.deep > 0) @@ -69,7 +70,7 @@ function groupFileItemsIntoLibraryItemDirs(fileItems) { var mediaFileItems = [] var otherFileItems = [] itemsFiltered.forEach(item => { - if (isMediaFile(item.fullpath)) mediaFileItems.push(item) + if (isMediaFile(mediaType, item.fullpath)) mediaFileItems.push(item) else otherFileItems.push(item) }) @@ -141,7 +142,7 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) { var fileItems = await recurseFiles(folderPath) - var libraryItemGrouping = groupFileItemsIntoLibraryItemDirs(fileItems) + var libraryItemGrouping = groupFileItemsIntoLibraryItemDirs(libraryMediaType, fileItems) if (!Object.keys(libraryItemGrouping).length) { Logger.error('Root path has no media folders', fileItems.length) @@ -268,17 +269,24 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) { function getPodcastDataFromDir(folderPath, relPath) { relPath = relPath.replace(/\\/g, '/') + var splitDir = relPath.split('/') + + // Audio files will always be in the directory named for the title + var title = splitDir.pop() return { + mediaMetadata: { + title + }, relPath: relPath, // relative audiobook path i.e. /Author Name/Book Name/.. path: Path.posix.join(folderPath, relPath) // i.e. /audiobook/Author Name/Book Name/.. } } function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettings) { - var parseSubtitle = !!serverSettings.scannerParseSubtitle if (libraryMediaType === 'podcast') { - return getPodcastDataFromDir(folderPath, relPath, parseSubtitle) + return getPodcastDataFromDir(folderPath, relPath) } else { + var parseSubtitle = !!serverSettings.scannerParseSubtitle return getBookDataFromDir(folderPath, relPath, parseSubtitle) } }