Merge remote-tracking branch 'origin/master' into auth_passportjs

This commit is contained in:
lukeIam 2023-03-24 18:23:08 +01:00
commit be53b31712
100 changed files with 2799 additions and 1217 deletions

View file

@ -77,7 +77,7 @@ class Server {
this.abMergeManager = new AbMergeManager(this.db, this.taskManager)
this.playbackSessionManager = new PlaybackSessionManager(this.db)
this.coverManager = new CoverManager(this.db, this.cacheManager)
this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager)
this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager, this.taskManager)
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager)
this.rssFeedManager = new RssFeedManager(this.db)
this.eBookManager = new EBookManager(this.db)

View file

@ -82,6 +82,11 @@ class LibraryController {
return res.json(req.library)
}
async getEpisodeDownloadQueue(req, res) {
const libraryDownloadQueueDetails = this.podcastManager.getDownloadQueueDetails(req.library.id)
return res.json(libraryDownloadQueueDetails)
}
async update(req, res) {
const library = req.library
@ -229,6 +234,16 @@ class LibraryController {
if (payload.sortBy === 'book.volumeNumber') payload.sortBy = null // TODO: Remove temp fix after mobile release 0.9.60
if (filterSeries && !payload.sortBy) {
sortArray.push({ asc: (li) => li.media.metadata.getSeries(filterSeries).sequence })
// If no series sequence then fallback to sorting by title (or collapsed series name for sub-series)
sortArray.push({
asc: (li) => {
if (this.db.serverSettings.sortingIgnorePrefix) {
return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix
} else {
return li.collapsedSeries?.name || li.media.metadata.title
}
}
})
}
if (payload.sortBy) {
@ -637,6 +652,7 @@ class LibraryController {
var authorsWithCount = libraryHelpers.getAuthorsWithCount(libraryItems)
var genresWithCount = libraryHelpers.getGenresWithCount(libraryItems)
var durationStats = libraryHelpers.getItemDurationStats(libraryItems)
var sizeStats = libraryHelpers.getItemSizeStats(libraryItems)
var stats = {
totalItems: libraryItems.length,
totalAuthors: Object.keys(authorsWithCount).length,
@ -645,6 +661,7 @@ class LibraryController {
longestItems: durationStats.longestItems,
numAudioTracks: durationStats.numAudioTracks,
totalSize: libraryHelpers.getLibraryItemsTotalSize(libraryItems),
largestItems: sizeStats.largestItems,
authorsWithCount,
genresWithCount
}
@ -755,4 +772,4 @@ class LibraryController {
next()
}
}
module.exports = new LibraryController()
module.exports = new LibraryController()

View file

@ -36,8 +36,11 @@ class LibraryItemController {
}).filter(au => au)
}
} else if (includeEntities.includes('downloads')) {
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
item.episodesDownloading = downloadsInQueue.map(d => d.toJSONForClient())
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
item.episodeDownloadsQueued = downloadsInQueue.map(d => d.toJSONForClient())
if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {
item.episodesDownloading = [this.podcastManager.currentDownload.toJSONForClient()]
}
}
return res.json(item)

View file

@ -225,6 +225,20 @@ class PodcastController {
res.json(libraryItem.toJSONExpanded())
}
// GET: api/podcasts/:id/episode/:episodeId
async getEpisode(req, res) {
const episodeId = req.params.episodeId
const libraryItem = req.libraryItem
const episode = libraryItem.media.episodes.find(ep => ep.id === episodeId)
if (!episode) {
Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
return res.sendStatus(404)
}
res.json(episode)
}
// DELETE: api/podcasts/:id/episode/:episodeId
async removeEpisode(req, res) {
var episodeId = req.params.episodeId
@ -283,4 +297,4 @@ class PodcastController {
next()
}
}
module.exports = new PodcastController()
module.exports = new PodcastController()

View file

@ -134,4 +134,4 @@ class RSSFeedController {
next()
}
}
module.exports = new RSSFeedController()
module.exports = new RSSFeedController()

View file

@ -14,12 +14,14 @@ const LibraryFile = require('../objects/files/LibraryFile')
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
const PodcastEpisode = require('../objects/entities/PodcastEpisode')
const AudioFile = require('../objects/files/AudioFile')
const Task = require("../objects/Task")
class PodcastManager {
constructor(db, watcher, notificationManager) {
constructor(db, watcher, notificationManager, taskManager) {
this.db = db
this.watcher = watcher
this.notificationManager = notificationManager
this.taskManager = taskManager
this.downloadQueue = []
this.currentDownload = null
@ -56,18 +58,28 @@ class PodcastManager {
newPe.setData(ep, index++)
newPe.libraryItemId = libraryItem.id
var newPeDl = new PodcastEpisodeDownload()
newPeDl.setData(newPe, libraryItem, isAutoDownload)
newPeDl.setData(newPe, libraryItem, isAutoDownload, libraryItem.libraryId)
this.startPodcastEpisodeDownload(newPeDl)
})
}
async startPodcastEpisodeDownload(podcastEpisodeDownload) {
SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails())
if (this.currentDownload) {
this.downloadQueue.push(podcastEpisodeDownload)
SocketAuthority.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient())
return
}
const task = new Task()
const taskDescription = `Downloading episode "${podcastEpisodeDownload.podcastEpisode.title}".`
const taskData = {
libraryId: podcastEpisodeDownload.libraryId,
libraryItemId: podcastEpisodeDownload.libraryItemId,
}
task.setData('download-podcast-episode', 'Downloading Episode', taskDescription, taskData)
this.taskManager.addTask(task)
SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient())
this.currentDownload = podcastEpisodeDownload
@ -81,7 +93,7 @@ class PodcastManager {
await filePerms.setDefault(this.currentDownload.libraryItem.path)
}
var success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath).then(() => true).catch((error) => {
let success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath).then(() => true).catch((error) => {
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
return false
})
@ -90,15 +102,21 @@ class PodcastManager {
if (!success) {
await fs.remove(this.currentDownload.targetPath)
this.currentDownload.setFinished(false)
task.setFailed('Failed to download episode')
} else {
Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.podcastEpisode.title}"`)
this.currentDownload.setFinished(true)
task.setFinished()
}
} else {
task.setFailed('Failed to download episode')
this.currentDownload.setFinished(false)
}
this.taskManager.taskFinished(task)
SocketAuthority.emitter('episode_download_finished', this.currentDownload.toJSONForClient())
SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails())
this.watcher.removeIgnoreDir(this.currentDownload.libraryItem.path)
this.currentDownload = null
@ -329,5 +347,15 @@ class PodcastManager {
feeds: rssFeedData
}
}
getDownloadQueueDetails(libraryId = null) {
let _currentDownload = this.currentDownload
if (libraryId && _currentDownload?.libraryId !== libraryId) _currentDownload = null
return {
currentDownload: _currentDownload?.toJSONForClient(),
queue: this.downloadQueue.filter(item => !libraryId || item.libraryId === libraryId).map(item => item.toJSONForClient())
}
}
}
module.exports = PodcastManager
module.exports = PodcastManager

View file

@ -188,9 +188,12 @@ class RssFeedManager {
async openFeedForItem(user, libraryItem, options) {
const serverAddress = options.serverAddress
const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
const ownerName = options.metadataDetails?.ownerName
const ownerEmail = options.metadataDetails?.ownerEmail
const feed = new Feed()
feed.setFromItem(user.id, slug, libraryItem, serverAddress)
feed.setFromItem(user.id, slug, libraryItem, serverAddress, preventIndexing, ownerName, ownerEmail)
this.feeds[feed.id] = feed
Logger.debug(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
@ -202,9 +205,12 @@ class RssFeedManager {
async openFeedForCollection(user, collectionExpanded, options) {
const serverAddress = options.serverAddress
const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
const ownerName = options.metadataDetails?.ownerName
const ownerEmail = options.metadataDetails?.ownerEmail
const feed = new Feed()
feed.setFromCollection(user.id, slug, collectionExpanded, serverAddress)
feed.setFromCollection(user.id, slug, collectionExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
this.feeds[feed.id] = feed
Logger.debug(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
@ -216,9 +222,12 @@ class RssFeedManager {
async openFeedForSeries(user, seriesExpanded, options) {
const serverAddress = options.serverAddress
const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
const ownerName = options.metadataDetails?.ownerName
const ownerEmail = options.metadataDetails?.ownerEmail
const feed = new Feed()
feed.setFromSeries(user.id, slug, seriesExpanded, serverAddress)
feed.setFromSeries(user.id, slug, seriesExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
this.feeds[feed.id] = feed
Logger.debug(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
@ -246,4 +255,4 @@ class RssFeedManager {
return this.handleCloseFeed(feed)
}
}
module.exports = RssFeedManager
module.exports = RssFeedManager

View file

@ -70,17 +70,19 @@ class Feed {
id: this.id,
entityType: this.entityType,
entityId: this.entityId,
feedUrl: this.feedUrl
feedUrl: this.feedUrl,
meta: this.meta.toJSONMinified(),
}
}
getEpisodePath(id) {
var episode = this.episodes.find(ep => ep.id === id)
console.log('getEpisodePath=', id, episode)
if (!episode) return null
return episode.fullPath
}
setFromItem(userId, slug, libraryItem, serverAddress) {
setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
const media = libraryItem.media
const mediaMetadata = media.metadata
const isPodcast = libraryItem.mediaType === 'podcast'
@ -106,6 +108,11 @@ class Feed {
this.meta.feedUrl = feedUrl
this.meta.link = `${serverAddress}/item/${libraryItem.id}`
this.meta.explicit = !!mediaMetadata.explicit
this.meta.type = mediaMetadata.type
this.meta.language = mediaMetadata.language
this.meta.preventIndexing = preventIndexing
this.meta.ownerName = ownerName
this.meta.ownerEmail = ownerEmail
this.episodes = []
if (isPodcast) { // PODCAST EPISODES
@ -142,6 +149,8 @@ class Feed {
this.meta.author = author
this.meta.imageUrl = media.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover` : `${this.serverAddress}/Logo.png`
this.meta.explicit = !!mediaMetadata.explicit
this.meta.type = mediaMetadata.type
this.meta.language = mediaMetadata.language
this.episodes = []
if (isPodcast) { // PODCAST EPISODES
@ -333,4 +342,4 @@ class Feed {
return author
}
}
module.exports = Feed
module.exports = Feed

View file

@ -14,6 +14,9 @@ class FeedEpisode {
this.author = null
this.explicit = null
this.duration = null
this.season = null
this.episode = null
this.episodeType = null
this.libraryItemId = null
this.episodeId = null
@ -35,6 +38,9 @@ class FeedEpisode {
this.author = episode.author
this.explicit = episode.explicit
this.duration = episode.duration
this.season = episode.season
this.episode = episode.episode
this.episodeType = episode.episodeType
this.libraryItemId = episode.libraryItemId
this.episodeId = episode.episodeId || null
this.trackIndex = episode.trackIndex || 0
@ -52,6 +58,9 @@ class FeedEpisode {
author: this.author,
explicit: this.explicit,
duration: this.duration,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
libraryItemId: this.libraryItemId,
episodeId: this.episodeId,
trackIndex: this.trackIndex,
@ -77,25 +86,31 @@ class FeedEpisode {
this.author = meta.author
this.explicit = mediaMetadata.explicit
this.duration = episode.duration
this.season = episode.season
this.episode = episode.episode
this.episodeType = episode.episodeType
this.libraryItemId = libraryItem.id
this.episodeId = episode.id
this.trackIndex = 0
this.fullPath = episode.audioFile.metadata.path
}
setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta, additionalOffset = 0) {
setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta, additionalOffset = null) {
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
let timeOffset = isNaN(audioTrack.index) ? 0 : (Number(audioTrack.index) * 1000) // Offset pubdate to ensure correct order
let episodeId = String(audioTrack.index)
// Additional offset can be used for collections/series
if (additionalOffset && !isNaN(additionalOffset)) {
if (additionalOffset !== null && !isNaN(additionalOffset)) {
timeOffset += Number(additionalOffset) * 1000
episodeId = String(additionalOffset) + '-' + episodeId
}
// 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/${audioTrack.index}/${audioTrack.metadata.filename}`
const contentUrl = `/feed/${slug}/item/${episodeId}/${audioTrack.metadata.filename}`
const media = libraryItem.media
const mediaMetadata = media.metadata
@ -110,7 +125,7 @@ class FeedEpisode {
}
}
this.id = String(audioTrack.index)
this.id = episodeId
this.title = title
this.description = mediaMetadata.description || ''
this.enclosure = {
@ -144,9 +159,12 @@ class FeedEpisode {
{ 'itunes:summary': this.description || '' },
{
"itunes:explicit": !!this.explicit
}
},
{ "itunes:episodeType": this.episodeType },
{ "itunes:season": this.season },
{ "itunes:episode": this.episode }
]
}
}
}
module.exports = FeedEpisode
module.exports = FeedEpisode

View file

@ -7,6 +7,11 @@ class FeedMeta {
this.feedUrl = null
this.link = null
this.explicit = null
this.type = null
this.language = null
this.preventIndexing = null
this.ownerName = null
this.ownerEmail = null
if (meta) {
this.construct(meta)
@ -21,6 +26,11 @@ class FeedMeta {
this.feedUrl = meta.feedUrl
this.link = meta.link
this.explicit = meta.explicit
this.type = meta.type
this.language = meta.language
this.preventIndexing = meta.preventIndexing
this.ownerName = meta.ownerName
this.ownerEmail = meta.ownerEmail
}
toJSON() {
@ -31,7 +41,22 @@ class FeedMeta {
imageUrl: this.imageUrl,
feedUrl: this.feedUrl,
link: this.link,
explicit: this.explicit
explicit: this.explicit,
type: this.type,
language: this.language,
preventIndexing: this.preventIndexing,
ownerName: this.ownerName,
ownerEmail: this.ownerEmail
}
}
toJSONMinified() {
return {
title: this.title,
description: this.description,
preventIndexing: this.preventIndexing,
ownerName: this.ownerName,
ownerEmail: this.ownerEmail
}
}
@ -43,16 +68,18 @@ class FeedMeta {
feed_url: this.feedUrl,
site_url: this.link,
image_url: this.imageUrl,
language: 'en',
custom_namespaces: {
'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd',
'psc': 'http://podlove.org/simple-chapters',
'podcast': 'https://podcastindex.org/namespace/1.0'
'podcast': 'https://podcastindex.org/namespace/1.0',
'googleplay': 'http://www.google.com/schemas/play-podcasts/1.0'
},
custom_elements: [
{ 'language': this.language || 'en' },
{ 'author': this.author || 'advplyr' },
{ 'itunes:author': this.author || 'advplyr' },
{ 'itunes:summary': this.description || '' },
{ 'itunes:type': this.type },
{
'itunes:image': {
_attr: {
@ -62,15 +89,15 @@ class FeedMeta {
},
{
'itunes:owner': [
{ 'itunes:name': this.author || '' },
{ 'itunes:email': '' }
{ 'itunes:name': this.ownerName || this.author || '' },
{ 'itunes:email': this.ownerEmail || '' }
]
},
{
"itunes:explicit": !!this.explicit
}
{ 'itunes:explicit': !!this.explicit },
{ 'itunes:block': this.preventIndexing?"Yes":"No" },
{ 'googleplay:block': this.preventIndexing?"yes":"no" }
]
}
}
}
module.exports = FeedMeta
module.exports = FeedMeta

View file

@ -197,9 +197,15 @@ class LibraryItem {
if (key === 'libraryFiles') {
this.libraryFiles = payload.libraryFiles.map(lf => lf.clone())
// Use first image library file as cover
const firstImageFile = this.libraryFiles.find(lf => lf.fileType === 'image')
if (firstImageFile) this.media.coverPath = firstImageFile.metadata.path
// Set cover image
const imageFiles = this.libraryFiles.filter(lf => lf.fileType === 'image')
const coverMatch = imageFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
if (coverMatch) {
this.media.coverPath = coverMatch.metadata.path
} else if (imageFiles.length) {
this.media.coverPath = imageFiles[0].metadata.path
}
} else if (this[key] !== undefined && key !== 'media') {
this[key] = payload[key]
}
@ -330,6 +336,7 @@ class LibraryItem {
}
if (dataFound.ino !== this.ino) {
Logger.warn(`[LibraryItem] Check scan item changed inode "${this.ino}" -> "${dataFound.ino}"`)
this.ino = dataFound.ino
hasUpdated = true
}
@ -341,7 +348,7 @@ class LibraryItem {
}
if (dataFound.path !== this.path) {
Logger.warn(`[LibraryItem] Check scan item changed path "${this.path}" -> "${dataFound.path}"`)
Logger.warn(`[LibraryItem] Check scan item changed path "${this.path}" -> "${dataFound.path}" (inode ${this.ino})`)
this.path = dataFound.path
this.relPath = dataFound.relPath
hasUpdated = true
@ -444,8 +451,14 @@ class LibraryItem {
// Set cover image if not set
const imageFiles = this.libraryFiles.filter(lf => lf.fileType === 'image')
if (imageFiles.length && !this.media.coverPath) {
this.media.coverPath = imageFiles[0].metadata.path
Logger.debug('[LibraryItem] Set media cover path', this.media.coverPath)
// attempt to find a file called cover.<ext> otherwise just fall back to the first image found
const coverMatch = imageFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
if (coverMatch) {
this.media.coverPath = coverMatch.metadata.path
} else {
this.media.coverPath = imageFiles[0].metadata.path
}
Logger.info('[LibraryItem] Set media cover path', this.media.coverPath)
hasUpdated = true
}

View file

@ -1,6 +1,7 @@
const Path = require('path')
const { getId } = require('../utils/index')
const { sanitizeFilename } = require('../utils/fileUtils')
const globals = require('../utils/globals')
class PodcastEpisodeDownload {
constructor() {
@ -8,9 +9,9 @@ class PodcastEpisodeDownload {
this.podcastEpisode = null
this.url = null
this.libraryItem = null
this.libraryId = null
this.isAutoDownload = false
this.isDownloading = false
this.isFinished = false
this.failed = false
@ -22,20 +23,32 @@ class PodcastEpisodeDownload {
toJSONForClient() {
return {
id: this.id,
episodeDisplayTitle: this.podcastEpisode ? this.podcastEpisode.title : null,
episodeDisplayTitle: this.podcastEpisode?.title ?? null,
url: this.url,
libraryItemId: this.libraryItem ? this.libraryItem.id : null,
isDownloading: this.isDownloading,
libraryItemId: this.libraryItem?.id || null,
libraryId: this.libraryId || null,
isFinished: this.isFinished,
failed: this.failed,
startedAt: this.startedAt,
createdAt: this.createdAt,
finishedAt: this.finishedAt
finishedAt: this.finishedAt,
podcastTitle: this.libraryItem?.media.metadata.title ?? null,
podcastExplicit: !!this.libraryItem?.media.metadata.explicit,
season: this.podcastEpisode?.season ?? null,
episode: this.podcastEpisode?.episode ?? null,
episodeType: this.podcastEpisode?.episodeType ?? 'full',
publishedAt: this.podcastEpisode?.publishedAt ?? null
}
}
get fileExtension() {
const extname = Path.extname(this.url).substring(1).toLowerCase()
if (globals.SupportedAudioTypes.includes(extname)) return extname
return 'mp3'
}
get targetFilename() {
return sanitizeFilename(`${this.podcastEpisode.title}.mp3`)
return sanitizeFilename(`${this.podcastEpisode.title}.${this.fileExtension}`)
}
get targetPath() {
return Path.join(this.libraryItem.path, this.targetFilename)
@ -47,13 +60,21 @@ class PodcastEpisodeDownload {
return this.libraryItem ? this.libraryItem.id : null
}
setData(podcastEpisode, libraryItem, isAutoDownload) {
setData(podcastEpisode, libraryItem, isAutoDownload, libraryId) {
this.id = getId('epdl')
this.podcastEpisode = podcastEpisode
this.url = podcastEpisode.enclosure.url
const url = podcastEpisode.enclosure.url
if (decodeURIComponent(url) !== url) { // Already encoded
this.url = url
} else {
this.url = encodeURI(url)
}
this.libraryItem = libraryItem
this.isAutoDownload = isAutoDownload
this.createdAt = Date.now()
this.libraryId = libraryId
}
setFinished(success) {
@ -62,4 +83,4 @@ class PodcastEpisodeDownload {
this.failed = !success
}
}
module.exports = PodcastEpisodeDownload
module.exports = PodcastEpisodeDownload

View file

@ -82,7 +82,8 @@ class Stream extends EventEmitter {
AudioMimeType.WMA,
AudioMimeType.AIFF,
AudioMimeType.WEBM,
AudioMimeType.WEBMA
AudioMimeType.WEBMA,
AudioMimeType.AWB
]
}
get codecsToForceAAC() {

View file

@ -117,7 +117,7 @@ class PodcastEpisode {
this.enclosure = data.enclosure ? { ...data.enclosure } : null
this.season = data.season || ''
this.episode = data.episode || ''
this.episodeType = data.episodeType || ''
this.episodeType = data.episodeType || 'full'
this.publishedAt = data.publishedAt || 0
this.addedAt = Date.now()
this.updatedAt = Date.now()
@ -165,4 +165,4 @@ class PodcastEpisode {
return cleanStringForSearch(this.title).includes(query)
}
}
module.exports = PodcastEpisode
module.exports = PodcastEpisode

View file

@ -296,7 +296,7 @@ class Book {
})
}
} else if (key === 'narrators') {
if (opfMetadata.narrators && opfMetadata.narrators.length && (!this.metadata.narrators.length || opfMetadataOverrideDetails)) {
if (opfMetadata.narrators?.length && (!this.metadata.narrators.length || opfMetadataOverrideDetails)) {
metadataUpdatePayload.narrators = opfMetadata.narrators
}
} else if (key === 'series') {
@ -356,9 +356,9 @@ class Book {
}
updateAudioTracks(orderedFileData) {
var index = 1
let index = 1
this.audioFiles = orderedFileData.map((fileData) => {
var audioFile = this.audioFiles.find(af => af.ino === fileData.ino)
const audioFile = this.audioFiles.find(af => af.ino === fileData.ino)
audioFile.manuallyVerified = true
audioFile.invalid = false
audioFile.error = null
@ -376,11 +376,11 @@ class Book {
this.rebuildTracks()
}
rebuildTracks(preferOverdriveMediaMarker) {
rebuildTracks() {
Logger.debug(`[Book] Tracks being rebuilt...!`)
this.audioFiles.sort((a, b) => a.index - b.index)
this.missingParts = []
this.setChapters(preferOverdriveMediaMarker)
this.setChapters()
this.checkUpdateMissingTracks()
}
@ -412,14 +412,16 @@ class Book {
return wasUpdated
}
setChapters(preferOverdriveMediaMarker = false) {
setChapters() {
const preferOverdriveMediaMarker = !!global.ServerSettings.scannerPreferOverdriveMediaMarker
// If 1 audio file without chapters, then no chapters will be set
var includedAudioFiles = this.audioFiles.filter(af => !af.exclude)
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) {
var overdriveChapters = parseOverdriveMediaMarkersAsChapters(includedAudioFiles)
const overdriveChapters = parseOverdriveMediaMarkersAsChapters(includedAudioFiles)
if (overdriveChapters) {
Logger.info('[Book] Overdrive Media Markers and preference found! Using these for chapter definitions')
this.chapters = overdriveChapters
@ -460,17 +462,26 @@ class Book {
})
}
} else if (includedAudioFiles.length > 1) {
const preferAudioMetadata = !!global.ServerSettings.scannerPreferAudioMetadata
// Build chapters from audio files
this.chapters = []
var currChapterId = 0
var currStartTime = 0
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: file.metadata.filename ? Path.basename(file.metadata.filename, Path.extname(file.metadata.filename)) : `Chapter ${currChapterId}`
title
})
currStartTime += file.duration
}

View file

@ -17,6 +17,7 @@ class BookMetadata {
this.asin = null
this.language = null
this.explicit = false
this.abridged = false
if (metadata) {
this.construct(metadata)
@ -38,6 +39,7 @@ class BookMetadata {
this.asin = metadata.asin
this.language = metadata.language
this.explicit = !!metadata.explicit
this.abridged = !!metadata.abridged
}
toJSON() {
@ -55,7 +57,8 @@ class BookMetadata {
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: this.explicit
explicit: this.explicit,
abridged: this.abridged
}
}
@ -76,7 +79,8 @@ class BookMetadata {
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: this.explicit
explicit: this.explicit,
abridged: this.abridged
}
}
@ -100,7 +104,8 @@ class BookMetadata {
authorName: this.authorName,
authorNameLF: this.authorNameLF,
narratorName: this.narratorName,
seriesName: this.seriesName
seriesName: this.seriesName,
abridged: this.abridged
}
}

View file

@ -15,6 +15,7 @@ class PodcastMetadata {
this.itunesArtistId = null
this.explicit = false
this.language = null
this.type = null
if (metadata) {
this.construct(metadata)
@ -34,6 +35,7 @@ class PodcastMetadata {
this.itunesArtistId = metadata.itunesArtistId
this.explicit = metadata.explicit
this.language = metadata.language || null
this.type = metadata.type || 'episodic'
}
toJSON() {
@ -49,7 +51,8 @@ class PodcastMetadata {
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
explicit: this.explicit,
language: this.language
language: this.language,
type: this.type
}
}
@ -67,7 +70,8 @@ class PodcastMetadata {
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
explicit: this.explicit,
language: this.language
language: this.language,
type: this.type
}
}
@ -112,6 +116,7 @@ class PodcastMetadata {
this.itunesArtistId = mediaMetadata.itunesArtistId || null
this.explicit = !!mediaMetadata.explicit
this.language = mediaMetadata.language || null
this.type = mediaMetadata.type || null
if (mediaMetadata.genres && mediaMetadata.genres.length) {
this.genres = [...mediaMetadata.genres]
}
@ -132,4 +137,4 @@ class PodcastMetadata {
return hasUpdates
}
}
module.exports = PodcastMetadata
module.exports = PodcastMetadata

View file

@ -51,6 +51,7 @@ class ServerSettings {
this.chromecastEnabled = false
this.enableEReader = false
this.dateFormat = 'MM/dd/yyyy'
this.timeFormat = 'HH:mm'
this.language = 'en-us'
this.logLevel = Logger.logLevel
@ -106,6 +107,7 @@ class ServerSettings {
this.chromecastEnabled = !!settings.chromecastEnabled
this.enableEReader = !!settings.enableEReader
this.dateFormat = settings.dateFormat || 'MM/dd/yyyy'
this.timeFormat = settings.timeFormat || 'HH:mm'
this.language = settings.language || 'en-us'
this.logLevel = settings.logLevel || Logger.logLevel
this.version = settings.version || null
@ -180,6 +182,7 @@ class ServerSettings {
chromecastEnabled: this.chromecastEnabled,
enableEReader: this.enableEReader,
dateFormat: this.dateFormat,
timeFormat: this.timeFormat,
language: this.language,
logLevel: this.logLevel,
version: this.version,
@ -218,4 +221,4 @@ class ServerSettings {
return hasUpdates
}
}
module.exports = ServerSettings
module.exports = ServerSettings

View file

@ -19,7 +19,7 @@ class Audible {
}
cleanResult(item) {
const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin } = item
const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin, formatType } = item
const series = []
if (seriesPrimary) {
@ -54,7 +54,8 @@ class Audible {
language: language ? language.charAt(0).toUpperCase() + language.slice(1) : null,
duration: runtimeLengthMin && !isNaN(runtimeLengthMin) ? Number(runtimeLengthMin) : 0,
region: item.region || null,
rating: item.rating || null
rating: item.rating || null,
abridged: formatType === 'abridged'
}
}

View file

@ -95,7 +95,8 @@ class iTunes {
cover: this.getCoverArtwork(data),
trackCount: data.trackCount,
feedUrl: data.feedUrl,
pageUrl: data.collectionViewUrl
pageUrl: data.collectionViewUrl,
explicit: data.trackExplicitness === 'explicit'
}
}
@ -105,4 +106,4 @@ class iTunes {
})
}
}
module.exports = iTunes
module.exports = iTunes

View file

@ -76,6 +76,7 @@ class ApiRouter {
this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this))
this.router.delete('/libraries/:id/issues', LibraryController.middleware.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this))
this.router.get('/libraries/:id/episode-downloads', LibraryController.middleware.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this))
this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this))
this.router.get('/libraries/:id/playlists', LibraryController.middleware.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this))
@ -235,6 +236,7 @@ class ApiRouter {
this.router.get('/podcasts/:id/search-episode', PodcastController.middleware.bind(this), PodcastController.findEpisode.bind(this))
this.router.post('/podcasts/:id/download-episodes', PodcastController.middleware.bind(this), PodcastController.downloadEpisodes.bind(this))
this.router.post('/podcasts/:id/match-episodes', PodcastController.middleware.bind(this), PodcastController.quickMatchEpisodes.bind(this))
this.router.get('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.getEpisode.bind(this))
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.updateEpisode.bind(this))
this.router.delete('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.removeEpisode.bind(this))
@ -553,4 +555,4 @@ class ApiRouter {
}
}
}
module.exports = ApiRouter
module.exports = ApiRouter

View file

@ -221,7 +221,7 @@ class MediaFileScanner {
*/
async scanMediaFiles(mediaLibraryFiles, libraryItem, libraryScan = null) {
const preferAudioMetadata = libraryScan ? !!libraryScan.preferAudioMetadata : !!global.ServerSettings.scannerPreferAudioMetadata
const preferOverdriveMediaMarker = libraryScan ? !!libraryScan.preferOverdriveMediaMarker : !!global.ServerSettings.scannerPreferOverdriveMediaMarker
const preferOverdriveMediaMarker = !!global.ServerSettings.scannerPreferOverdriveMediaMarker
let hasUpdated = false
@ -280,7 +280,7 @@ class MediaFileScanner {
}
if (hasUpdated) {
libraryItem.media.rebuildTracks(preferOverdriveMediaMarker)
libraryItem.media.rebuildTracks()
}
} else if (libraryItem.mediaType === 'podcast') { // Podcast Media Type
const existingAudioFiles = mediaScanResult.audioFiles.filter(af => libraryItem.media.findFileWithInode(af.ino))

View file

@ -201,6 +201,7 @@ class Scanner {
const dataFound = libraryItemDataFound.find(lid => lid.ino === libraryItem.ino || comparePaths(lid.relPath, libraryItem.relPath))
if (!dataFound) {
libraryScan.addLog(LogLevel.WARN, `Library Item "${libraryItem.media.metadata.title}" is missing`)
Logger.warn(`[Scanner] Library item "${libraryItem.media.metadata.title}" is missing (inode "${libraryItem.ino}")`)
libraryScan.resultsMissing++
libraryItem.setMissing()
itemsToUpdate.push(libraryItem)
@ -792,7 +793,7 @@ class Scanner {
async quickMatchBookBuildUpdatePayload(libraryItem, matchData, options) {
// Update media metadata if not set OR overrideDetails flag
const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'asin', 'isbn']
const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'abridged', 'asin', 'isbn']
const updatePayload = {}
updatePayload.metadata = {}
@ -899,7 +900,7 @@ class Scanner {
description: episodeToMatch.description || '',
enclosure: episodeToMatch.enclosure || null,
episode: episodeToMatch.episode || '',
episodeType: episodeToMatch.episodeType || '',
episodeType: episodeToMatch.episodeType || 'full',
season: episodeToMatch.season || '',
pubDate: episodeToMatch.pubDate || '',
publishedAt: episodeToMatch.publishedAt
@ -993,4 +994,4 @@ class Scanner {
return MediaFileScanner.probeAudioFileWithTone(audioFile)
}
}
module.exports = Scanner
module.exports = Scanner

View file

@ -121,6 +121,10 @@ const bookMetadataMapper = {
explicit: {
to: (m) => m.explicit ? 'Y' : 'N',
from: (v) => v && v.toLowerCase() == 'y'
},
abridged: {
to: (m) => m.abridged ? 'Y' : 'N',
from: (v) => v && v.toLowerCase() == 'y'
}
}

View file

@ -46,7 +46,9 @@ module.exports.AudioMimeType = {
WMA: 'audio/x-ms-wma',
AIFF: 'audio/x-aiff',
WEBM: 'audio/webm',
WEBMA: 'audio/webm'
WEBMA: 'audio/webm',
MKA: 'audio/x-matroska',
AWB: 'audio/amr-wb'
}
module.exports.VideoMimeType = {

View file

@ -107,7 +107,6 @@ module.exports.setDefaultDirSync = (path, silent = false) => {
const uid = global.Uid
const gid = global.Gid
if (isNaN(uid) || isNaN(gid)) {
if (!silent) Logger.debug('Not modifying permissions since no uid/gid is specified')
return true
}
if (!silent) Logger.debug(`[FilePerms] Setting dir permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)

View file

@ -1,6 +1,6 @@
const globals = {
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma'],
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb'],
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
SupportedVideoTypes: ['mp4'],
TextFileTypes: ['txt', 'nfo'],

View file

@ -1,10 +1,10 @@
const sanitizeHtml = require('../libs/sanitizeHtml')
const {entities} = require("./htmlEntities");
const { entities } = require("./htmlEntities");
function sanitize(html) {
const sanitizerOptions = {
allowedTags: [
'p', 'ol', 'ul', 'li', 'a', 'strong', 'em', 'del'
'p', 'ol', 'ul', 'li', 'a', 'strong', 'em', 'del', 'br'
],
disallowedTagsMode: 'discard',
allowedAttributes: {

View file

@ -108,7 +108,7 @@ module.exports.reqSupportsWebp = (req) => {
module.exports.areEquivalent = areEquivalent
module.exports.copyValue = (val) => {
if (!val) return null
if (!val) return val === false ? false : null
if (!this.isObject(val)) return val
if (Array.isArray(val)) {

View file

@ -67,6 +67,8 @@ module.exports = {
filtered = filtered.filter(li => li.hasIssues)
} else if (filterBy === 'feed-open') {
filtered = filtered.filter(li => feedsArray.some(feed => feed.entityId === li.id))
} else if (filterBy === 'abridged') {
filtered = filtered.filter(li => !!li.media.metadata?.abridged)
}
return filtered
@ -95,17 +97,20 @@ module.exports = {
checkSeriesProgressFilter(series, filterBy, user) {
const filter = this.decode(filterBy.split('.')[1])
var numBooksStartedOrFinished = 0
let someBookHasProgress = false
let someBookIsUnfinished = false
for (const libraryItem of series.books) {
const itemProgress = user.getMediaProgress(libraryItem.id)
if (filter === 'Finished' && (!itemProgress || !itemProgress.isFinished)) return false
if (filter === 'Not Started' && itemProgress) return false
if (itemProgress) numBooksStartedOrFinished++
if (!itemProgress || !itemProgress.isFinished) someBookIsUnfinished = true
if (itemProgress && itemProgress.progress > 0) someBookHasProgress = true
if (filter === 'finished' && (!itemProgress || !itemProgress.isFinished)) return false
if (filter === 'not-started' && itemProgress) return false
}
if (numBooksStartedOrFinished === series.books.length) { // Completely finished series
if (filter === 'Not Finished') return false
} else if (numBooksStartedOrFinished === 0 && filter === 'In Progress') { // Series not started
if (!someBookIsUnfinished && filter === 'not-finished') { // Completely finished series
return false
} else if (!someBookHasProgress && filter === 'in-progress') { // Series not started
return false
}
return true
@ -280,6 +285,19 @@ module.exports = {
}
},
getItemSizeStats(libraryItems) {
var sorted = sort(libraryItems).desc(li => li.media.size)
var top10 = sorted.slice(0, 10).map(li => ({ id: li.id, title: li.media.metadata.title, size: li.media.size })).filter(i => i.size > 0)
var totalSize = 0
libraryItems.forEach((li) => {
totalSize += li.media.size
})
return {
totalSize,
largestItems: top10
}
},
getLibraryItemsTotalSize(libraryItems) {
var totalSize = 0
libraryItems.forEach((li) => {
@ -843,4 +861,4 @@ module.exports = {
return Object.values(albums)
}
}
}

View file

@ -43,6 +43,8 @@ module.exports.parse = (nameString) => {
// Example &LF: Friedman, Milton & Friedman, Rose
if (nameString.includes('&')) {
nameString.split('&').forEach((asa) => splitNames = splitNames.concat(asa.split(',')))
} else if (nameString.includes(';')) {
nameString.split(';').forEach((asa) => splitNames = splitNames.concat(asa.split(',')))
} else {
splitNames = nameString.split(',')
}

View file

@ -111,7 +111,7 @@ function fetchVolumeNumber(metadataMeta) {
function fetchNarrators(creators, metadata) {
const narrators = fetchCreators(creators, 'nrt')
if (typeof metadata.meta == "undefined" || narrators.length) return narrators
if (narrators?.length) return narrators
try {
const narratorsJSON = JSON.parse(fetchTagString(metadata.meta, "calibre:user_metadata:#narrators").replace(/&quot;/g, '"'))
return narratorsJSON["#value#"]
@ -150,7 +150,7 @@ module.exports.parseOpfMetadataXML = async (xml) => {
const metadataMeta = prefix ? metadata[`${prefix}:meta`] || metadata.meta : metadata.meta
metadata.meta = {}
if (metadataMeta && metadataMeta.length) {
if (metadataMeta?.length) {
metadataMeta.forEach((meta) => {
if (meta && meta['$'] && meta['$'].name) {
metadata.meta[meta['$'].name] = [meta['$'].content || '']

View file

@ -46,7 +46,8 @@ function extractPodcastMetadata(channel) {
categories: extractCategories(channel),
feedUrl: null,
description: null,
descriptionPlain: null
descriptionPlain: null,
type: null
}
if (channel['itunes:new-feed-url']) {
@ -61,7 +62,7 @@ function extractPodcastMetadata(channel) {
metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription)
}
var arrayFields = ['title', 'language', 'itunes:explicit', 'itunes:author', 'pubDate', 'link']
var arrayFields = ['title', 'language', 'itunes:explicit', 'itunes:author', 'pubDate', 'link', 'itunes:type']
arrayFields.forEach((key) => {
var cleanKey = key.split(':').pop()
metadata[cleanKey] = extractFirstArrayItem(channel, key)
@ -258,4 +259,4 @@ module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => {
}
})
return matches.sort((a, b) => a.levenshtein - b.levenshtein)
}
}