diff --git a/client/pages/library/_library/podcast/search.vue b/client/pages/library/_library/podcast/search.vue index 46bf9e05..d4e6ed96 100644 --- a/client/pages/library/_library/podcast/search.vue +++ b/client/pages/library/_library/podcast/search.vue @@ -82,7 +82,7 @@ export default { return } this.processing = true - var podcastfeed = await this.$axios.$post(`/api/getPodcastFeed`, { rssFeed: podcast.feedUrl }).catch((error) => { + var podcastfeed = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: podcast.feedUrl }).catch((error) => { console.error('Failed to get feed', error) this.$toast.error('Failed to get podcast feed') return null diff --git a/server/BackupManager.js b/server/BackupManager.js index 29f27f13..6093dcdf 100644 --- a/server/BackupManager.js +++ b/server/BackupManager.js @@ -15,7 +15,7 @@ const Backup = require('./objects/Backup') class BackupManager { constructor(db, emitter) { this.BackupPath = Path.join(global.MetadataPath, 'backups') - this.MetadataBooksPath = Path.join(global.MetadataPath, 'books') + this.ItemsMetadataPath = Path.join(global.MetadataPath, 'items') this.db = db this.emitter = emitter @@ -115,7 +115,7 @@ class BackupManager { const zip = new StreamZip.async({ file: backup.fullPath }) await zip.extract('config/', global.ConfigPath) if (backup.backupMetadataCovers) { - await zip.extract('metadata-books/', this.MetadataBooksPath) + await zip.extract('metadata-items/', this.ItemsMetadataPath) } await this.db.reinit() this.emitter('backup_applied') @@ -154,7 +154,7 @@ class BackupManager { async runBackup() { // Check if Metadata Path is inside Config Path (otherwise there will be an infinite loop as the archiver tries to zip itself) Logger.info(`[BackupManager] Running Backup`) - var metadataBooksPath = this.serverSettings.backupMetadataCovers ? this.MetadataBooksPath : null + var metadataItemsPath = this.serverSettings.backupMetadataCovers ? this.ItemsMetadataPath : null var newBackup = new Backup() @@ -164,7 +164,7 @@ class BackupManager { } newBackup.setData(newBackData) - var zipResult = await this.zipBackup(metadataBooksPath, newBackup).then(() => true).catch((error) => { + var zipResult = await this.zipBackup(metadataItemsPath, newBackup).then(() => true).catch((error) => { Logger.error(`[BackupManager] Backup Failed ${error}`) return false }) @@ -204,7 +204,7 @@ class BackupManager { } } - zipBackup(metadataBooksPath, backup) { + zipBackup(metadataItemsPath, backup) { return new Promise((resolve, reject) => { // create a file to stream archive data to const output = fs.createWriteStream(backup.fullPath) @@ -274,9 +274,9 @@ class BackupManager { archive.directory(this.db.AuthorsPath, 'config/authors') archive.directory(this.db.SeriesPath, 'config/series') - if (metadataBooksPath) { - Logger.debug(`[BackupManager] Backing up Metadata Books "${metadataBooksPath}"`) - archive.directory(metadataBooksPath, 'metadata-books') + if (metadataItemsPath) { + Logger.debug(`[BackupManager] Backing up Metadata Items "${metadataItemsPath}"`) + archive.directory(metadataItemsPath, 'metadata-items') } archive.append(backup.detailsString, { name: 'details' }) diff --git a/server/CoverController.js b/server/CoverController.js index bd0b344f..d4ec94b6 100644 --- a/server/CoverController.js +++ b/server/CoverController.js @@ -15,14 +15,14 @@ class CoverController { this.db = db this.cacheManager = cacheManager - this.BookMetadataPath = Path.posix.join(global.MetadataPath, 'books') + this.ItemMetadataPath = Path.posix.join(global.MetadataPath, 'items') } getCoverDirectory(libraryItem) { if (this.db.serverSettings.storeCoverWithBook) { return libraryItem.path } else { - return Path.posix.join(this.BookMetadataPath, libraryItem.id) + return Path.posix.join(this.ItemMetadataPath, libraryItem.id) } } @@ -237,7 +237,7 @@ class CoverController { var coverAlreadyExists = await fs.pathExists(coverFilePath) if (coverAlreadyExists) { - Logger.warn(`[Audiobook] Extract embedded cover art but cover already exists for "${libraryItem.media.metadata.title}" - bail`) + Logger.warn(`[CoverController] Extract embedded cover art but cover already exists for "${libraryItem.media.metadata.title}" - bail`) return false } diff --git a/server/Server.js b/server/Server.js index 6f83c520..57b717f6 100644 --- a/server/Server.js +++ b/server/Server.js @@ -111,28 +111,19 @@ class Server { await this.downloadManager.removeOrphanDownloads() if (version.localeCompare('1.7.3') < 0) { // Old version data model migration - await dbMigration.migrateUserData(this.db) // Db not yet loaded - await this.db.init() - await dbMigration.migrateLibraryItems(this.db) - // TODO: Eventually remove audiobooks db when stable + await dbMigration.migrate(this.db) } else { await this.db.init() } this.auth.init() - // TODO: Implement method to remove old user auidobook data and book metadata folders - // await this.checkUserAudiobookData() - // await this.purgeMetadata() + await this.checkUserLibraryItemProgress() // Remove invalid user item progress + await this.purgeMetadata() // Remove metadata folders without library item await this.backupManager.init() await this.logManager.init() - // If server upgrade and last version was 1.7.0 or earlier - add abmetadata files - // if (this.db.checkPreviousVersionIsBefore('1.7.1')) { - // TODO: wait until stable - // } - if (this.db.serverSettings.scannerDisableWatcher) { Logger.info(`[Server] Watcher is disabled`) this.watcher.disabled = true @@ -275,18 +266,17 @@ class Server { socket.emit('save_metadata_complete', response) } - // Remove unused /metadata/books/{id} folders + // Remove unused /metadata/items/{id} folders async purgeMetadata() { - var booksMetadata = Path.join(global.MetadataPath, 'books') - var booksMetadataExists = await fs.pathExists(booksMetadata) - if (!booksMetadataExists) return - var foldersInBooksMetadata = await fs.readdir(booksMetadata) + var itemsMetadata = Path.join(global.MetadataPath, 'items') + if (!(await fs.pathExists(itemsMetadata))) return + var foldersInItemsMetadata = await fs.readdir(itemsMetadata) var purged = 0 - await Promise.all(foldersInBooksMetadata.map(async foldername => { - var hasMatchingAudiobook = this.db.audiobooks.find(ab => ab.id === foldername) - if (!hasMatchingAudiobook) { - var folderPath = Path.join(booksMetadata, foldername) + await Promise.all(foldersInItemsMetadata.map(async foldername => { + var hasMatchingItem = this.db.libraryItems.find(ab => ab.id === foldername) + if (!hasMatchingItem) { + var folderPath = Path.join(itemsMetadata, foldername) Logger.debug(`[Server] Purging unused metadata ${folderPath}`) await fs.remove(folderPath).then(() => { @@ -297,24 +287,21 @@ class Server { } })) if (purged > 0) { - Logger.info(`[Server] Purged ${purged} unused audiobook metadata`) + Logger.info(`[Server] Purged ${purged} unused library item metadata`) } return purged } - // Check user audiobook data has matching audiobook - async checkUserAudiobookData() { + // Remove user library item progress entries that dont have a library item + async checkUserLibraryItemProgress() { for (let i = 0; i < this.db.users.length; i++) { var _user = this.db.users[i] - if (_user.audiobooks) { - // Find user audiobook data that has no matching audiobook - var audiobookIdsToRemove = Object.keys(_user.audiobooks).filter(aid => { - return !this.db.audiobooks.find(ab => ab.id === aid) - }) - if (audiobookIdsToRemove.length) { - Logger.debug(`[Server] Found ${audiobookIdsToRemove.length} audiobook data to remove from user ${_user.username}`) - for (let y = 0; y < audiobookIdsToRemove.length; y++) { - _user.removeLibraryItemProgress(audiobookIdsToRemove[y]) + if (_user.libraryItemProgress) { + var itemProgressIdsToRemove = _user.libraryItemProgress.map(lip => lip.id).filter(lipId => !this.db.libraryItems.find(_li => _li.id == lipId)) + if (itemProgressIdsToRemove.length) { + Logger.debug(`[Server] Found ${itemProgressIdsToRemove.length} library item progress data to remove from user ${_user.username}`) + for (const lipId of itemProgressIdsToRemove) { + _user.removeLibraryItemProgress(lipId) } await this.db.updateEntity('user', _user) } diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index ae8fe3c9..fe11c03a 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -1,9 +1,7 @@ const Path = require('path') const fs = require('fs-extra') -const axios = require('axios') - const Logger = require('../Logger') -const { parsePodcastRssFeedXml } = require('../utils/podcastUtils') + const { isObject } = require('../utils/index') // @@ -139,28 +137,6 @@ class MiscController { res.sendStatus(200) } - getPodcastFeed(req, res) { - var url = req.body.rssFeed - if (!url) { - return res.status(400).send('Bad request') - } - - axios.get(url).then(async (data) => { - if (!data || !data.data) { - Logger.error('Invalid podcast feed request response') - return res.status(500).send('Bad response from feed request') - } - var podcast = await parsePodcastRssFeedXml(data.data) - if (!podcast) { - return res.status(500).send('Invalid podcast RSS feed') - } - res.json(podcast) - }).catch((error) => { - console.error('Failed', error) - res.status(500).send(error) - }) - } - async findBooks(req, res) { var provider = req.query.provider || 'google' var title = req.query.title || '' diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js new file mode 100644 index 00000000..020f37a5 --- /dev/null +++ b/server/controllers/PodcastController.js @@ -0,0 +1,62 @@ +const axios = require('axios') +const fs = require('fs-extra') +const Logger = require('../Logger') +const { parsePodcastRssFeedXml } = require('../utils/podcastUtils') +const LibraryItem = require('../objects/LibraryItem') + +class PodcastController { + + async create(req, res) { + if (!req.user.isRoot) { + Logger.error(`[PodcastController] Non-root user attempted to create podcast`, req.user) + return res.sendStatus(500) + } + const payload = req.body + + if (await fs.pathExists(payload.path)) { + Logger.error(`[PodcastController] Attempt to create podcast when folder path already exists "${payload.path}"`) + return res.status(400).send('Path already exists') + } + + var success = await fs.ensureDir(payload.path).then(() => true).catch((error) => { + Logger.error(`[PodcastController] Failed to ensure podcast dir "${payload.path}"`, error) + return false + }) + if (!success) return res.status(400).send('Invalid podcast path') + + if (payload.mediaMetadata.imageUrl) { + // TODO: Download image + } + + var libraryItem = new LibraryItem() + libraryItem.setData('podcast', payload) + + await this.db.insertLibraryItem(libraryItem) + this.emitter('item_added', libraryItem.toJSONExpanded()) + + res.json(libraryItem.toJSONExpanded()) + } + + getPodcastFeed(req, res) { + var url = req.body.rssFeed + if (!url) { + return res.status(400).send('Bad request') + } + + axios.get(url).then(async (data) => { + if (!data || !data.data) { + Logger.error('Invalid podcast feed request response') + return res.status(500).send('Bad response from feed request') + } + var podcast = await parsePodcastRssFeedXml(data.data) + if (!podcast) { + return res.status(500).send('Invalid podcast RSS feed') + } + res.json(podcast) + }).catch((error) => { + console.error('Failed', error) + res.status(500).send(error) + }) + } +} +module.exports = new PodcastController() \ No newline at end of file diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js index 59c3b225..be652657 100644 --- a/server/objects/entities/PodcastEpisode.js +++ b/server/objects/entities/PodcastEpisode.js @@ -1,3 +1,4 @@ +const { getId } = require('../../utils/index') const AudioFile = require('../files/AudioFile') const AudioTrack = require('../files/AudioTrack') @@ -5,9 +6,8 @@ class PodcastEpisode { constructor(episode) { this.id = null this.index = null - this.podcastId = null - this.episodeNumber = null + this.episodeNumber = null this.title = null this.description = null this.enclosure = null @@ -25,7 +25,6 @@ class PodcastEpisode { construct(episode) { this.id = episode.id this.index = episode.index - this.podcastId = episode.podcastId this.episodeNumber = episode.episodeNumber this.title = episode.title this.description = episode.description @@ -40,7 +39,6 @@ class PodcastEpisode { return { id: this.id, index: this.index, - podcastId: this.podcastId, episodeNumber: this.episodeNumber, title: this.title, description: this.description, @@ -61,6 +59,18 @@ class PodcastEpisode { } get size() { return this.audioFile.metadata.size } + setData(data, index = 1) { + this.id = getId('ep') + this.index = index + this.title = data.title + this.pubDate = data.pubDate || '' + this.description = data.description || '' + this.enclosure = data.enclosure ? { ...data.enclosure } : null + this.episodeNumber = data.episodeNumber || '' + 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 3c17939f..bb269c9f 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -4,8 +4,6 @@ const { areEquivalent, copyValue } = require('../../utils/index') class Podcast { constructor(podcast) { - this.id = null - this.metadata = null this.coverPath = null this.tags = [] @@ -22,7 +20,6 @@ class Podcast { } construct(podcast) { - this.id = podcast.id this.metadata = new PodcastMetadata(podcast.metadata) this.coverPath = podcast.coverPath this.tags = [...podcast.tags] @@ -32,7 +29,6 @@ class Podcast { toJSON() { return { - id: this.id, metadata: this.metadata.toJSON(), coverPath: this.coverPath, tags: [...this.tags], @@ -43,7 +39,6 @@ class Podcast { toJSONMinified() { return { - id: this.id, metadata: this.metadata.toJSON(), coverPath: this.coverPath, tags: [...this.tags], @@ -54,7 +49,6 @@ class Podcast { toJSONExpanded() { return { - id: this.id, metadata: this.metadata.toJSONExpanded(), coverPath: this.coverPath, tags: [...this.tags], @@ -124,9 +118,10 @@ class Podcast { return this.episodes[0] } - setData(scanMediaMetadata) { - this.metadata = new PodcastMetadata() - this.metadata.setData(scanMediaMetadata) + setData(metadata, coverPath = null, autoDownload = false) { + this.metadata = new PodcastMetadata(metadata) + this.coverPath = coverPath + this.autoDownloadEpisodes = autoDownload } async syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 1a61ed22..b3defb2c 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -14,6 +14,7 @@ const SeriesController = require('../controllers/SeriesController') const AuthorController = require('../controllers/AuthorController') const MediaEntityController = require('../controllers/MediaEntityController') const SessionController = require('../controllers/SessionController') +const PodcastController = require('../controllers/PodcastController') const MiscController = require('../controllers/MiscController') const BookFinder = require('../finders/BookFinder') @@ -173,6 +174,12 @@ class ApiRouter { this.router.post('/session/:id/sync', SessionController.middleware.bind(this), SessionController.sync.bind(this)) this.router.post('/session/:id/close', SessionController.middleware.bind(this), SessionController.close.bind(this)) + // + // Podcast Routes + // + this.router.post('/podcasts', PodcastController.create.bind(this)) + this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this)) + // // Misc Routes // @@ -180,7 +187,6 @@ class ApiRouter { this.router.get('/download/:id', MiscController.download.bind(this)) this.router.patch('/settings', MiscController.updateServerSettings.bind(this)) // Root only this.router.post('/purgecache', MiscController.purgeCache.bind(this)) // Root only - this.router.post('/getPodcastFeed', MiscController.getPodcastFeed.bind(this)) this.router.post('/authorize', MiscController.authorize.bind(this)) this.router.get('/search/covers', MiscController.findCovers.bind(this)) this.router.get('/search/books', MiscController.findBooks.bind(this)) diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 37659ebe..40aa658f 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -18,7 +18,6 @@ const Series = require('../objects/entities/Series') class Scanner { constructor(db, coverController, emitter) { - this.BookMetadataPath = Path.posix.join(global.MetadataPath, 'books') this.ScanLogPath = Path.posix.join(global.MetadataPath, 'logs', 'scans') this.db = db diff --git a/server/utils/dbMigration.js b/server/utils/dbMigration.js index 209fc1fd..e57505b8 100644 --- a/server/utils/dbMigration.js +++ b/server/utils/dbMigration.js @@ -151,6 +151,17 @@ function makeFilesFromOldAb(audiobook) { } } +// Metadata path was changed to /metadata/items make sure cover is using new path +function cleanOldCoverPath(coverPath) { + if (!coverPath) return null + var oldMetadataPath = Path.posix.join(global.MetadataPath, 'books') + if (coverPath.startsWith(oldMetadataPath)) { + const newMetadataPath = Path.posix.join(global.MetadataPath, 'items') + return coverPath.replace(oldMetadataPath, newMetadataPath) + } + return coverPath +} + function makeLibraryItemFromOldAb(audiobook) { var libraryItem = new LibraryItem() libraryItem.id = getId('li') @@ -184,7 +195,7 @@ function makeLibraryItemFromOldAb(audiobook) { } bookEntity.metadata = bookMetadata - bookEntity.coverPath = audiobook.book.coverFullPath + bookEntity.coverPath = cleanOldCoverPath(audiobook.book.coverFullPath) bookEntity.tags = [...audiobook.tags] var payload = makeFilesFromOldAb(audiobook) @@ -312,8 +323,6 @@ async function migrateLibraryItems(db) { seriesToAdd = [] Logger.info(`==== Library Item migration complete ====`) } -module.exports.migrateLibraryItems = migrateLibraryItems - function cleanUserObject(db, userObj) { var cleanedUserPayload = { @@ -445,4 +454,24 @@ async function migrateUserData(db) { Logger.info(`==== User migration complete (${userCount} Users, ${sessionCount} Sessions) ====`) } -module.exports.migrateUserData = migrateUserData \ No newline at end of file + +async function checkUpdateMetadataPath() { + var bookMetadataPath = Path.posix.join(global.MetadataPath, 'books') // OLD + if (!(await fs.pathExists(bookMetadataPath))) { + Logger.debug(`[dbMigration] No need to update books metadata path`) + return + } + var itemsMetadataPath = Path.posix.join(global.MetadataPath, 'items') + await fs.rename(bookMetadataPath, itemsMetadataPath) + Logger.info(`>>> Renamed metadata dir from /metadata/books to /metadata/items`) +} + +module.exports.migrate = async (db) => { + await checkUpdateMetadataPath() + // Before DB Load clean data + await migrateUserData(db) + await db.init() + // After DB Load + await migrateLibraryItems(db) + // TODO: Eventually remove audiobooks db when stable +} \ No newline at end of file