@@ -168,6 +168,9 @@ export default {
console.error('No item...', params.id)
return redirect('/')
}
+ if (store.state.libraries.currentLibraryId !== item.libraryId || !store.state.libraries.filterData) {
+ await store.dispatch('libraries/fetch', item.libraryId)
+ }
return {
libraryItem: item,
rssFeed: item.rssFeed || null,
@@ -791,10 +794,6 @@ export default {
this.episodeDownloadsQueued = this.libraryItem.episodeDownloadsQueued || []
this.episodesDownloading = this.libraryItem.episodesDownloading || []
- // use this items library id as the current
- if (this.libraryId) {
- this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
- }
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
this.$root.socket.on('item_updated', this.libraryItemUpdated)
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
diff --git a/client/pages/library/_library/authors/index.vue b/client/pages/library/_library/authors/index.vue
index a98a06c95d..75e9f083ec 100644
--- a/client/pages/library/_library/authors/index.vue
+++ b/client/pages/library/_library/authors/index.vue
@@ -63,7 +63,7 @@ export default {
if (typeof a[sortProp] === 'number' && typeof b[sortProp] === 'number') {
return a[sortProp] > b[sortProp] ? bDesc : -bDesc
}
- return a[sortProp].localeCompare(b[sortProp], undefined, { sensitivity: 'base' }) * bDesc
+ return a[sortProp]?.localeCompare(b[sortProp], undefined, { sensitivity: 'base' }) * bDesc
})
}
},
diff --git a/client/pages/library/_library/podcast/download-queue.vue b/client/pages/library/_library/podcast/download-queue.vue
index dd57748dbc..49b4d4da69 100644
--- a/client/pages/library/_library/podcast/download-queue.vue
+++ b/client/pages/library/_library/podcast/download-queue.vue
@@ -54,11 +54,19 @@
diff --git a/client/pages/playlist/_id.vue b/client/pages/playlist/_id.vue
index 06a893a85d..d36c9ea3c3 100644
--- a/client/pages/playlist/_id.vue
+++ b/client/pages/playlist/_id.vue
@@ -15,7 +15,7 @@
diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue
index 547f5b05a1..2d0aa13136 100644
--- a/client/pages/upload/index.vue
+++ b/client/pages/upload/index.vue
@@ -20,7 +20,7 @@
diff --git a/client/players/PlayerHandler.js b/client/players/PlayerHandler.js
index 660ca2c176..42d76bd033 100644
--- a/client/players/PlayerHandler.js
+++ b/client/players/PlayerHandler.js
@@ -36,10 +36,10 @@ export default class PlayerHandler {
return this.libraryItem ? this.libraryItem.id : null
}
get isPlayingCastedItem() {
- return this.libraryItem && (this.player instanceof CastPlayer)
+ return this.libraryItem && this.player instanceof CastPlayer
}
get isPlayingLocalItem() {
- return this.libraryItem && (this.player instanceof LocalAudioPlayer)
+ return this.libraryItem && this.player instanceof LocalAudioPlayer
}
get userToken() {
return this.ctx.$store.getters['user/getToken']
@@ -49,7 +49,13 @@ export default class PlayerHandler {
}
get episode() {
if (!this.episodeId) return null
- return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId)
+ return this.libraryItem.media.episodes.find((ep) => ep.id === this.episodeId)
+ }
+ get jumpForwardAmount() {
+ return this.ctx.$store.getters['user/getUserSetting']('jumpForwardAmount')
+ }
+ get jumpBackwardAmount() {
+ return this.ctx.$store.getters['user/getUserSetting']('jumpBackwardAmount')
}
setSessionId(sessionId) {
@@ -66,7 +72,7 @@ export default class PlayerHandler {
this.playWhenReady = playWhenReady
this.initialPlaybackRate = this.isMusic ? 1 : playbackRate
- this.startTimeOverride = (startTimeOverride == null || isNaN(startTimeOverride)) ? undefined : Number(startTimeOverride)
+ this.startTimeOverride = startTimeOverride == null || isNaN(startTimeOverride) ? undefined : Number(startTimeOverride)
if (!this.player) this.switchPlayer(playWhenReady)
else this.prepare()
@@ -127,7 +133,7 @@ export default class PlayerHandler {
playerError() {
// Switch to HLS stream on error
- if (!this.isCasting && (this.player instanceof LocalAudioPlayer)) {
+ if (!this.isCasting && this.player instanceof LocalAudioPlayer) {
console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
this.prepare(true)
}
@@ -207,7 +213,8 @@ export default class PlayerHandler {
this.prepareSession(session)
}
- prepareOpenSession(session, playbackRate) { // Session opened on init socket
+ prepareOpenSession(session, playbackRate) {
+ // Session opened on init socket
if (!this.player) this.switchPlayer() // Must set player first for open sessions
this.libraryItem = session.libraryItem
@@ -241,7 +248,7 @@ export default class PlayerHandler {
this.player.set(this.libraryItem, videoTrack, this.isHlsTranscode, this.startTime, this.playWhenReady)
} else {
- var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
+ var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, this.userToken))
this.ctx.playerLoading = true
this.isHlsTranscode = true
@@ -295,7 +302,7 @@ export default class PlayerHandler {
const currentTime = this.player.getCurrentTime()
this.ctx.setCurrentTime(currentTime)
- const exactTimeElapsed = ((Date.now() - lastTick) / 1000)
+ const exactTimeElapsed = (Date.now() - lastTick) / 1000
lastTick = Date.now()
this.listeningTimeSinceSync += exactTimeElapsed
const TimeToWaitBeforeSync = this.lastSyncTime > 0 ? 10 : 20
@@ -320,7 +327,7 @@ export default class PlayerHandler {
}
this.listeningTimeSinceSync = 0
this.lastSyncTime = 0
- return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 6000 }).catch((error) => {
+ return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 6000, progress: false }).catch((error) => {
console.error('Failed to close session', error)
})
}
@@ -340,17 +347,20 @@ export default class PlayerHandler {
}
this.listeningTimeSinceSync = 0
- this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 9000 }).then(() => {
- this.failedProgressSyncs = 0
- }).catch((error) => {
- console.error('Failed to update session progress', error)
- // After 4 failed sync attempts show an alert toast
- this.failedProgressSyncs++
- if (this.failedProgressSyncs >= 4) {
- this.ctx.showFailedProgressSyncs()
+ this.ctx.$axios
+ .$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 9000, progress: false })
+ .then(() => {
this.failedProgressSyncs = 0
- }
- })
+ })
+ .catch((error) => {
+ console.error('Failed to update session progress', error)
+ // After 4 failed sync attempts show an alert toast
+ this.failedProgressSyncs++
+ if (this.failedProgressSyncs >= 4) {
+ this.ctx.showFailedProgressSyncs()
+ this.failedProgressSyncs = 0
+ }
+ })
}
stopPlayInterval() {
@@ -381,13 +391,15 @@ export default class PlayerHandler {
jumpBackward() {
if (!this.player) return
var currentTime = this.getCurrentTime()
- this.seek(Math.max(0, currentTime - 10))
+ const jumpAmount = this.jumpBackwardAmount
+ this.seek(Math.max(0, currentTime - jumpAmount))
}
jumpForward() {
if (!this.player) return
var currentTime = this.getCurrentTime()
- this.seek(Math.min(currentTime + 10, this.getDuration()))
+ const jumpAmount = this.jumpForwardAmount
+ this.seek(Math.min(currentTime + jumpAmount, this.getDuration()))
}
setVolume(volume) {
@@ -411,4 +423,4 @@ export default class PlayerHandler {
this.sendProgressSync(time)
}
}
-}
\ No newline at end of file
+}
diff --git a/client/plugins/constants.js b/client/plugins/constants.js
index f001f6ced0..d89fbbbd6b 100644
--- a/client/plugins/constants.js
+++ b/client/plugins/constants.js
@@ -32,12 +32,18 @@ const PlayMethod = {
LOCAL: 3
}
+const SleepTimerTypes = {
+ COUNTDOWN: 'countdown',
+ CHAPTER: 'chapter'
+}
+
const Constants = {
SupportedFileTypes,
DownloadStatus,
BookCoverAspectRatio,
BookshelfView,
- PlayMethod
+ PlayMethod,
+ SleepTimerTypes
}
const KeyNames = {
diff --git a/client/plugins/init.client.js b/client/plugins/init.client.js
index cbf514fd16..984ec9d0d7 100644
--- a/client/plugins/init.client.js
+++ b/client/plugins/init.client.js
@@ -6,7 +6,6 @@ import * as locale from 'date-fns/locale'
Vue.directive('click-outside', vClickOutside.directive)
-
Vue.prototype.$setDateFnsLocale = (localeString) => {
if (!locale[localeString]) return 0
return setDefaultOptions({ locale: locale[localeString] })
@@ -112,14 +111,15 @@ Vue.prototype.$sanitizeSlug = (str) => {
str = str.toLowerCase()
// remove accents, swap ñ for n, etc
- var from = "àáäâèéëêìíïîòóöôùúüûñçěščřžýúůďťň·/,:;"
- var to = "aaaaeeeeiiiioooouuuuncescrzyuudtn-----"
+ var from = 'àáäâèéëêìíïîòóöôùúüûñçěščřžýúůďťň·/,:;'
+ var to = 'aaaaeeeeiiiioooouuuuncescrzyuudtn-----'
for (var i = 0, l = from.length; i < l; i++) {
str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i))
}
- str = str.replace('.', '-') // replace a dot by a dash
+ str = str
+ .replace('.', '-') // replace a dot by a dash
.replace(/[^a-z0-9 -_]/g, '') // remove invalid chars
.replace(/\s+/g, '-') // collapse whitespace and replace by a dash
.replace(/-+/g, '-') // collapse dashes
@@ -131,13 +131,16 @@ Vue.prototype.$sanitizeSlug = (str) => {
Vue.prototype.$copyToClipboard = (str, ctx) => {
return new Promise((resolve) => {
if (navigator.clipboard) {
- navigator.clipboard.writeText(str).then(() => {
- if (ctx) ctx.$toast.success('Copied to clipboard')
- resolve(true)
- }, (err) => {
- console.error('Clipboard copy failed', str, err)
- resolve(false)
- })
+ navigator.clipboard.writeText(str).then(
+ () => {
+ if (ctx) ctx.$toast.success('Copied to clipboard')
+ resolve(true)
+ },
+ (err) => {
+ console.error('Clipboard copy failed', str, err)
+ resolve(false)
+ }
+ )
} else {
const el = document.createElement('textarea')
el.value = str
@@ -160,26 +163,18 @@ function xmlToJson(xml) {
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
const key = res[1] || res[3]
const value = res[2] && xmlToJson(res[2])
- json[key] = ((value && Object.keys(value).length) ? value : res[2]) || null
-
+ json[key] = (value && Object.keys(value).length ? value : res[2]) || null
}
return json
}
Vue.prototype.$xmlToJson = xmlToJson
-Vue.prototype.$encodeUriPath = (path) => {
- return path.replace(/\\/g, '/').replace(/%/g, '%25').replace(/#/g, '%23')
-}
-
const encode = (text) => encodeURIComponent(Buffer.from(text).toString('base64'))
Vue.prototype.$encode = encode
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()
Vue.prototype.$decode = decode
-export {
- encode,
- decode
-}
+export { encode, decode }
export default ({ app, store }, inject) => {
app.$decode = decode
app.$encode = encode
diff --git a/client/plugins/version.js b/client/plugins/version.js
index 593b29a536..488404c64f 100644
--- a/client/plugins/version.js
+++ b/client/plugins/version.js
@@ -11,6 +11,7 @@ function parseSemver(ver) {
return null
}
return {
+ name: ver,
total,
version: groups[2],
major: Number(groups[3]),
@@ -24,49 +25,61 @@ function parseSemver(ver) {
return null
}
+function getReleases() {
+ return axios
+ .get(`https://api.github.com/repos/advplyr/audiobookshelf/releases`)
+ .then((res) => {
+ return res.data
+ .map((release) => {
+ const tagName = release.tag_name
+ const verObj = parseSemver(tagName)
+ if (verObj) {
+ verObj.pubdate = new Date(release.published_at)
+ verObj.changelog = release.body
+ return verObj
+ }
+ return null
+ })
+ .filter((verObj) => verObj)
+ })
+ .catch((error) => {
+ console.error('Failed to get releases', error)
+ return []
+ })
+}
+
export const currentVersion = packagejson.version
export async function checkForUpdate() {
if (!packagejson.version) {
return null
}
- var currVerObj = parseSemver('v' + packagejson.version)
- if (!currVerObj) {
- console.error('Invalid version', packagejson.version)
+
+ const releases = await getReleases()
+ if (!releases.length) {
+ console.error('No releases found')
return null
}
- var largestVer = null
- await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/releases`).then((res) => {
- var releases = res.data
- if (releases && releases.length) {
- releases.forEach((release) => {
- var tagName = release.tag_name
- var verObj = parseSemver(tagName)
- if (verObj) {
- if (!largestVer || largestVer.total < verObj.total) {
- largestVer = verObj
- }
- }
- if (verObj.version == currVerObj.version) {
- currVerObj.pubdate = new Date(release.published_at)
- currVerObj.changelog = release.body
- }
- })
- }
- })
- if (!largestVer) {
- console.error('No valid version tags to compare with')
+ const currentVersion = releases.find((release) => release.version == packagejson.version)
+ if (!currentVersion) {
+ console.error('Current version not found in releases')
return null
}
+ const latestVersion = releases[0]
+ const currentVersionMinor = currentVersion.minor
+ const currentVersionMajor = currentVersion.major
+ // Show all releases with the same minor version and lower or equal total version
+ const releasesToShow = releases.filter((release) => {
+ return release.major == currentVersionMajor && release.minor == currentVersionMinor && release.total <= currentVersion.total
+ })
+
return {
- hasUpdate: largestVer.total > currVerObj.total,
- latestVersion: largestVer.version,
- githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${largestVer.version}`,
- currentVersion: currVerObj.version,
- currentTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${currVerObj.version}`,
- currentVersionPubDate: currVerObj.pubdate,
- currentVersionChangelog: currVerObj.changelog
+ hasUpdate: latestVersion.total > currentVersion.total,
+ latestVersion: latestVersion.version,
+ githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${latestVersion.version}`,
+ currentVersion: currentVersion.version,
+ releasesToShow
}
}
diff --git a/client/static/fonts/MaterialIcons.woff2 b/client/static/fonts/MaterialIcons.woff2
deleted file mode 100644
index 28fc9e48d6..0000000000
Binary files a/client/static/fonts/MaterialIcons.woff2 and /dev/null differ
diff --git a/client/static/fonts/MaterialIconsOutlined.woff2 b/client/static/fonts/MaterialIconsOutlined.woff2
deleted file mode 100644
index aa2ef9c1b5..0000000000
Binary files a/client/static/fonts/MaterialIconsOutlined.woff2 and /dev/null differ
diff --git a/client/static/fonts/MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].woff2 b/client/static/fonts/MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].woff2
new file mode 100644
index 0000000000..f62e84aa77
Binary files /dev/null and b/client/static/fonts/MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].woff2 differ
diff --git a/client/static/fonts/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].woff2 b/client/static/fonts/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].woff2
new file mode 100644
index 0000000000..02f60c326b
Binary files /dev/null and b/client/static/fonts/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].woff2 differ
diff --git a/client/store/index.js b/client/store/index.js
index ed7c35b616..acd03eb468 100644
--- a/client/store/index.js
+++ b/client/store/index.js
@@ -32,33 +32,33 @@ export const state = () => ({
})
export const getters = {
- getServerSetting: state => key => {
+ getServerSetting: (state) => (key) => {
if (!state.serverSettings) return null
return state.serverSettings[key]
},
- getLibraryItemIdStreaming: state => {
+ getLibraryItemIdStreaming: (state) => {
return state.streamLibraryItem?.id || null
},
getIsStreamingFromDifferentLibrary: (state, getters, rootState) => {
if (!state.streamLibraryItem) return false
return state.streamLibraryItem.libraryId !== rootState.libraries.currentLibraryId
},
- getIsMediaStreaming: state => (libraryItemId, episodeId) => {
+ getIsMediaStreaming: (state) => (libraryItemId, episodeId) => {
if (!state.streamLibraryItem) return null
if (!episodeId) return state.streamLibraryItem.id == libraryItemId
return state.streamLibraryItem.id == libraryItemId && state.streamEpisodeId == episodeId
},
- getIsMediaQueued: state => (libraryItemId, episodeId) => {
- return state.playerQueueItems.some(i => {
+ getIsMediaQueued: (state) => (libraryItemId, episodeId) => {
+ return state.playerQueueItems.some((i) => {
if (!episodeId) return i.libraryItemId === libraryItemId
return i.libraryItemId === libraryItemId && i.episodeId === episodeId
})
},
- getBookshelfView: state => {
+ getBookshelfView: (state) => {
if (!state.serverSettings || isNaN(state.serverSettings.bookshelfView)) return Constants.BookshelfView.STANDARD
return state.serverSettings.bookshelfView
},
- getHomeBookshelfView: state => {
+ getHomeBookshelfView: (state) => {
if (!state.serverSettings || isNaN(state.serverSettings.homeBookshelfView)) return Constants.BookshelfView.STANDARD
return state.serverSettings.homeBookshelfView
}
@@ -69,17 +69,20 @@ export const actions = {
const updatePayload = {
...payload
}
- return this.$axios.$patch('/api/settings', updatePayload).then((result) => {
- if (result.success) {
- commit('setServerSettings', result.serverSettings)
- return true
- } else {
+ return this.$axios
+ .$patch('/api/settings', updatePayload)
+ .then((result) => {
+ if (result.success) {
+ commit('setServerSettings', result.serverSettings)
+ return true
+ } else {
+ return false
+ }
+ })
+ .catch((error) => {
+ console.error('Failed to update server settings', error)
return false
- }
- }).catch((error) => {
- console.error('Failed to update server settings', error)
- return false
- })
+ })
},
checkForUpdate({ commit }) {
const VERSION_CHECK_BUFF = 1000 * 60 * 5 // 5 minutes
@@ -96,7 +99,7 @@ export const actions = {
}
var shouldCheckForUpdate = Date.now() - Number(lastVerCheck) > VERSION_CHECK_BUFF
- if (!shouldCheckForUpdate && savedVersionData && savedVersionData.version !== currentVersion) {
+ if (!shouldCheckForUpdate && savedVersionData && (savedVersionData.version !== currentVersion || !savedVersionData.releasesToShow)) {
// Version mismatch between saved data so check for update anyway
shouldCheckForUpdate = true
}
@@ -180,7 +183,7 @@ export const mutations = {
})
},
addItemToQueue(state, item) {
- const exists = state.playerQueueItems.some(i => {
+ const exists = state.playerQueueItems.some((i) => {
if (!i.episodeId) return i.libraryItemId === item.libraryItemId
return i.libraryItemId === item.libraryItemId && i.episodeId === item.episodeId
})
diff --git a/client/store/libraries.js b/client/store/libraries.js
index f56bdad671..b92800baf2 100644
--- a/client/store/libraries.js
+++ b/client/store/libraries.js
@@ -166,22 +166,6 @@ export const actions = {
commit('set', [])
})
return true
- },
- loadLibraryFilterData({ state, commit, rootState }) {
- if (!rootState.user || !rootState.user.user) {
- console.error('libraries/loadLibraryFilterData - User not set')
- return false
- }
-
- this.$axios
- .$get(`/api/libraries/${state.currentLibraryId}/filterdata`)
- .then((data) => {
- commit('setLibraryFilterData', data)
- })
- .catch((error) => {
- console.error('Failed', error)
- commit('setLibraryFilterData', null)
- })
}
}
diff --git a/client/store/tasks.js b/client/store/tasks.js
index 96e7e5b821..24a7517a94 100644
--- a/client/store/tasks.js
+++ b/client/store/tasks.js
@@ -1,34 +1,60 @@
+import Vue from 'vue'
export const state = () => ({
tasks: [],
- queuedEmbedLIds: []
+ queuedEmbedLIds: [],
+ audioFilesEncoding: {},
+ audioFilesFinished: {},
+ taskProgress: {}
})
export const getters = {
getTasksByLibraryItemId: (state) => (libraryItemId) => {
- return state.tasks.filter(t => t.data?.libraryItemId === libraryItemId)
+ return state.tasks.filter((t) => t.data?.libraryItemId === libraryItemId)
},
getRunningLibraryScanTask: (state) => (libraryId) => {
const libraryScanActions = ['library-scan', 'library-match-all']
- return state.tasks.find(t => libraryScanActions.includes(t.action) && t.data?.libraryId === libraryId && !t.isFinished)
+ return state.tasks.find((t) => libraryScanActions.includes(t.action) && t.data?.libraryId === libraryId && !t.isFinished)
+ },
+ getAudioFilesEncoding: (state) => (libraryItemId) => {
+ return state.audioFilesEncoding[libraryItemId]
+ },
+ getAudioFilesFinished: (state) => (libraryItemId) => {
+ return state.audioFilesFinished[libraryItemId]
+ },
+ getTaskProgress: (state) => (libraryItemId) => {
+ return state.taskProgress[libraryItemId]
}
}
-export const actions = {
-
-}
+export const actions = {}
export const mutations = {
+ updateAudioFilesEncoding(state, payload) {
+ if (!state.audioFilesEncoding[payload.libraryItemId]) {
+ Vue.set(state.audioFilesEncoding, payload.libraryItemId, {})
+ }
+ Vue.set(state.audioFilesEncoding[payload.libraryItemId], payload.ino, payload.progress)
+ },
+ updateAudioFilesFinished(state, payload) {
+ if (!state.audioFilesFinished[payload.libraryItemId]) {
+ Vue.set(state.audioFilesFinished, payload.libraryItemId, {})
+ }
+ Vue.set(state.audioFilesFinished[payload.libraryItemId], payload.ino, payload.finished)
+ },
+ updateTaskProgress(state, payload) {
+ Vue.set(state.taskProgress, payload.libraryItemId, payload.progress)
+ },
setTasks(state, tasks) {
state.tasks = tasks
},
addUpdateTask(state, task) {
- const index = state.tasks.findIndex(d => d.id === task.id)
+ const index = state.tasks.findIndex((d) => d.id === task.id)
if (index >= 0) {
state.tasks.splice(index, 1, task)
} else {
// Remove duplicate (only have one library item per action)
- state.tasks = state.tasks.filter(_task => {
+ state.tasks = state.tasks.filter((_task) => {
if (!_task.data?.libraryItemId || _task.action !== task.action) return true
return _task.data.libraryItemId !== task.data.libraryItemId
})
@@ -37,17 +63,17 @@ export const mutations = {
}
},
removeTask(state, task) {
- state.tasks = state.tasks.filter(d => d.id !== task.id)
+ state.tasks = state.tasks.filter((d) => d.id !== task.id)
},
setQueuedEmbedLIds(state, libraryItemIds) {
state.queuedEmbedLIds = libraryItemIds
},
addQueuedEmbedLId(state, libraryItemId) {
- if (!state.queuedEmbedLIds.some(lid => lid === libraryItemId)) {
+ if (!state.queuedEmbedLIds.some((lid) => lid === libraryItemId)) {
state.queuedEmbedLIds.push(libraryItemId)
}
},
removeQueuedEmbedLId(state, libraryItemId) {
- state.queuedEmbedLIds = state.queuedEmbedLIds.filter(lid => lid !== libraryItemId)
+ state.queuedEmbedLIds = state.queuedEmbedLIds.filter((lid) => lid !== libraryItemId)
}
-}
\ No newline at end of file
+}
diff --git a/client/store/user.js b/client/store/user.js
index 70746bc1e0..10dc8ef662 100644
--- a/client/store/user.js
+++ b/client/store/user.js
@@ -8,12 +8,15 @@ export const state = () => ({
bookshelfCoverSize: 120,
collapseSeries: false,
collapseBookSeries: false,
+ showSubtitles: false,
useChapterTrack: false,
seriesSortBy: 'name',
seriesSortDesc: false,
seriesFilterBy: 'all',
authorSortBy: 'name',
- authorSortDesc: false
+ authorSortDesc: false,
+ jumpForwardAmount: 10,
+ jumpBackwardAmount: 10
}
})
@@ -23,13 +26,15 @@ export const getters = {
getToken: (state) => {
return state.user?.token || null
},
- getUserMediaProgress: (state) => (libraryItemId, episodeId = null) => {
- if (!state.user.mediaProgress) return null
- return state.user.mediaProgress.find((li) => {
- if (episodeId && li.episodeId !== episodeId) return false
- return li.libraryItemId == libraryItemId
- })
- },
+ getUserMediaProgress:
+ (state) =>
+ (libraryItemId, episodeId = null) => {
+ if (!state.user.mediaProgress) return null
+ return state.user.mediaProgress.find((li) => {
+ if (episodeId && li.episodeId !== episodeId) return false
+ return li.libraryItemId == libraryItemId
+ })
+ },
getUserBookmarksForItem: (state) => (libraryItemId) => {
if (!state.user.bookmarks) return []
return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId)
@@ -150,7 +155,7 @@ export const mutations = {
},
setUserToken(state, token) {
state.user.token = token
- localStorage.setItem('token', user.token)
+ localStorage.setItem('token', token)
},
updateMediaProgress(state, { id, data }) {
if (!state.user) return
diff --git a/client/strings/de.json b/client/strings/de.json
index af57225fe4..749a0973d8 100644
--- a/client/strings/de.json
+++ b/client/strings/de.json
@@ -59,6 +59,7 @@
"ButtonPurgeItemsCache": "Lösche Medien-Cache",
"ButtonQueueAddItem": "Zur Warteschlange hinzufügen",
"ButtonQueueRemoveItem": "Aus der Warteschlange entfernen",
+ "ButtonQuickEmbedMetadata": "Schnelles Hinzufügen von Metadaten",
"ButtonQuickMatch": "Schnellabgleich",
"ButtonReScan": "Neu scannen",
"ButtonRead": "Lesen",
@@ -66,11 +67,11 @@
"ButtonReadMore": "Mehr anzeigen",
"ButtonRefresh": "Neu Laden",
"ButtonRemove": "Entfernen",
- "ButtonRemoveAll": "Alles löschen",
- "ButtonRemoveAllLibraryItems": "Lösche alle Bibliothekseinträge",
- "ButtonRemoveFromContinueListening": "Lösche den Eintrag aus der Fortsetzungsliste",
- "ButtonRemoveFromContinueReading": "Lösche die Serie aus der Lesefortsetzungsliste",
- "ButtonRemoveSeriesFromContinueSeries": "Lösche die Serie aus der Serienfortsetzungsliste",
+ "ButtonRemoveAll": "Alles entfernen",
+ "ButtonRemoveAllLibraryItems": "Entferne alle Bibliothekseinträge",
+ "ButtonRemoveFromContinueListening": "Entferne den Eintrag aus der Fortsetzungsliste",
+ "ButtonRemoveFromContinueReading": "Entferne die Serie aus der Lesefortsetzungsliste",
+ "ButtonRemoveSeriesFromContinueSeries": "Entferne die Serie aus der Serienfortsetzungsliste",
"ButtonReset": "Zurücksetzen",
"ButtonResetToDefault": "Zurücksetzen auf Standard",
"ButtonRestore": "Wiederherstellen",
@@ -88,6 +89,7 @@
"ButtonShow": "Anzeigen",
"ButtonStartM4BEncode": "M4B-Kodierung starten",
"ButtonStartMetadataEmbed": "Metadateneinbettung starten",
+ "ButtonStats": "Statistiken",
"ButtonSubmit": "Ok",
"ButtonTest": "Test",
"ButtonUpload": "Hochladen",
@@ -154,6 +156,7 @@
"HeaderPasswordAuthentication": "Passwort Authentifizierung",
"HeaderPermissions": "Berechtigungen",
"HeaderPlayerQueue": "Player Warteschlange",
+ "HeaderPlayerSettings": "Player Einstellungen",
"HeaderPlaylist": "Wiedergabeliste",
"HeaderPlaylistItems": "Einträge in der Wiedergabeliste",
"HeaderPodcastsToAdd": "Podcasts zum Hinzufügen",
@@ -161,8 +164,8 @@
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet",
"HeaderRSSFeeds": "RSS-Feeds",
- "HeaderRemoveEpisode": "Episode löschen",
- "HeaderRemoveEpisodes": "Lösche {0} Episoden",
+ "HeaderRemoveEpisode": "Episode entfernen",
+ "HeaderRemoveEpisodes": "Entferne {0} Episoden",
"HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte",
"HeaderSchedule": "Zeitplan",
"HeaderScheduleLibraryScans": "Automatische Bibliotheksscans",
@@ -226,7 +229,7 @@
"LabelBackupLocation": "Backup-Ort",
"LabelBackupsEnableAutomaticBackups": "Automatische Sicherung aktivieren",
"LabelBackupsEnableAutomaticBackupsHelp": "Backups werden in /metadata/backups gespeichert",
- "LabelBackupsMaxBackupSize": "Maximale Sicherungsgröße (in GB)",
+ "LabelBackupsMaxBackupSize": "Maximale Sicherungsgröße (in GB) (0 gleich ohne Begrenzung)",
"LabelBackupsMaxBackupSizeHelp": "Zum Schutz vor Fehlkonfigurationen schlagen Sicherungen fehl, wenn sie die konfigurierte Größe überschreiten.",
"LabelBackupsNumberToKeep": "Anzahl der aufzubewahrenden Sicherungen",
"LabelBackupsNumberToKeepHelp": "Es wird immer nur 1 Sicherung auf einmal entfernt. Wenn du bereits mehrere Sicherungen als die definierte max. Anzahl hast, solltest du diese manuell entfernen.",
@@ -259,7 +262,7 @@
"LabelCustomCronExpression": "Benutzerdefinierter Cron-Ausdruck:",
"LabelDatetime": "Datum & Uhrzeit",
"LabelDays": "Tage",
- "LabelDeleteFromFileSystemCheckbox": "Löschen von der Festplatte + Datenbank (deaktivieren um nur aus der Datenbank zu löschen)",
+ "LabelDeleteFromFileSystemCheckbox": "Löschen von der Festplatte + Datenbank (deaktivieren um nur aus der Datenbank zu entfernen)",
"LabelDescription": "Beschreibung",
"LabelDeselectAll": "Alles abwählen",
"LabelDevice": "Gerät",
@@ -289,13 +292,16 @@
"LabelEmbeddedCover": "Eingebettetes Cover",
"LabelEnable": "Aktivieren",
"LabelEnd": "Ende",
+ "LabelEndOfChapter": "Ende des Kapitels",
"LabelEpisode": "Episode",
"LabelEpisodeTitle": "Episodentitel",
"LabelEpisodeType": "Episodentyp",
"LabelExample": "Beispiel",
+ "LabelExpandSeries": "Serie erweitern",
"LabelExplicit": "Explizit (Altersbeschränkung)",
"LabelExplicitChecked": "Explicit (Altersbeschränkung) (angehakt)",
"LabelExplicitUnchecked": "Not Explicit (Altersbeschränkung) (nicht angehakt)",
+ "LabelExportOPML": "OPML exportieren",
"LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Abholen der Metadaten",
"LabelFile": "Datei",
@@ -319,6 +325,7 @@
"LabelHardDeleteFile": "Datei dauerhaft löschen",
"LabelHasEbook": "E-Book verfügbar",
"LabelHasSupplementaryEbook": "Ergänzendes E-Book verfügbar",
+ "LabelHideSubtitles": "Untertitel ausblenden",
"LabelHighestPriority": "Höchste Priorität",
"LabelHost": "Host",
"LabelHour": "Stunde",
@@ -339,6 +346,8 @@
"LabelIntervalEveryHour": "Jede Stunde",
"LabelInvert": "Umkehren",
"LabelItem": "Medium",
+ "LabelJumpBackwardAmount": "Zurückspringen Zeit",
+ "LabelJumpForwardAmount": "Vorwärtsspringn Zeit",
"LabelLanguage": "Sprache",
"LabelLanguageDefaultServer": "Standard-Server-Sprache",
"LabelLanguages": "Sprachen",
@@ -446,6 +455,8 @@
"LabelRSSFeedPreventIndexing": "Indizierung verhindern",
"LabelRSSFeedSlug": "RSS-Feed-Schlagwort",
"LabelRSSFeedURL": "RSS Feed URL",
+ "LabelRandomly": "Zufällig",
+ "LabelReAddSeriesToContinueListening": "Serien erneut zur Fortsetzungsliste hinzufügen",
"LabelRead": "Lesen",
"LabelReadAgain": "Noch einmal Lesen",
"LabelReadEbookWithoutProgress": "E-Book lesen und Fortschritt verwerfen",
@@ -455,7 +466,7 @@
"LabelRedo": "Wiederholen",
"LabelRegion": "Region",
"LabelReleaseDate": "Veröffentlichungsdatum",
- "LabelRemoveCover": "Lösche Titelbild",
+ "LabelRemoveCover": "Entferne Titelbild",
"LabelRowsPerPage": "Zeilen pro Seite",
"LabelSearchTerm": "Begriff suchen",
"LabelSearchTitle": "Titel suchen",
@@ -512,10 +523,11 @@
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet",
"LabelSettingsTimeFormat": "Zeitformat",
"LabelShare": "Teilen",
- "LabelShareOpen": "Teilen Offen",
+ "LabelShareOpen": "Teilen öffnen",
"LabelShareURL": "URL teilen",
"LabelShowAll": "Alles anzeigen",
"LabelShowSeconds": "Zeige Sekunden",
+ "LabelShowSubtitles": "Untertitel anzeigen",
"LabelSize": "Größe",
"LabelSleepTimer": "Schlummerfunktion",
"LabelSlug": "URL Teil",
@@ -553,6 +565,10 @@
"LabelThemeDark": "Dunkel",
"LabelThemeLight": "Hell",
"LabelTimeBase": "Basiszeit",
+ "LabelTimeDurationXHours": "{0} Stunden",
+ "LabelTimeDurationXMinutes": "{0} Minuten",
+ "LabelTimeDurationXSeconds": "{0} Sekunden",
+ "LabelTimeInMinutes": "Zeit in Minuten",
"LabelTimeListened": "Gehörte Zeit",
"LabelTimeListenedToday": "Heute gehörte Zeit",
"LabelTimeRemaining": "{0} verbleibend",
@@ -592,9 +608,12 @@
"LabelVersion": "Version",
"LabelViewBookmarks": "Lesezeichen anzeigen",
"LabelViewChapters": "Kapitel anzeigen",
+ "LabelViewPlayerSettings": "Zeige player Einstellungen",
"LabelViewQueue": "Player-Warteschlange anzeigen",
"LabelVolume": "Lautstärke",
"LabelWeekdaysToRun": "Wochentage für die Ausführung",
+ "LabelXBooks": "{0} Bücher",
+ "LabelXItems": "{0} Medien",
"LabelYearReviewHide": "Verstecke Jahr in Übersicht",
"LabelYearReviewShow": "Zeige Jahr in Übersicht",
"LabelYourAudiobookDuration": "Laufzeit deines Mediums",
@@ -637,11 +656,11 @@
"MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?",
"MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?",
"MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?",
- "MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird gelöscht! Bist du dir sicher?",
- "MessageConfirmRemoveEpisode": "Episode \"{0}\" wird geloscht! Bist du dir sicher?",
- "MessageConfirmRemoveEpisodes": "{0} Episoden werden gelöscht! Bist du dir sicher?",
+ "MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird entfernt! Bist du dir sicher?",
+ "MessageConfirmRemoveEpisode": "Episode \"{0}\" wird entfernt! Bist du dir sicher?",
+ "MessageConfirmRemoveEpisodes": "{0} Episoden werden entfernt! Bist du dir sicher?",
"MessageConfirmRemoveListeningSessions": "Bist du dir sicher, dass du {0} Hörsitzungen enfernen möchtest?",
- "MessageConfirmRemoveNarrator": "Erzähler \"{0}\" wird gelöscht! Bist du dir sicher?",
+ "MessageConfirmRemoveNarrator": "Erzähler \"{0}\" wird entfernt! Bist du dir sicher?",
"MessageConfirmRemovePlaylist": "Wiedergabeliste \"{0}\" wird entfernt! Bist du dir sicher?",
"MessageConfirmRenameGenre": "Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts werden umbenannt! Bist du dir sicher?",
"MessageConfirmRenameGenreMergeNote": "Hinweis: Kategorie existiert bereits -> Kategorien werden zusammengelegt.",
@@ -652,6 +671,7 @@
"MessageConfirmSendEbookToDevice": "{0} E-Book \"{1}\" wird auf das Gerät \"{2}\" gesendet! Bist du dir sicher?",
"MessageDownloadingEpisode": "Episode wird heruntergeladen",
"MessageDragFilesIntoTrackOrder": "Verschiebe die Dateien in die richtige Reihenfolge",
+ "MessageEmbedFailed": "Einbetten fehlgeschlagen!",
"MessageEmbedFinished": "Einbettung abgeschlossen!",
"MessageEpisodesQueuedForDownload": "{0} Episode(n) in der Warteschlange zum Herunterladen",
"MessageEreaderDevices": "Um die Zustellung von E-Books sicherzustellen, musst du eventuell die oben genannte E-Mail-Adresse als gültigen Absender für jedes unten aufgeführte Gerät hinzufügen.",
@@ -706,15 +726,16 @@
"MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig",
"MessageNoUserPlaylists": "Keine Wiedergabelisten vorhanden",
"MessageNotYetImplemented": "Noch nicht implementiert",
+ "MessageOpmlPreviewNote": "Hinweis: Dies ist nur eine Vorschau der geparsten OPML Datei. Der eigentliche Podcast-Titel wird aus dem RSS-Feed übernommen.",
"MessageOr": "Oder",
"MessagePauseChapter": "Kapitelwiedergabe pausieren",
"MessagePlayChapter": "Kapitelanfang anhören",
"MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung",
"MessagePodcastHasNoRSSFeedForMatching": "Der Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann",
"MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.",
- "MessageRemoveChapter": "Kapitel löschen",
+ "MessageRemoveChapter": "Kapitel entfernen",
"MessageRemoveEpisodes": "Entferne {0} Episode(n)",
- "MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste löschen",
+ "MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste entfernen",
"MessageRemoveUserWarning": "Benutzer \"{0}\" wird dauerhaft gelöscht! Bist du dir sicher?",
"MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und mitwirken",
"MessageResetChaptersConfirm": "Kapitel und vorgenommenen Änderungen werden zurückgesetzt und rückgängig gemacht! Bist du dir sicher?",
@@ -769,8 +790,8 @@
"ToastBatchUpdateSuccess": "Stapelaktualisierung erfolgreich",
"ToastBookmarkCreateFailed": "Lesezeichen konnte nicht erstellt werden",
"ToastBookmarkCreateSuccess": "Lesezeichen hinzugefügt",
- "ToastBookmarkRemoveFailed": "Lesezeichen konnte nicht gelöscht werden",
- "ToastBookmarkRemoveSuccess": "Lesezeichen gelöscht",
+ "ToastBookmarkRemoveFailed": "Lesezeichen konnte nicht entfernt werden",
+ "ToastBookmarkRemoveSuccess": "Lesezeichen entfernt",
"ToastBookmarkUpdateFailed": "Lesezeichenaktualisierung fehlgeschlagen",
"ToastBookmarkUpdateSuccess": "Lesezeichen aktualisiert",
"ToastCachePurgeFailed": "Cache leeren fehlgeschlagen",
@@ -780,7 +801,7 @@
"ToastCollectionItemsRemoveFailed": "Fehler beim Entfernen der Medien aus der Sammlung",
"ToastCollectionItemsRemoveSuccess": "Medien aus der Sammlung entfernt",
"ToastCollectionRemoveFailed": "Sammlung konnte nicht entfernt werden",
- "ToastCollectionRemoveSuccess": "Sammlung gelöscht",
+ "ToastCollectionRemoveSuccess": "Sammlung entfernt",
"ToastCollectionUpdateFailed": "Sammlung konnte nicht aktualisiert werden",
"ToastCollectionUpdateSuccess": "Sammlung aktualisiert",
"ToastDeleteFileFailed": "Die Datei konnte nicht gelöscht werden",
diff --git a/client/strings/en-us.json b/client/strings/en-us.json
index b9b9b5def2..a8740b1443 100644
--- a/client/strings/en-us.json
+++ b/client/strings/en-us.json
@@ -59,6 +59,7 @@
"ButtonPurgeItemsCache": "Purge Items Cache",
"ButtonQueueAddItem": "Add to queue",
"ButtonQueueRemoveItem": "Remove from queue",
+ "ButtonQuickEmbedMetadata": "Quick Embed Metadata",
"ButtonQuickMatch": "Quick Match",
"ButtonReScan": "Re-Scan",
"ButtonRead": "Read",
@@ -88,6 +89,7 @@
"ButtonShow": "Show",
"ButtonStartM4BEncode": "Start M4B Encode",
"ButtonStartMetadataEmbed": "Start Metadata Embed",
+ "ButtonStats": "Stats",
"ButtonSubmit": "Submit",
"ButtonTest": "Test",
"ButtonUpload": "Upload",
@@ -154,6 +156,7 @@
"HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Player Queue",
+ "HeaderPlayerSettings": "Player Settings",
"HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items",
"HeaderPodcastsToAdd": "Podcasts to Add",
@@ -226,7 +229,7 @@
"LabelBackupLocation": "Backup Location",
"LabelBackupsEnableAutomaticBackups": "Enable automatic backups",
"LabelBackupsEnableAutomaticBackupsHelp": "Backups saved in /metadata/backups",
- "LabelBackupsMaxBackupSize": "Maximum backup size (in GB)",
+ "LabelBackupsMaxBackupSize": "Maximum backup size (in GB) (0 for unlimited)",
"LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
"LabelBackupsNumberToKeep": "Number of backups to keep",
"LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
@@ -289,13 +292,16 @@
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Enable",
"LabelEnd": "End",
+ "LabelEndOfChapter": "End of Chapter",
"LabelEpisode": "Episode",
"LabelEpisodeTitle": "Episode Title",
"LabelEpisodeType": "Episode Type",
"LabelExample": "Example",
+ "LabelExpandSeries": "Expand Series",
"LabelExplicit": "Explicit",
"LabelExplicitChecked": "Explicit (checked)",
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
+ "LabelExportOPML": "Export OPML",
"LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "File",
@@ -319,6 +325,7 @@
"LabelHardDeleteFile": "Hard delete file",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
+ "LabelHideSubtitles": "Hide Subtitles",
"LabelHighestPriority": "Highest priority",
"LabelHost": "Host",
"LabelHour": "Hour",
@@ -339,6 +346,8 @@
"LabelIntervalEveryHour": "Every hour",
"LabelInvert": "Invert",
"LabelItem": "Item",
+ "LabelJumpBackwardAmount": "Jump backward amount",
+ "LabelJumpForwardAmount": "Jump forward amount",
"LabelLanguage": "Language",
"LabelLanguageDefaultServer": "Default Server Language",
"LabelLanguages": "Languages",
@@ -446,6 +455,8 @@
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
"LabelRSSFeedSlug": "RSS Feed Slug",
"LabelRSSFeedURL": "RSS Feed URL",
+ "LabelRandomly": "Randomly",
+ "LabelReAddSeriesToContinueListening": "Re-add series to Continue Listening",
"LabelRead": "Read",
"LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
@@ -516,6 +527,7 @@
"LabelShareURL": "Share URL",
"LabelShowAll": "Show All",
"LabelShowSeconds": "Show seconds",
+ "LabelShowSubtitles": "Show Subtitles",
"LabelSize": "Size",
"LabelSleepTimer": "Sleep timer",
"LabelSlug": "Slug",
@@ -553,6 +565,10 @@
"LabelThemeDark": "Dark",
"LabelThemeLight": "Light",
"LabelTimeBase": "Time Base",
+ "LabelTimeDurationXHours": "{0} hours",
+ "LabelTimeDurationXMinutes": "{0} minutes",
+ "LabelTimeDurationXSeconds": "{0} seconds",
+ "LabelTimeInMinutes": "Time in minutes",
"LabelTimeListened": "Time Listened",
"LabelTimeListenedToday": "Time Listened Today",
"LabelTimeRemaining": "{0} remaining",
@@ -592,9 +608,12 @@
"LabelVersion": "Version",
"LabelViewBookmarks": "View bookmarks",
"LabelViewChapters": "View chapters",
+ "LabelViewPlayerSettings": "View player settings",
"LabelViewQueue": "View player queue",
"LabelVolume": "Volume",
"LabelWeekdaysToRun": "Weekdays to run",
+ "LabelXBooks": "{0} books",
+ "LabelXItems": "{0} items",
"LabelYearReviewHide": "Hide Year in Review",
"LabelYearReviewShow": "See Year in Review",
"LabelYourAudiobookDuration": "Your audiobook duration",
@@ -652,6 +671,7 @@
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
"MessageDownloadingEpisode": "Downloading episode",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
+ "MessageEmbedFailed": "Embed Failed!",
"MessageEmbedFinished": "Embed Finished!",
"MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
@@ -706,6 +726,7 @@
"MessageNoUpdatesWereNecessary": "No updates were necessary",
"MessageNoUserPlaylists": "You have no playlists",
"MessageNotYetImplemented": "Not yet implemented",
+ "MessageOpmlPreviewNote": "Note: This is a preview of the parsed OPML file. The actual podcast title will be taken from the RSS feed.",
"MessageOr": "or",
"MessagePauseChapter": "Pause chapter playback",
"MessagePlayChapter": "Listen to beginning of chapter",
@@ -750,6 +771,24 @@
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Search..",
"PlaceholderSearchEpisode": "Search episode..",
+ "StatsAuthorsAdded": "authors added",
+ "StatsBooksAdded": "books added",
+ "StatsBooksAdditional": "Some additions include…",
+ "StatsBooksFinished": "books finished",
+ "StatsBooksFinishedThisYear": "Some books finished this year…",
+ "StatsBooksListenedTo": "books listened to",
+ "StatsCollectionGrewTo": "Your book collection grew to…",
+ "StatsSessions": "sessions",
+ "StatsSpentListening": "spent listening",
+ "StatsTopAuthor": "TOP AUTHOR",
+ "StatsTopAuthors": "TOP AUTHORS",
+ "StatsTopGenre": "TOP GENRE",
+ "StatsTopGenres": "TOP GENRES",
+ "StatsTopMonth": "TOP MONTH",
+ "StatsTopNarrator": "TOP NARRATOR",
+ "StatsTopNarrators": "TOP NARRATORS",
+ "StatsTotalDuration": "With a total duration of…",
+ "StatsYearInReview": "YEAR IN REVIEW",
"ToastAccountUpdateFailed": "Failed to update account",
"ToastAccountUpdateSuccess": "Account updated",
"ToastAuthorImageRemoveFailed": "Failed to remove image",
@@ -785,6 +824,7 @@
"ToastCollectionUpdateSuccess": "Collection updated",
"ToastDeleteFileFailed": "Failed to delete file",
"ToastDeleteFileSuccess": "File deleted",
+ "ToastErrorCannotShare": "Cannot share natively on this device",
"ToastFailedToLoadData": "Failed to load data",
"ToastItemCoverUpdateFailed": "Failed to update item cover",
"ToastItemCoverUpdateSuccess": "Item cover updated",
diff --git a/client/strings/es.json b/client/strings/es.json
index 93a99abc26..4f9c6141ca 100644
--- a/client/strings/es.json
+++ b/client/strings/es.json
@@ -59,6 +59,7 @@
"ButtonPurgeItemsCache": "Purgar Elementos de Cache",
"ButtonQueueAddItem": "Agregar a la Fila",
"ButtonQueueRemoveItem": "Remover de la Fila",
+ "ButtonQuickEmbedMetadata": "Agregue metadatos rápidamente",
"ButtonQuickMatch": "Encontrar Rápido",
"ButtonReScan": "Re-Escanear",
"ButtonRead": "Leer",
@@ -88,6 +89,7 @@
"ButtonShow": "Mostrar",
"ButtonStartM4BEncode": "Iniciar Codificación M4B",
"ButtonStartMetadataEmbed": "Iniciar la Inserción de Metadata",
+ "ButtonStats": "Estadísticas",
"ButtonSubmit": "Enviar",
"ButtonTest": "Prueba",
"ButtonUpload": "Subir",
@@ -154,6 +156,7 @@
"HeaderPasswordAuthentication": "Autenticación por contraseña",
"HeaderPermissions": "Permisos",
"HeaderPlayerQueue": "Fila del Reproductor",
+ "HeaderPlayerSettings": "Ajustes del reproductor",
"HeaderPlaylist": "Lista de reproducción",
"HeaderPlaylistItems": "Elementos de lista de reproducción",
"HeaderPodcastsToAdd": "Podcasts para agregar",
@@ -226,7 +229,7 @@
"LabelBackupLocation": "Ubicación del Respaldo",
"LabelBackupsEnableAutomaticBackups": "Habilitar Respaldo Automático",
"LabelBackupsEnableAutomaticBackupsHelp": "Respaldo Guardado en /metadata/backups",
- "LabelBackupsMaxBackupSize": "Tamaño Máximo de Respaldos (en GB)",
+ "LabelBackupsMaxBackupSize": "Tamaño máximo de copia de seguridad (en GB) (0 para ilimitado)",
"LabelBackupsMaxBackupSizeHelp": "Como protección contra una configuración errónea, los respaldos fallarán si se excede el tamaño configurado.",
"LabelBackupsNumberToKeep": "Numero de respaldos para conservar",
"LabelBackupsNumberToKeepHelp": "Solamente 1 respaldo se removerá a la vez. Si tiene mas respaldos guardados, debe removerlos manualmente.",
@@ -289,13 +292,16 @@
"LabelEmbeddedCover": "Portada Integrada",
"LabelEnable": "Habilitar",
"LabelEnd": "Fin",
+ "LabelEndOfChapter": "Fin del capítulo",
"LabelEpisode": "Episodio",
"LabelEpisodeTitle": "Titulo de Episodio",
"LabelEpisodeType": "Tipo de Episodio",
"LabelExample": "Ejemplo",
+ "LabelExpandSeries": "Ampliar serie",
"LabelExplicit": "Explicito",
"LabelExplicitChecked": "Explícito (marcado)",
"LabelExplicitUnchecked": "No Explícito (sin marcar)",
+ "LabelExportOPML": "Exportar OPML",
"LabelFeedURL": "Fuente de URL",
"LabelFetchingMetadata": "Obteniendo metadatos",
"LabelFile": "Archivo",
@@ -319,6 +325,7 @@
"LabelHardDeleteFile": "Eliminar Definitivamente",
"LabelHasEbook": "Tiene un libro",
"LabelHasSupplementaryEbook": "Tiene un libro complementario",
+ "LabelHideSubtitles": "Ocultar subtítulos",
"LabelHighestPriority": "Mayor prioridad",
"LabelHost": "Host",
"LabelHour": "Hora",
@@ -339,6 +346,8 @@
"LabelIntervalEveryHour": "Cada Hora",
"LabelInvert": "Invertir",
"LabelItem": "Elemento",
+ "LabelJumpBackwardAmount": "Cantidad de saltos hacia atrás",
+ "LabelJumpForwardAmount": "Cantidad de saltos hacia adelante",
"LabelLanguage": "Idioma",
"LabelLanguageDefaultServer": "Lenguaje Predeterminado del Servidor",
"LabelLanguages": "Idiomas",
@@ -446,6 +455,8 @@
"LabelRSSFeedPreventIndexing": "Prevenir indexado",
"LabelRSSFeedSlug": "Fuente RSS Slug",
"LabelRSSFeedURL": "URL de Fuente RSS",
+ "LabelRandomly": "Aleatoriamente",
+ "LabelReAddSeriesToContinueListening": "Volver a agregar la serie para continuar escuchándola",
"LabelRead": "Leído",
"LabelReadAgain": "Volver a leer",
"LabelReadEbookWithoutProgress": "Leer Ebook sin guardar progreso",
@@ -512,9 +523,11 @@
"LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca",
"LabelSettingsTimeFormat": "Formato de Tiempo",
"LabelShare": "Compartir",
+ "LabelShareOpen": "abrir un recurso compartido",
"LabelShareURL": "Compartir la URL",
"LabelShowAll": "Mostrar Todos",
"LabelShowSeconds": "Mostrar segundos",
+ "LabelShowSubtitles": "Mostrar subtítulos",
"LabelSize": "Tamaño",
"LabelSleepTimer": "Temporizador de apagado",
"LabelSlug": "Slug",
@@ -552,6 +565,10 @@
"LabelThemeDark": "Oscuro",
"LabelThemeLight": "Claro",
"LabelTimeBase": "Tiempo Base",
+ "LabelTimeDurationXHours": "{0} horas",
+ "LabelTimeDurationXMinutes": "{0} minutos",
+ "LabelTimeDurationXSeconds": "{0} segundos",
+ "LabelTimeInMinutes": "Tiempo en minutos",
"LabelTimeListened": "Tiempo Escuchando",
"LabelTimeListenedToday": "Tiempo Escuchando Hoy",
"LabelTimeRemaining": "{0} restante",
@@ -591,9 +608,12 @@
"LabelVersion": "Versión",
"LabelViewBookmarks": "Ver Marcadores",
"LabelViewChapters": "Ver Capítulos",
+ "LabelViewPlayerSettings": "Ver los ajustes del reproductor",
"LabelViewQueue": "Ver Fila del Reproductor",
"LabelVolume": "Volumen",
"LabelWeekdaysToRun": "Correr en Días de la Semana",
+ "LabelXBooks": "{0} libros",
+ "LabelXItems": "{0} elementos",
"LabelYearReviewHide": "Ocultar Year in Review",
"LabelYearReviewShow": "Ver Year in Review",
"LabelYourAudiobookDuration": "Duración de tu Audiolibro",
@@ -651,6 +671,7 @@
"MessageConfirmSendEbookToDevice": "¿Está seguro de que enviar {0} ebook(s) \"{1}\" al dispositivo \"{2}\"?",
"MessageDownloadingEpisode": "Descargando Capitulo",
"MessageDragFilesIntoTrackOrder": "Arrastra los archivos al orden correcto de las pistas.",
+ "MessageEmbedFailed": "¡Error al insertar!",
"MessageEmbedFinished": "Incrustación Terminada!",
"MessageEpisodesQueuedForDownload": "{0} Episodio(s) en cola para descargar",
"MessageEreaderDevices": "Para garantizar la entrega de libros electrónicos, es posible que tenga que agregar la dirección de correo electrónico anterior como remitente válido para cada dispositivo enumerado a continuación.",
@@ -705,6 +726,7 @@
"MessageNoUpdatesWereNecessary": "No fue necesario actualizar",
"MessageNoUserPlaylists": "No tienes lista de reproducciones",
"MessageNotYetImplemented": "Aun no implementado",
+ "MessageOpmlPreviewNote": "Nota: Esta es una vista previa del archivo OPML analizado. El título real del podcast se obtendrá del canal RSS.",
"MessageOr": "o",
"MessagePauseChapter": "Pausar la reproducción del capítulo",
"MessagePlayChapter": "Escuchar el comienzo del capítulo",
diff --git a/client/strings/fi.json b/client/strings/fi.json
index 88b2cee36d..95e9254995 100644
--- a/client/strings/fi.json
+++ b/client/strings/fi.json
@@ -88,6 +88,7 @@
"ButtonShow": "Näytä",
"ButtonStartM4BEncode": "Aloita M4B enkoodaus",
"ButtonStartMetadataEmbed": "Aloita metadatan embed",
+ "ButtonStats": "Tilastot",
"ButtonSubmit": "Lähetä",
"ButtonTest": "Testi",
"ButtonUpload": "Lähetä palvelimelle",
@@ -120,43 +121,158 @@
"HeaderDetails": "Yksityiskohdat",
"HeaderDownloadQueue": "Latausjono",
"HeaderEbookFiles": "E-kirjatiedostot",
+ "HeaderEmail": "Sähköposti",
+ "HeaderEmailSettings": "Sähköpostiasetukset",
"HeaderEpisodes": "Jaksot",
+ "HeaderEreaderDevices": "E-lukijalaitteet",
"HeaderEreaderSettings": "E-lukijan asetukset",
+ "HeaderFiles": "Tiedostot",
+ "HeaderIgnoredFiles": "Ohitetut tiedostot",
"HeaderLatestEpisodes": "Viimeisimmät jaksot",
"HeaderLibraries": "Kirjastot",
+ "HeaderLibraryFiles": "Kirjaston tiedostot",
+ "HeaderLibraryStats": "Kirjaston tilastot",
+ "HeaderListeningStats": "Kuuntelutilastot",
+ "HeaderLogs": "Lokit",
+ "HeaderNewAccount": "Uusi tili",
+ "HeaderNewLibrary": "Uusi kirjasto",
+ "HeaderNotifications": "Ilmoitukset",
"HeaderOpenRSSFeed": "Avaa RSS-syöte",
+ "HeaderOtherFiles": "Muut tiedostot",
+ "HeaderPermissions": "Käyttöoikeudet",
"HeaderPlaylist": "Soittolista",
+ "HeaderPlaylistItems": "Soittolistan kohteet",
"HeaderRSSFeedGeneral": "RSS yksityiskohdat",
"HeaderRSSFeedIsOpen": "RSS syöte on avoinna",
+ "HeaderRemoveEpisode": "Poista jakso",
+ "HeaderRemoveEpisodes": "Poista {0} jaksoa",
+ "HeaderSchedule": "Ajoita",
+ "HeaderScheduleLibraryScans": "Ajoita automaattiset kirjastoskannaukset",
+ "HeaderSetBackupSchedule": "Aseta varmuuskopiointiaikataulu",
"HeaderSettings": "Asetukset",
+ "HeaderSettingsExperimental": "Kokeelliset ominaisuudet",
"HeaderSleepTimer": "Uniajastin",
"HeaderStatsMinutesListeningChart": "Kuunteluminuutit (viim. 7 pv)",
"HeaderStatsRecentSessions": "Viimeaikaiset istunnot",
"HeaderTableOfContents": "Sisällysluettelo",
+ "HeaderTools": "Työkalut",
+ "HeaderUsers": "Käyttäjät",
"HeaderYourStats": "Tilastosi",
+ "LabelAccountType": "Tilin tyyppi",
+ "LabelAccountTypeGuest": "Vieras",
+ "LabelAccountTypeUser": "Käyttäjä",
+ "LabelActivity": "Toiminta",
+ "LabelAddToCollection": "Lisää kokoelmaan",
+ "LabelAddToCollectionBatch": "Lisää {0} kirjaa kokoelmaan",
"LabelAddToPlaylist": "Lisää soittolistaan",
+ "LabelAddToPlaylistBatch": "Lisää {0} kohdetta soittolistaan",
"LabelAdded": "Lisätty",
- "LabelAddedAt": "Lisätty",
+ "LabelAddedAt": "Lisätty listalle",
"LabelAll": "Kaikki",
+ "LabelAllUsers": "Kaikki käyttäjät",
+ "LabelAllUsersExcludingGuests": "Kaikki käyttäjät vieraita lukuun ottamatta",
+ "LabelAllUsersIncludingGuests": "Kaikki käyttäjät mukaan lukien vieraat",
"LabelAuthor": "Tekijä",
"LabelAuthorFirstLast": "Tekijä (Etunimi Sukunimi)",
"LabelAuthorLastFirst": "Tekijä (Sukunimi, Etunimi)",
"LabelAuthors": "Tekijät",
"LabelAutoDownloadEpisodes": "Lataa jaksot automaattisesti",
+ "LabelBackupsEnableAutomaticBackups": "Ota automaattinen varmuuskopiointi käyttöön",
+ "LabelBackupsEnableAutomaticBackupsHelp": "Varmuuskopiot tallennettu kansioon /metadata/backups",
+ "LabelBackupsMaxBackupSize": "Varmuuskopion enimmäiskoko (Gt) (0 rajaton)",
+ "LabelBackupsNumberToKeep": "Säilytettävien varmuuskopioiden määrä",
"LabelBooks": "Kirjat",
+ "LabelButtonText": "Painikkeen teksti",
+ "LabelChangePassword": "Vaihda salasana",
"LabelChapters": "Luvut",
+ "LabelClickForMoreInfo": "Napsauta saadaksesi lisätietoja",
"LabelClosePlayer": "Sulje soitin",
+ "LabelCodec": "Koodekki",
"LabelCollapseSeries": "Pienennä sarja",
+ "LabelCollection": "Kokoelma",
+ "LabelCollections": "Kokoelmat",
"LabelComplete": "Valmis",
+ "LabelConfirmPassword": "Vahvista salasana",
"LabelContinueListening": "Jatka kuuntelua",
"LabelContinueReading": "Jatka lukemista",
"LabelContinueSeries": "Jatka sarjoja",
+ "LabelCover": "Kansikuva",
+ "LabelCoverImageURL": "Kansikuvan URL-osoite",
+ "LabelCurrent": "Nykyinen",
"LabelDescription": "Kuvaus",
+ "LabelDevice": "Laite",
+ "LabelDeviceInfo": "Laitteen tiedot",
+ "LabelDownload": "Lataa",
+ "LabelDownloadNEpisodes": "Lataa {0} jaksoa",
"LabelDuration": "Kesto",
"LabelEbook": "E-kirja",
"LabelEbooks": "E-kirjat",
+ "LabelEdit": "Muokkaa",
+ "LabelEmail": "Sähköposti",
+ "LabelEnable": "Ota käyttöön",
+ "LabelEndOfChapter": "Luvun loppu",
+ "LabelEpisode": "Jakso",
"LabelFile": "Tiedosto",
"LabelFileBirthtime": "Tiedoston syntymäaika",
"LabelFileModified": "Muutettu tiedosto",
- "LabelFilename": "Tiedostonimi"
+ "LabelFilename": "Tiedostonimi",
+ "LabelFolder": "Kansio",
+ "LabelInProgress": "Kesken",
+ "LabelIncomplete": "Keskeneräinen",
+ "LabelLanguage": "Kieli",
+ "LabelListenAgain": "Kuuntele uudelleen",
+ "LabelMediaType": "Mediatyyppi",
+ "LabelMore": "Lisää",
+ "LabelMoreInfo": "Lisätietoja",
+ "LabelName": "Nimi",
+ "LabelNarrator": "Lukija",
+ "LabelNarrators": "Lukijat",
+ "LabelNewestAuthors": "Uusimmat kirjailijat",
+ "LabelNewestEpisodes": "Uusimmat jaksot",
+ "LabelPassword": "Salasana",
+ "LabelPath": "Polku",
+ "LabelPodcast": "Podcast",
+ "LabelPodcasts": "Podcastit",
+ "LabelPublishYear": "Julkaisuvuosi",
+ "LabelRSSFeedPreventIndexing": "Estä indeksointi",
+ "LabelRead": "Lue",
+ "LabelReadAgain": "Lue uudelleen",
+ "LabelRecentSeries": "Viimeisimmät sarjat",
+ "LabelRecentlyAdded": "Viimeeksi lisätyt",
+ "LabelSeason": "Kausi",
+ "LabelSetEbookAsPrimary": "Aseta ensisijaiseksi",
+ "LabelSetEbookAsSupplementary": "Aseta täydentäväksi",
+ "LabelShowAll": "Näytä kaikki",
+ "LabelSize": "Koko",
+ "LabelSleepTimer": "Uniajastin",
+ "LabelStatsDailyAverage": "Päivittäinen keskiarvo",
+ "LabelStatsInARow": "peräjälkeen",
+ "LabelStatsMinutes": "minuuttia",
+ "LabelTheme": "Teema",
+ "LabelThemeDark": "Tumma",
+ "LabelThemeLight": "Kirkas",
+ "LabelTimeRemaining": "{0} jäljellä",
+ "LabelType": "Tyyppi",
+ "LabelUser": "Käyttäjä",
+ "LabelUsername": "Käyttäjätunnus",
+ "LabelYourBookmarks": "Kirjanmerkkisi",
+ "LabelYourProgress": "Edistymisesi",
+ "MessageDownloadingEpisode": "Ladataan jaksoa",
+ "MessageEpisodesQueuedForDownload": "{0} jaksoa on latausjonossa",
+ "MessageFetching": "Haetaan...",
+ "MessageLoading": "Ladataan...",
+ "MessageMarkAsFinished": "Merkitse valmiiksi",
+ "MessageNoBookmarks": "Ei kirjanmerkkejä",
+ "MessageNoItems": "Ei kohteita",
+ "MessageNoItemsFound": "Kohteita ei löytynyt",
+ "MessageNoPodcastsFound": "Podcasteja ei löytynyt",
+ "MessageNoUserPlaylists": "Sinulla ei ole soittolistoja",
+ "MessageReportBugsAndContribute": "Ilmoita virheistä, toivo ominaisuuksia ja osallistu",
+ "ToastBookmarkCreateFailed": "Kirjanmerkin luominen epäonnistui",
+ "ToastBookmarkRemoveFailed": "Kirjanmerkin poistaminen epäonnistui",
+ "ToastBookmarkUpdateFailed": "Kirjanmerkin päivittäminen epäonnistui",
+ "ToastItemMarkedAsFinishedFailed": "Valmiiksi merkitseminen epäonnistui",
+ "ToastPlaylistCreateFailed": "Soittolistan luominen epäonnistui",
+ "ToastPodcastCreateFailed": "Podcastin luominen epäonnistui",
+ "ToastPodcastCreateSuccess": "Podcastin luominen onnistui"
}
diff --git a/client/strings/fr.json b/client/strings/fr.json
index 5aceef6385..32520aaa5e 100644
--- a/client/strings/fr.json
+++ b/client/strings/fr.json
@@ -1,6 +1,6 @@
{
"ButtonAdd": "Ajouter",
- "ButtonAddChapters": "Ajouter le chapitre",
+ "ButtonAddChapters": "Ajouter des chapitres",
"ButtonAddDevice": "Ajouter un appareil",
"ButtonAddLibrary": "Ajouter une bibliothèque",
"ButtonAddPodcasts": "Ajouter des podcasts",
@@ -37,7 +37,7 @@
"ButtonJumpForward": "Avancer",
"ButtonLatest": "Dernière version",
"ButtonLibrary": "Bibliothèque",
- "ButtonLogout": "Me déconnecter",
+ "ButtonLogout": "Déconnexion",
"ButtonLookup": "Chercher",
"ButtonManageTracks": "Gérer les pistes",
"ButtonMapChapterTitles": "Correspondance des titres de chapitres",
@@ -59,6 +59,7 @@
"ButtonPurgeItemsCache": "Purger le cache des éléments",
"ButtonQueueAddItem": "Ajouter à la liste de lecture",
"ButtonQueueRemoveItem": "Supprimer de la liste de lecture",
+ "ButtonQuickEmbedMetadata": "Ajouter rapidement des métadonnées",
"ButtonQuickMatch": "Recherche rapide",
"ButtonReScan": "Nouvelle analyse",
"ButtonRead": "Lire",
@@ -88,6 +89,7 @@
"ButtonShow": "Afficher",
"ButtonStartM4BEncode": "Démarrer l’encodage M4B",
"ButtonStartMetadataEmbed": "Démarrer les Métadonnées intégrées",
+ "ButtonStats": "Statistiques",
"ButtonSubmit": "Soumettre",
"ButtonTest": "Test",
"ButtonUpload": "Téléverser",
@@ -154,6 +156,7 @@
"HeaderPasswordAuthentication": "Authentification par mot de passe",
"HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Liste d’écoute",
+ "HeaderPlayerSettings": "Paramètres du lecteur",
"HeaderPlaylist": "Liste de lecture",
"HeaderPlaylistItems": "Éléments de la liste de lecture",
"HeaderPodcastsToAdd": "Podcasts à ajouter",
@@ -226,7 +229,7 @@
"LabelBackupLocation": "Emplacement de la sauvegarde",
"LabelBackupsEnableAutomaticBackups": "Activer les sauvegardes automatiques",
"LabelBackupsEnableAutomaticBackupsHelp": "Sauvegardes enregistrées dans /metadata/backups",
- "LabelBackupsMaxBackupSize": "Taille maximale de la sauvegarde (en Go)",
+ "LabelBackupsMaxBackupSize": "Taille maximale de la sauvegarde (en Go) (0 pour illimité)",
"LabelBackupsMaxBackupSizeHelp": "Afin de prévenir les mauvaises configuration, la sauvegarde échouera si elle excède la taille limite.",
"LabelBackupsNumberToKeep": "Nombre de sauvegardes à conserver",
"LabelBackupsNumberToKeepHelp": "Seule une sauvegarde sera supprimée à la fois. Si vous avez déjà plus de sauvegardes à effacer, vous devez les supprimer manuellement.",
@@ -258,6 +261,7 @@
"LabelCurrently": "Actuellement :",
"LabelCustomCronExpression": "Expression cron personnalisée :",
"LabelDatetime": "Date",
+ "LabelDays": "Jours",
"LabelDeleteFromFileSystemCheckbox": "Supprimer du système de fichiers (décocher pour ne supprimer que de la base de données)",
"LabelDescription": "Description",
"LabelDeselectAll": "Tout déselectionner",
@@ -281,20 +285,23 @@
"LabelEmail": "Courriel",
"LabelEmailSettingsFromAddress": "Expéditeur",
"LabelEmailSettingsRejectUnauthorized": "Rejeter les certificats non autorisés",
- "LabelEmailSettingsRejectUnauthorizedHelp": "Désactiver la validation du certificat SSL peut exposer votre connexion à des risques de sécurité, tels que des attaques de type « man-in-the-middle ». Ne désactivez cette option que si vous en comprenez les implications et si vous faites confiance au serveur de messagerie auquel vous vous connectez.",
+ "LabelEmailSettingsRejectUnauthorizedHelp": "Désactiver la validation du certificat SSL peut exposer votre connexion à des risques de sécurité, tels que des attaques de type « Attaque de l’homme du milieu ». Ne désactivez cette option que si vous en comprenez les implications et si vous faites confiance au serveur de messagerie auquel vous vous connectez.",
"LabelEmailSettingsSecure": "Sécurisé",
"LabelEmailSettingsSecureHelp": "Si vous activez cette option, TLS sera utiliser lors de la connexion au serveur. Sinon, TLS est utilisé uniquement si le serveur supporte l’extension STARTTLS. Dans la plupart des cas, activez l’option, vous vous connecterai sur le port 465. Pour le port 587 ou 25, désactiver l’option. (source : nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsTestAddress": "Adresse de test",
"LabelEmbeddedCover": "Couverture du livre intégrée",
"LabelEnable": "Activer",
"LabelEnd": "Fin",
+ "LabelEndOfChapter": "Fin du chapitre",
"LabelEpisode": "Épisode",
"LabelEpisodeTitle": "Titre de l’épisode",
"LabelEpisodeType": "Type de l’épisode",
"LabelExample": "Exemple",
+ "LabelExpandSeries": "Développer la série",
"LabelExplicit": "Restriction",
"LabelExplicitChecked": "Explicite (vérifié)",
"LabelExplicitUnchecked": "Non explicite (non vérifié)",
+ "LabelExportOPML": "Exporter OPML",
"LabelFeedURL": "URL du flux",
"LabelFetchingMetadata": "Récupération des métadonnées",
"LabelFile": "Fichier",
@@ -318,9 +325,11 @@
"LabelHardDeleteFile": "Suppression du fichier",
"LabelHasEbook": "A un livre numérique",
"LabelHasSupplementaryEbook": "A un livre numérique supplémentaire",
+ "LabelHideSubtitles": "Masquer les sous-titres",
"LabelHighestPriority": "Priorité la plus élevée",
"LabelHost": "Hôte",
"LabelHour": "Heure",
+ "LabelHours": "Heures",
"LabelIcon": "Icône",
"LabelImageURLFromTheWeb": "URL de l’image à partir du web",
"LabelInProgress": "En cours",
@@ -371,6 +380,7 @@
"LabelMetadataOrderOfPrecedenceDescription": "Les sources de métadonnées ayant une priorité plus élevée auront la priorité sur celles ayant une priorité moins élevée",
"LabelMetadataProvider": "Fournisseur de métadonnées",
"LabelMinute": "Minute",
+ "LabelMinutes": "Minutes",
"LabelMissing": "Manquant",
"LabelMissingEbook": "Ne possède aucun livre numérique",
"LabelMissingSupplementaryEbook": "Ne possède aucun livre numérique supplémentaire",
@@ -410,6 +420,7 @@
"LabelOverwrite": "Écraser",
"LabelPassword": "Mot de passe",
"LabelPath": "Chemin",
+ "LabelPermanent": "Permanent",
"LabelPermissionsAccessAllLibraries": "Peut accéder à toutes les bibliothèque",
"LabelPermissionsAccessAllTags": "Peut accéder à toutes les étiquettes",
"LabelPermissionsAccessExplicitContent": "Peut accéder au contenu restreint",
@@ -442,6 +453,8 @@
"LabelRSSFeedPreventIndexing": "Empêcher l’indexation",
"LabelRSSFeedSlug": "Balise URL du flux RSS",
"LabelRSSFeedURL": "Adresse du flux RSS",
+ "LabelRandomly": "Au hasard",
+ "LabelReAddSeriesToContinueListening": "Ajouter à nouveau la série pour continuer à l’écouter",
"LabelRead": "Lire",
"LabelReadAgain": "Lire à nouveau",
"LabelReadEbookWithoutProgress": "Lire le livre numérique sans sauvegarder la progression",
@@ -507,8 +520,12 @@
"LabelSettingsStoreMetadataWithItem": "Enregistrer les métadonnées avec l’élément",
"LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les fichiers de métadonnées sont stockés dans /metadata/items. En activant ce paramètre, les fichiers de métadonnées seront stockés dans les dossiers des éléments de votre bibliothèque",
"LabelSettingsTimeFormat": "Format d’heure",
+ "LabelShare": "Partager",
+ "LabelShareOpen": "Ouvrir le partage",
+ "LabelShareURL": "Partager l’URL",
"LabelShowAll": "Tout afficher",
"LabelShowSeconds": "Afficher les seondes",
+ "LabelShowSubtitles": "Afficher les sous-titres",
"LabelSize": "Taille",
"LabelSleepTimer": "Minuterie de mise en veille",
"LabelSlug": "Balise",
@@ -546,6 +563,10 @@
"LabelThemeDark": "Sombre",
"LabelThemeLight": "Clair",
"LabelTimeBase": "Base de temps",
+ "LabelTimeDurationXHours": "{0} heures",
+ "LabelTimeDurationXMinutes": "{0} minutes",
+ "LabelTimeDurationXSeconds": "{0} secondes",
+ "LabelTimeInMinutes": "Temps en minutes",
"LabelTimeListened": "Temps d’écoute",
"LabelTimeListenedToday": "Nombres d’écoutes aujourd’hui",
"LabelTimeRemaining": "{0} restantes",
@@ -585,19 +606,23 @@
"LabelVersion": "Version",
"LabelViewBookmarks": "Afficher les favoris",
"LabelViewChapters": "Afficher les chapitres",
+ "LabelViewPlayerSettings": "Afficher les paramètres du lecteur",
"LabelViewQueue": "Afficher la liste de lecture",
"LabelVolume": "Volume",
"LabelWeekdaysToRun": "Jours de la semaine à exécuter",
+ "LabelXBooks": "{0} livres",
+ "LabelXItems": "{0} éléments",
"LabelYearReviewHide": "Masquer le bilan de l’année",
"LabelYearReviewShow": "Afficher le bilan de l’année",
"LabelYourAudiobookDuration": "Durée de vos livres audios",
"LabelYourBookmarks": "Vos favoris",
- "LabelYourPlaylists": "Vos listes de lecture",
+ "LabelYourPlaylists": "Mes listes de lecture",
"LabelYourProgress": "Votre progression",
"MessageAddToPlayerQueue": "Ajouter en file d’attente",
"MessageAppriseDescription": "Nécessite une instance d’
API Apprise pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes.
L’URL de l’API Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur
http://192.168.1.1:8337
alors vous devez mettre
http://192.168.1.1:8337/notify
.",
"MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression des utilisateurs, les détails des éléments de la bibliothèque, les paramètres du serveur et les images stockées dans
/metadata/items
&
/metadata/authors
. Les sauvegardes
n’incluent pas les fichiers stockés dans les dossiers de votre bibliothèque.",
"MessageBackupsLocationEditNote": "Remarque : Mettre à jour l'emplacement de sauvegarde ne déplacera pas ou ne modifiera pas les sauvegardes existantes",
+ "MessageBackupsLocationNoEditNote": "Remarque : l’emplacement de sauvegarde est défini via une variable d’environnement et ne peut pas être modifié ici.",
"MessageBackupsLocationPathEmpty": "L'emplacement de secours ne peut pas être vide",
"MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera d’ajouter les couvertures et métadonnées manquantes pour les éléments sélectionnés. Activez les options ci-dessous pour permettre la Recherche par correspondance d’écraser les couvertures et/ou métadonnées existantes.",
"MessageBookshelfNoCollections": "Vous n’avez pas encore de collections",
@@ -644,6 +669,7 @@
"MessageConfirmSendEbookToDevice": "Êtes-vous sûr de vouloir envoyer {0} livre numérique « {1} » à l'appareil « {2} » ?",
"MessageDownloadingEpisode": "Téléchargement de l’épisode",
"MessageDragFilesIntoTrackOrder": "Faites glisser les fichiers dans l’ordre correct des pistes",
+ "MessageEmbedFailed": "Échec de l’intégration !",
"MessageEmbedFinished": "Intégration terminée !",
"MessageEpisodesQueuedForDownload": "{0} épisode(s) mis en file pour téléchargement",
"MessageEreaderDevices": "Pour garantir l’envoie des livres électroniques, il se peut que vous deviez ajouter l’adresse électronique ci-dessus en tant qu’expéditeur valide pour chaque appareil répertorié ci-dessous.",
@@ -698,6 +724,7 @@
"MessageNoUpdatesWereNecessary": "Aucune mise à jour n’était nécessaire",
"MessageNoUserPlaylists": "Vous n’avez aucune liste de lecture",
"MessageNotYetImplemented": "Non implémenté",
+ "MessageOpmlPreviewNote": "Remarque : Il s’agit d’un aperçu du fichier OPML analysé. Le titre réel du podcast provient du flux RSS.",
"MessageOr": "ou",
"MessagePauseChapter": "Suspendre la lecture du chapitre",
"MessagePlayChapter": "Écouter depuis le début du chapitre",
@@ -716,6 +743,9 @@
"MessageSelected": "{0} sélectionnés",
"MessageServerCouldNotBeReached": "Serveur inaccessible",
"MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre",
+ "MessageShareExpirationWillBe": "Expire le
{0} ",
+ "MessageShareExpiresIn": "Expire dans {0}",
+ "MessageShareURLWillBe": "L’adresse de partage sera
{0} ",
"MessageStartPlaybackAtTime": "Démarrer la lecture pour « {0} » à {1} ?",
"MessageThinking": "Je cherche…",
"MessageUploaderItemFailed": "Échec du téléversement",
@@ -730,7 +760,7 @@
"NoteChapterEditorTimes": "Information : l’horodatage du premier chapitre doit être à 0:00 et celui du dernier chapitre ne peut se situer au-delà de la durée du livre audio.",
"NoteFolderPicker": "Information : les dossiers déjà surveillés ne sont pas affichés",
"NoteRSSFeedPodcastAppsHttps": "Attention : la majorité des application de podcast nécessite une adresse de flux HTTPS",
- "NoteRSSFeedPodcastAppsPubDate": "Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.",
+ "NoteRSSFeedPodcastAppsPubDate": "Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.",
"NoteUploaderFoldersWithMediaFiles": "Les dossiers contenant des fichiers multimédias seront traités comme des éléments distincts de la bibliothèque.",
"NoteUploaderOnlyAudioFiles": "Si vous téléversez uniquement des fichiers audio, chaque fichier audio sera traité comme un livre audio distinct.",
"NoteUploaderUnsupportedFiles": "Les fichiers non pris en charge sont ignorés. Lorsque vous choisissez ou déposez un dossier, les autres fichiers qui ne sont pas dans un dossier d’élément sont ignorés.",
diff --git a/client/strings/he.json b/client/strings/he.json
index aa6eb98665..514639405d 100644
--- a/client/strings/he.json
+++ b/client/strings/he.json
@@ -9,7 +9,7 @@
"ButtonApply": "החל",
"ButtonApplyChapters": "החל פרקים",
"ButtonAuthors": "יוצרים",
- "ButtonBack": "Back",
+ "ButtonBack": "חזור",
"ButtonBrowseForFolder": "עיין בתיקייה",
"ButtonCancel": "בטל",
"ButtonCancelEncode": "בטל קידוד",
@@ -62,8 +62,8 @@
"ButtonQuickMatch": "התאמה מהירה",
"ButtonReScan": "סרוק מחדש",
"ButtonRead": "קרא",
- "ButtonReadLess": "Read less",
- "ButtonReadMore": "Read more",
+ "ButtonReadLess": "קרא פחות",
+ "ButtonReadMore": "קרא יותר",
"ButtonRefresh": "רענן",
"ButtonRemove": "הסר",
"ButtonRemoveAll": "הסר הכל",
@@ -115,7 +115,7 @@
"HeaderCollectionItems": "פריטי אוסף",
"HeaderCover": "כריכה",
"HeaderCurrentDownloads": "הורדות נוכחיות",
- "HeaderCustomMessageOnLogin": "Custom Message on Login",
+ "HeaderCustomMessageOnLogin": "הודעה מותאמת אישית בהתחברות",
"HeaderCustomMetadataProviders": "ספקי מטא-נתונים מותאמים אישית",
"HeaderDetails": "פרטים",
"HeaderDownloadQueue": "תור הורדה",
@@ -806,8 +806,8 @@
"ToastSendEbookToDeviceSuccess": "הספר נשלח אל המכשיר \"{0}\"",
"ToastSeriesUpdateFailed": "עדכון הסדרה נכשל",
"ToastSeriesUpdateSuccess": "הסדרה עודכנה בהצלחה",
- "ToastServerSettingsUpdateFailed": "Failed to update server settings",
- "ToastServerSettingsUpdateSuccess": "Server settings updated",
+ "ToastServerSettingsUpdateFailed": "כשל בעדכון הגדרות שרת",
+ "ToastServerSettingsUpdateSuccess": "הגדרות שרת עודכנו בהצלחה",
"ToastSessionDeleteFailed": "מחיקת הפעולה נכשלה",
"ToastSessionDeleteSuccess": "הפעולה נמחקה בהצלחה",
"ToastSocketConnected": "קצה תקשורת חובר",
diff --git a/client/strings/it.json b/client/strings/it.json
index a06333def7..c1251621f0 100644
--- a/client/strings/it.json
+++ b/client/strings/it.json
@@ -11,7 +11,7 @@
"ButtonAuthors": "Autori",
"ButtonBack": "Indietro",
"ButtonBrowseForFolder": "Per Cartella",
- "ButtonCancel": "Annulla",
+ "ButtonCancel": "Cancella",
"ButtonCancelEncode": "Ferma la codifica",
"ButtonChangeRootPassword": "Cambia la Password di root",
"ButtonCheckAndDownloadNewEpisodes": "Controlla & scarica i nuovi episodi",
@@ -43,7 +43,7 @@
"ButtonMapChapterTitles": "Titoli dei Capitoli",
"ButtonMatchAllAuthors": "Aggiungi metadata agli Autori",
"ButtonMatchBooks": "Aggiungi metadata della Libreria",
- "ButtonNevermind": "Ingnora",
+ "ButtonNevermind": "Ingora",
"ButtonNext": "Prossimo",
"ButtonNextChapter": "Prossimo Capitolo",
"ButtonOk": "Ok",
@@ -59,6 +59,7 @@
"ButtonPurgeItemsCache": "Elimina la Cache selezionata",
"ButtonQueueAddItem": "Aggiungi alla Coda",
"ButtonQueueRemoveItem": "Rimuovi dalla Coda",
+ "ButtonQuickEmbedMetadata": "Incorporamento rapido Metadati",
"ButtonQuickMatch": "Controlla Metadata Auto",
"ButtonReScan": "Ri-scansiona",
"ButtonRead": "Leggi",
@@ -88,6 +89,7 @@
"ButtonShow": "Mostra",
"ButtonStartM4BEncode": "Inizia L'Encode del M4B",
"ButtonStartMetadataEmbed": "Inizia Incorporo Metadata",
+ "ButtonStats": "Statistische",
"ButtonSubmit": "Invia",
"ButtonTest": "Test",
"ButtonUpload": "Carica",
@@ -154,6 +156,7 @@
"HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "Permessi",
"HeaderPlayerQueue": "Coda Riproduzione",
+ "HeaderPlayerSettings": "Impostazioni Player",
"HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Elementi della playlist",
"HeaderPodcastsToAdd": "Podcasts da Aggiungere",
@@ -218,7 +221,7 @@
"LabelAutoDownloadEpisodes": "Auto Download Episodi",
"LabelAutoFetchMetadata": "Auto controllo Metadata",
"LabelAutoFetchMetadataHelp": "Recupera i metadati per titolo, autore e serie per semplificare il caricamento. Potrebbe essere necessario abbinare metadati aggiuntivi dopo il caricamento.",
- "LabelAutoLaunch": "Auto Avvio",
+ "LabelAutoLaunch": "Avvio Automatico",
"LabelAutoLaunchDescription": "Reindirizzamento automatico al provider di autenticazione quando si accede alla pagina di accesso (percorso di sostituzione manuale
/login?autoLaunch=0
)",
"LabelAutoRegister": "Auto Registrazione",
"LabelAutoRegisterDescription": "Crea automaticamente nuovi utenti dopo aver effettuato l'accesso",
@@ -226,7 +229,7 @@
"LabelBackupLocation": "Percorso del Backup",
"LabelBackupsEnableAutomaticBackups": "Abilita backup Automatico",
"LabelBackupsEnableAutomaticBackupsHelp": "I Backup saranno salvati in /metadata/backups",
- "LabelBackupsMaxBackupSize": "Dimensione massima backup (in GB)",
+ "LabelBackupsMaxBackupSize": "Dimensione massima backup (in GB) (0 Illimitato)",
"LabelBackupsMaxBackupSizeHelp": "Come protezione contro gli errori di config, i backup falliranno se superano la dimensione configurata.",
"LabelBackupsNumberToKeep": "Numero di backup da mantenere",
"LabelBackupsNumberToKeepHelp": "Verrà rimosso solo 1 backup alla volta, quindi se hai più backup, dovrai rimuoverli manualmente.",
@@ -258,6 +261,7 @@
"LabelCurrently": "Attualmente:",
"LabelCustomCronExpression": "Espressione Cron personalizzata:",
"LabelDatetime": "Data & Ora",
+ "LabelDays": "Giorni",
"LabelDeleteFromFileSystemCheckbox": "Elimina dal file system (togli la spunta per eliminarla solo dal DB)",
"LabelDescription": "Descrizione",
"LabelDeselectAll": "Deseleziona Tutto",
@@ -288,13 +292,16 @@
"LabelEmbeddedCover": "Cover Integrata",
"LabelEnable": "Abilita",
"LabelEnd": "Fine",
+ "LabelEndOfChapter": "Fine Capitolo",
"LabelEpisode": "Episodio",
"LabelEpisodeTitle": "Titolo Episodio",
"LabelEpisodeType": "Tipo Episodio",
"LabelExample": "Esempio",
+ "LabelExpandSeries": "Espandi Serie",
"LabelExplicit": "Esplicito",
"LabelExplicitChecked": "Esplicito (selezionato)",
"LabelExplicitUnchecked": "Non Esplicito (selezionato)",
+ "LabelExportOPML": "Esposta OPML",
"LabelFeedURL": "URL del flusso",
"LabelFetchingMetadata": "Recupero dei metadati",
"LabelFile": "File",
@@ -318,9 +325,11 @@
"LabelHardDeleteFile": "Elimina Definitivamente",
"LabelHasEbook": "Ha un libro",
"LabelHasSupplementaryEbook": "Ha un libro supplementale",
+ "LabelHideSubtitles": "Nascondi Sottotitoli",
"LabelHighestPriority": "Priorità Massima",
"LabelHost": "Host",
"LabelHour": "Ora",
+ "LabelHours": "Ore",
"LabelIcon": "Icona",
"LabelImageURLFromTheWeb": "Immagine URL da internet",
"LabelInProgress": "In corso",
@@ -337,6 +346,8 @@
"LabelIntervalEveryHour": "Ogni ora",
"LabelInvert": "Inverti",
"LabelItem": "Oggetti",
+ "LabelJumpBackwardAmount": "secondi di avvolgimento",
+ "LabelJumpForwardAmount": "Secondi di Avvolgimento",
"LabelLanguage": "Lingua",
"LabelLanguageDefaultServer": "Lingua di Default",
"LabelLanguages": "Lingua",
@@ -346,7 +357,7 @@
"LabelLastTime": "Ultima Volta",
"LabelLastUpdate": "Ultimo Aggiornamento",
"LabelLayout": "Disposizione",
- "LabelLayoutSinglePage": "Pagina Singola",
+ "LabelLayoutSinglePage": "Pagina singola",
"LabelLayoutSplitPage": "Dividi Pagina",
"LabelLess": "Poco",
"LabelLibrariesAccessibleToUser": "Librerie Accessibili agli Utenti",
@@ -371,6 +382,7 @@
"LabelMetadataOrderOfPrecedenceDescription": "Le origini di metadati con priorità più alta sovrascriveranno le origini di metadati con priorità inferiore",
"LabelMetadataProvider": "Metadata Provider",
"LabelMinute": "Minuto",
+ "LabelMinutes": "Minuti",
"LabelMissing": "Altro",
"LabelMissingEbook": "Non ha ebook",
"LabelMissingSupplementaryEbook": "Non ha ebook supplementare",
@@ -400,7 +412,7 @@
"LabelNotificationsMaxFailedAttempts": "Numero massimo di tentativi falliti",
"LabelNotificationsMaxFailedAttemptsHelp": "Le notifiche vengono disabilitate se falliscono molte volte",
"LabelNotificationsMaxQueueSize": "Coda Massima di notifiche eventi",
- "LabelNotificationsMaxQueueSizeHelp": "Le notifiche sono limitate per 1 al secondo, per evitare lo spamming le notifiche verrano ignorare se superano la coda",
+ "LabelNotificationsMaxQueueSizeHelp": "Le notifiche sono limitate per 1 al secondo, per evitare lo spamming le notifiche verrano ignorare se superano la coda.",
"LabelNumberOfBooks": "Numero di libri",
"LabelNumberOfEpisodes": "# degli episodi",
"LabelOpenIDAdvancedPermsClaimDescription": "Nome dell'attestazione OpenID che contiene autorizzazioni avanzate per le azioni dell'utente all'interno dell'applicazione che verranno applicate ai ruoli non amministratori (
se configurato ). Se il reclamo manca nella risposta, l'accesso ad ABS verrà negato. Se manca una singola opzione, verrà trattata come
falsa
. Assicurati che l'attestazione del provider di identità corrisponda alla struttura prevista:",
@@ -410,6 +422,7 @@
"LabelOverwrite": "Sovrascrivi",
"LabelPassword": "Password",
"LabelPath": "Percorso",
+ "LabelPermanent": "Permanente",
"LabelPermissionsAccessAllLibraries": "Può accedere a tutte le librerie",
"LabelPermissionsAccessAllTags": "Può accedere a tutti i tag",
"LabelPermissionsAccessExplicitContent": "Può accedere a contenuti espliciti",
@@ -435,13 +448,14 @@
"LabelPubDate": "Data di pubblicazione",
"LabelPublishYear": "Anno di pubblicazione",
"LabelPublisher": "Editore",
- "LabelPublishers": "Publishers",
+ "LabelPublishers": "Editori",
"LabelRSSFeedCustomOwnerEmail": "E-mail del proprietario personalizzato",
"LabelRSSFeedCustomOwnerName": "Nome del proprietario personalizzato",
"LabelRSSFeedOpen": "RSS Feed Aperto",
"LabelRSSFeedPreventIndexing": "Impedisci l'indicizzazione",
"LabelRSSFeedSlug": "Parole chiave del flusso RSS",
"LabelRSSFeedURL": "RSS Feed URL",
+ "LabelReAddSeriesToContinueListening": "Aggiungi di nuovo la serie per continuare ad ascoltare",
"LabelRead": "Leggi",
"LabelReadAgain": "Leggi ancora",
"LabelReadEbookWithoutProgress": "Leggi l'ebook senza mantenere i progressi",
@@ -459,7 +473,7 @@
"LabelSeason": "Stagione",
"LabelSelectAll": "Seleziona tutto",
"LabelSelectAllEpisodes": "Seleziona tutti gli Episodi",
- "LabelSelectEpisodesShowing": "Episodi {0} selezionati",
+ "LabelSelectEpisodesShowing": "Selezionati {0} episodi da visualizzare",
"LabelSelectUsers": "Selezione Utenti",
"LabelSendEbookToDevice": "Invia ebook a...",
"LabelSequence": "Sequenza",
@@ -507,8 +521,12 @@
"LabelSettingsStoreMetadataWithItem": "Archivia i metadata con il file",
"LabelSettingsStoreMetadataWithItemHelp": "Di default, i metadati sono salvati dentro /metadata/items, abilitando questa opzione si memorizzeranno i metadata nella cartella della libreria",
"LabelSettingsTimeFormat": "Formato Ora",
+ "LabelShare": "Condividi",
+ "LabelShareOpen": "Apri Condivisioni",
+ "LabelShareURL": "Condividi URL",
"LabelShowAll": "Mostra tutto",
"LabelShowSeconds": "Mostra i secondi",
+ "LabelShowSubtitles": "Mostra Sottotitoli",
"LabelSize": "Dimensione",
"LabelSleepTimer": "Temporizzatore",
"LabelSlug": "Lento",
@@ -546,6 +564,10 @@
"LabelThemeDark": "Scuro",
"LabelThemeLight": "Chiaro",
"LabelTimeBase": "Tempo base",
+ "LabelTimeDurationXHours": "{0} Ore",
+ "LabelTimeDurationXMinutes": "{0} minuti",
+ "LabelTimeDurationXSeconds": "{0} secondi",
+ "LabelTimeInMinutes": "Tempo in minuti",
"LabelTimeListened": "Tempo di Ascolto",
"LabelTimeListenedToday": "Tempo di Ascolto Oggi",
"LabelTimeRemaining": "{0} rimanente",
@@ -585,9 +607,12 @@
"LabelVersion": "Versione",
"LabelViewBookmarks": "Visualizza i Segnalibri",
"LabelViewChapters": "Visualizza i Capitoli",
+ "LabelViewPlayerSettings": "Mostra Impostazioni player",
"LabelViewQueue": "Visualizza coda",
"LabelVolume": "Volume",
"LabelWeekdaysToRun": "Giorni feriali da eseguire",
+ "LabelXBooks": "{0} libri",
+ "LabelXItems": "{0} oggetti",
"LabelYearReviewHide": "Nascondi Anno in rassegna",
"LabelYearReviewShow": "Vedi Anno in rassegna",
"LabelYourAudiobookDuration": "La durata dell'audiolibro",
@@ -598,6 +623,7 @@
"MessageAppriseDescription": "Per utilizzare questa funzione è necessario disporre di un'istanza di
Apprise API in esecuzione o un'API che gestirà quelle stesse richieste.
L'API Url dovrebbe essere il percorso URL completo per inviare la notifica, ad esempio se la tua istanza API è servita cosi .
http://192.168.1.1:8337
Allora dovrai mettere
http://192.168.1.1:8337/notify
.",
"MessageBackupsDescription": "I backup includono utenti, progressi degli utenti, dettagli sugli elementi della libreria, impostazioni del server e immagini archiviate in
/metadata/items
&
/metadata/authors
. I backup non includono i file archiviati nelle cartelle della libreria.",
"MessageBackupsLocationEditNote": "Nota: l'aggiornamento della posizione di backup non sposterà o modificherà i backup esistenti",
+ "MessageBackupsLocationNoEditNote": "Nota: la posizione del backup viene impostata tramite una variabile di ambiente e non può essere modificata qui.",
"MessageBackupsLocationPathEmpty": "Il percorso del backup non può essere vuoto",
"MessageBatchQuickMatchDescription": "Quick Match tenterà di aggiungere copertine e metadati mancanti per gli elementi selezionati. Attiva l'opzione per consentire a Quick Match di sovrascrivere copertine e/o metadati esistenti.",
"MessageBookshelfNoCollections": "Non hai ancora creato nessuna raccolta",
@@ -644,6 +670,7 @@
"MessageConfirmSendEbookToDevice": "Sei sicuro di voler inviare {0} ebook \"{1}\" al Device \"{2}\"?",
"MessageDownloadingEpisode": "Scaricamento dell’episodio in corso",
"MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto",
+ "MessageEmbedFailed": "Incorporamento non riuscito!",
"MessageEmbedFinished": "Incorporamento finito!",
"MessageEpisodesQueuedForDownload": "{0} episodio(i) in coda per lo scaricamento",
"MessageEreaderDevices": "Per garantire la consegna degli ebook, potrebbe essere necessario aggiungere l'indirizzo e-mail sopra indicato come mittente valido per ciascun dispositivo elencato di seguito.",
@@ -698,6 +725,7 @@
"MessageNoUpdatesWereNecessary": "Nessun aggiornamento necessario",
"MessageNoUserPlaylists": "non hai nessuna Playlist",
"MessageNotYetImplemented": "Non Ancora Implementato",
+ "MessageOpmlPreviewNote": "Nota: questa è un'anteprima del file OPML analizzato. Il titolo effettivo del podcast verrà preso dal feed RSS.",
"MessageOr": "o",
"MessagePauseChapter": "Metti in Pausa Capitolo",
"MessagePlayChapter": "Ascolta dall'inizio del capitolo",
@@ -716,6 +744,9 @@
"MessageSelected": "{0} selezionati",
"MessageServerCouldNotBeReached": "Impossibile raggiungere il server",
"MessageSetChaptersFromTracksDescription": "Impostare i capitoli utilizzando ciascun file audio come capitolo e il titolo del capitolo come nome del file audio",
+ "MessageShareExpirationWillBe": "Scadrà tra
{0} ",
+ "MessageShareExpiresIn": "Scade in {0}",
+ "MessageShareURLWillBe": "L'indirizzo sarà:
{0} ",
"MessageStartPlaybackAtTime": "Avvia la riproduzione per \"{0}\" a {1}?",
"MessageThinking": "Elaborazione...",
"MessageUploaderItemFailed": "Caricamento Fallito",
diff --git a/client/strings/nl.json b/client/strings/nl.json
index 18bb421828..e209c3a508 100644
--- a/client/strings/nl.json
+++ b/client/strings/nl.json
@@ -1,15 +1,15 @@
{
"ButtonAdd": "Toevoegen",
"ButtonAddChapters": "Hoofdstukken toevoegen",
- "ButtonAddDevice": "Add Device",
- "ButtonAddLibrary": "Add Library",
+ "ButtonAddDevice": "Toestel toevoegen",
+ "ButtonAddLibrary": "Bibliotheek toevoegen",
"ButtonAddPodcasts": "Podcasts toevoegen",
- "ButtonAddUser": "Add User",
+ "ButtonAddUser": "Gebruiker toevoegen",
"ButtonAddYourFirstLibrary": "Voeg je eerste bibliotheek toe",
"ButtonApply": "Pas toe",
"ButtonApplyChapters": "Hoofdstukken toepassen",
"ButtonAuthors": "Auteurs",
- "ButtonBack": "Back",
+ "ButtonBack": "Terug",
"ButtonBrowseForFolder": "Bladeren naar map",
"ButtonCancel": "Annuleren",
"ButtonCancelEncode": "Encoding annuleren",
@@ -32,9 +32,9 @@
"ButtonFullPath": "Volledig pad",
"ButtonHide": "Verberg",
"ButtonHome": "Home",
- "ButtonIssues": "Issues",
- "ButtonJumpBackward": "Jump Backward",
- "ButtonJumpForward": "Jump Forward",
+ "ButtonIssues": "Problemen",
+ "ButtonJumpBackward": "Spring achteruit",
+ "ButtonJumpForward": "Spring vooruit",
"ButtonLatest": "Meest recent",
"ButtonLibrary": "Bibliotheek",
"ButtonLogout": "Log uit",
@@ -44,17 +44,17 @@
"ButtonMatchAllAuthors": "Alle auteurs matchen",
"ButtonMatchBooks": "Alle boeken matchen",
"ButtonNevermind": "Laat maar",
- "ButtonNext": "Next",
- "ButtonNextChapter": "Next Chapter",
+ "ButtonNext": "Volgende",
+ "ButtonNextChapter": "Volgend hoofdstuk",
"ButtonOk": "Ok",
"ButtonOpenFeed": "Feed openen",
"ButtonOpenManager": "Manager openen",
- "ButtonPause": "Pause",
+ "ButtonPause": "Pauze",
"ButtonPlay": "Afspelen",
"ButtonPlaying": "Speelt",
"ButtonPlaylists": "Afspeellijsten",
- "ButtonPrevious": "Previous",
- "ButtonPreviousChapter": "Previous Chapter",
+ "ButtonPrevious": "Vorige",
+ "ButtonPreviousChapter": "Vorig hoofdstuk",
"ButtonPurgeAllCache": "Volledige cache legen",
"ButtonPurgeItemsCache": "Onderdelen-cache legen",
"ButtonQueueAddItem": "In wachtrij zetten",
@@ -62,14 +62,14 @@
"ButtonQuickMatch": "Snelle match",
"ButtonReScan": "Nieuwe scan",
"ButtonRead": "Lees",
- "ButtonReadLess": "Read less",
- "ButtonReadMore": "Read more",
- "ButtonRefresh": "Refresh",
+ "ButtonReadLess": "Lees minder",
+ "ButtonReadMore": "Lees meer",
+ "ButtonRefresh": "Verversen",
"ButtonRemove": "Verwijder",
"ButtonRemoveAll": "Alles verwijderen",
"ButtonRemoveAllLibraryItems": "Verwijder volledige bibliotheekinhoud",
"ButtonRemoveFromContinueListening": "Vewijder uit Verder luisteren",
- "ButtonRemoveFromContinueReading": "Remove from Continue Reading",
+ "ButtonRemoveFromContinueReading": "Verwijder van Verder luisteren",
"ButtonRemoveSeriesFromContinueSeries": "Verwijder serie uit Serie vervolgen",
"ButtonReset": "Reset",
"ButtonResetToDefault": "Reset to default",
@@ -83,7 +83,7 @@
"ButtonSelectFolderPath": "Maplocatie selecteren",
"ButtonSeries": "Series",
"ButtonSetChaptersFromTracks": "Maak hoofdstukken op basis van tracks",
- "ButtonShare": "Share",
+ "ButtonShare": "Deel",
"ButtonShiftTimes": "Tijden verschuiven",
"ButtonShow": "Toon",
"ButtonStartM4BEncode": "Start M4B-encoding",
@@ -98,9 +98,9 @@
"ButtonUserEdit": "Wijzig gebruiker {0}",
"ButtonViewAll": "Toon alle",
"ButtonYes": "Ja",
- "ErrorUploadFetchMetadataAPI": "Error fetching metadata",
- "ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
- "ErrorUploadLacksTitle": "Must have a title",
+ "ErrorUploadFetchMetadataAPI": "Error metadata ophalen",
+ "ErrorUploadFetchMetadataNoResults": "Kan metadata niet ophalen - probeer de titel en/of auteur te updaten",
+ "ErrorUploadLacksTitle": "Moet een titel hebben",
"HeaderAccount": "Account",
"HeaderAdvanced": "Geavanceerd",
"HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen",
@@ -113,13 +113,13 @@
"HeaderChooseAFolder": "Map kiezen",
"HeaderCollection": "Collectie",
"HeaderCollectionItems": "Collectie-objecten",
- "HeaderCover": "Cover",
+ "HeaderCover": "Omslag",
"HeaderCurrentDownloads": "Huidige downloads",
"HeaderCustomMessageOnLogin": "Custom Message on Login",
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
"HeaderDetails": "Details",
"HeaderDownloadQueue": "Download-wachtrij",
- "HeaderEbookFiles": "Ebook Files",
+ "HeaderEbookFiles": "Ebook bestanden",
"HeaderEmail": "E-mail",
"HeaderEmailSettings": "E-mail instellingen",
"HeaderEpisodes": "Afleveringen",
@@ -239,11 +239,11 @@
"LabelChapterTitle": "Hoofdstuktitel",
"LabelChapters": "Hoofdstukken",
"LabelChaptersFound": "Hoofdstukken gevonden",
- "LabelClickForMoreInfo": "Click for more info",
+ "LabelClickForMoreInfo": "Klik voor meer informatie",
"LabelClosePlayer": "Sluit speler",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Series inklappen",
- "LabelCollection": "Collection",
+ "LabelCollection": "Collectie",
"LabelCollections": "Collecties",
"LabelComplete": "Compleet",
"LabelConfirmPassword": "Bevestig wachtwoord",
@@ -258,6 +258,7 @@
"LabelCurrently": "Op dit moment:",
"LabelCustomCronExpression": "Aangepaste Cron-uitdrukking:",
"LabelDatetime": "Datum-tijd",
+ "LabelDays": "Dagen",
"LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)",
"LabelDescription": "Beschrijving",
"LabelDeselectAll": "Deselecteer alle",
@@ -296,7 +297,7 @@
"LabelExplicitChecked": "Explicit (checked)",
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
"LabelFeedURL": "Feed URL",
- "LabelFetchingMetadata": "Fetching Metadata",
+ "LabelFetchingMetadata": "Metadata ophalen",
"LabelFile": "Bestand",
"LabelFileBirthtime": "Aanmaaktijd bestand",
"LabelFileModified": "Bestand gewijzigd",
@@ -306,7 +307,7 @@
"LabelFinished": "Voltooid",
"LabelFolder": "Map",
"LabelFolders": "Mappen",
- "LabelFontBold": "Bold",
+ "LabelFontBold": "Vetgedrukt",
"LabelFontBoldness": "Font Boldness",
"LabelFontFamily": "Lettertypefamilie",
"LabelFontItalic": "Italic",
@@ -321,6 +322,7 @@
"LabelHighestPriority": "Highest priority",
"LabelHost": "Host",
"LabelHour": "Uur",
+ "LabelHours": "Uren",
"LabelIcon": "Icoon",
"LabelImageURLFromTheWeb": "Image URL from the web",
"LabelInProgress": "Bezig",
@@ -567,7 +569,7 @@
"LabelTracksSingleTrack": "Enkele track",
"LabelType": "Type",
"LabelUnabridged": "Onverkort",
- "LabelUndo": "Undo",
+ "LabelUndo": "Ongedaan maken",
"LabelUnknown": "Onbekend",
"LabelUpdateCover": "Cover bijwerken",
"LabelUpdateCoverHelp": "Sta overschrijven van bestaande covers toe voor de geselecteerde boeken wanneer een match is gevonden",
@@ -630,7 +632,7 @@
"MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?",
"MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?",
"MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen wil verwijderen?",
- "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
+ "MessageConfirmRemoveListeningSessions": "Weet je zeker dat je {0} luistersessies wilt verwijderen?",
"MessageConfirmRemoveNarrator": "Weet je zeker dat je verteller \"{0}\" wil verwijderen?",
"MessageConfirmRemovePlaylist": "Weet je zeker dat je je afspeellijst \"{0}\" wil verwijderen?",
"MessageConfirmRenameGenre": "Weet je zeker dat je genre \"{0}\" wil hernoemen naar \"{1}\" voor alle onderdelen?",
@@ -714,6 +716,7 @@
"MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "Server niet bereikbaar",
"MessageSetChaptersFromTracksDescription": "Stel hoofdstukken in met ieder audiobestand als een hoofdstuk en de audiobestandsnaam als hoofdstuktitel",
+ "MessageShareExpiresIn": "Vervalt in {0}",
"MessageStartPlaybackAtTime": "Afspelen van \"{0}\" beginnen op {1}?",
"MessageThinking": "Aan het denken...",
"MessageUploaderItemFailed": "Uploaden mislukt",
diff --git a/client/strings/no.json b/client/strings/no.json
index cf60de7e8c..6db2d98fb6 100644
--- a/client/strings/no.json
+++ b/client/strings/no.json
@@ -1,8 +1,8 @@
{
"ButtonAdd": "Legg til",
"ButtonAddChapters": "Legg til kapittel",
- "ButtonAddDevice": "Add Device",
- "ButtonAddLibrary": "Add Library",
+ "ButtonAddDevice": "Legg til enhet",
+ "ButtonAddLibrary": "Legg til bibliotek",
"ButtonAddPodcasts": "Legg til podcast",
"ButtonAddUser": "Add User",
"ButtonAddYourFirstLibrary": "Legg til ditt første bibliotek",
@@ -33,8 +33,8 @@
"ButtonHide": "Gjøm",
"ButtonHome": "Hjem",
"ButtonIssues": "Problemer",
- "ButtonJumpBackward": "Jump Backward",
- "ButtonJumpForward": "Jump Forward",
+ "ButtonJumpBackward": "Hopp Bakover",
+ "ButtonJumpForward": "Hopp Fremover",
"ButtonLatest": "Siste",
"ButtonLibrary": "Bibliotek",
"ButtonLogout": "Logg ut",
@@ -45,7 +45,7 @@
"ButtonMatchBooks": "Søk opp bøker",
"ButtonNevermind": "Avbryt",
"ButtonNext": "Next",
- "ButtonNextChapter": "Next Chapter",
+ "ButtonNextChapter": "Neste Kapittel",
"ButtonOk": "Ok",
"ButtonOpenFeed": "Åpne Feed",
"ButtonOpenManager": "Åpne behandler",
@@ -53,18 +53,19 @@
"ButtonPlay": "Spill av",
"ButtonPlaying": "Spiller av",
"ButtonPlaylists": "Spillelister",
- "ButtonPrevious": "Previous",
- "ButtonPreviousChapter": "Previous Chapter",
+ "ButtonPrevious": "Forrige",
+ "ButtonPreviousChapter": "Forrige Kapittel",
"ButtonPurgeAllCache": "Tøm alle mellomlager",
"ButtonPurgeItemsCache": "Tøm mellomlager",
"ButtonQueueAddItem": "Legg til kø",
"ButtonQueueRemoveItem": "Fjern fra kø",
+ "ButtonQuickEmbedMetadata": "Hurtig Innbygging Av Metadata",
"ButtonQuickMatch": "Kjapt søk",
"ButtonReScan": "Skann på nytt",
"ButtonRead": "Les",
- "ButtonReadLess": "Read less",
- "ButtonReadMore": "Read more",
- "ButtonRefresh": "Refresh",
+ "ButtonReadLess": "Les Mindre",
+ "ButtonReadMore": "Les Mer",
+ "ButtonRefresh": "Oppdater",
"ButtonRemove": "Fjern",
"ButtonRemoveAll": "Fjern alle",
"ButtonRemoveAllLibraryItems": "Fjern alle bibliotekobjekter",
@@ -83,11 +84,12 @@
"ButtonSelectFolderPath": "Velg mappe",
"ButtonSeries": "Serier",
"ButtonSetChaptersFromTracks": "Sett kapittel fra spor",
- "ButtonShare": "Share",
+ "ButtonShare": "Del",
"ButtonShiftTimes": "Forskyv tider",
"ButtonShow": "Vis",
"ButtonStartM4BEncode": "Start M4B Koding",
"ButtonStartMetadataEmbed": "Start Metadata innbaking",
+ "ButtonStats": "Statistikk",
"ButtonSubmit": "Send inn",
"ButtonTest": "Test",
"ButtonUpload": "Last opp",
@@ -98,7 +100,7 @@
"ButtonUserEdit": "Rediger bruker {0}",
"ButtonViewAll": "Vis alt",
"ButtonYes": "Ja",
- "ErrorUploadFetchMetadataAPI": "Error fetching metadata",
+ "ErrorUploadFetchMetadataAPI": "Feil ved innhenting av metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Konto",
@@ -143,12 +145,12 @@
"HeaderManageTags": "Behandle tags",
"HeaderMapDetails": "Kartleggingsdetaljer",
"HeaderMatch": "Tilpasse",
- "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence",
+ "HeaderMetadataOrderOfPrecedence": "Prioriteringsrekkefølge for metadata",
"HeaderMetadataToEmbed": "Metadata å bake inn",
"HeaderNewAccount": "Ny konto",
"HeaderNewLibrary": "Ny bibliotek",
"HeaderNotifications": "Notifikasjoner",
- "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
+ "HeaderOpenIDConnectAuthentication": "Autentisering med OpenID Connect",
"HeaderOpenRSSFeed": "Åpne RSS Feed",
"HeaderOtherFiles": "Andre filer",
"HeaderPasswordAuthentication": "Password Authentication",
@@ -203,7 +205,7 @@
"LabelAddToPlaylist": "Legg til i spilleliste",
"LabelAddToPlaylistBatch": "Legg {0} enheter til i spilleliste",
"LabelAdded": "Lagt til",
- "LabelAddedAt": "Tillagt",
+ "LabelAddedAt": "Lagt Til",
"LabelAdminUsersOnly": "Admin users only",
"LabelAll": "Alle",
"LabelAllUsers": "Alle brukere",
diff --git a/client/strings/pl.json b/client/strings/pl.json
index 92dd2735d5..0fe8535dda 100644
--- a/client/strings/pl.json
+++ b/client/strings/pl.json
@@ -62,8 +62,8 @@
"ButtonQuickMatch": "Szybkie dopasowanie",
"ButtonReScan": "Ponowne skanowanie",
"ButtonRead": "Czytaj",
- "ButtonReadLess": "Read less",
- "ButtonReadMore": "Read more",
+ "ButtonReadLess": "Pokaż mniej",
+ "ButtonReadMore": "Pokaż więcej",
"ButtonRefresh": "Odśwież",
"ButtonRemove": "Usuń",
"ButtonRemoveAll": "Usuń wszystko",
@@ -88,6 +88,7 @@
"ButtonShow": "Pokaż",
"ButtonStartM4BEncode": "Eksportuj jako plik M4B",
"ButtonStartMetadataEmbed": "Osadź metadane",
+ "ButtonStats": "Statystyki",
"ButtonSubmit": "Zaloguj",
"ButtonTest": "Test",
"ButtonUpload": "Wgraj",
@@ -130,13 +131,13 @@
"HeaderIgnoredFiles": "Zignoruj pliki",
"HeaderItemFiles": "Pliki",
"HeaderItemMetadataUtils": "Item Metadata Utils",
- "HeaderLastListeningSession": "Ostatnio odtwarzana sesja",
+ "HeaderLastListeningSession": "Ostatnia sesja słuchania",
"HeaderLatestEpisodes": "Najnowsze odcinki",
"HeaderLibraries": "Biblioteki",
"HeaderLibraryFiles": "Pliki w bibliotece",
"HeaderLibraryStats": "Statystyki biblioteki",
"HeaderListeningSessions": "Sesje słuchania",
- "HeaderListeningStats": "Statystyki odtwarzania",
+ "HeaderListeningStats": "Statystyki słuchania",
"HeaderLogin": "Zaloguj się",
"HeaderLogs": "Logi",
"HeaderManageGenres": "Zarządzaj gatunkami",
@@ -148,12 +149,13 @@
"HeaderNewAccount": "Nowe konto",
"HeaderNewLibrary": "Nowa biblioteka",
"HeaderNotifications": "Powiadomienia",
- "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
+ "HeaderOpenIDConnectAuthentication": "Uwierzytelnianie OpenID Connect",
"HeaderOpenRSSFeed": "Utwórz kanał RSS",
"HeaderOtherFiles": "Inne pliki",
"HeaderPasswordAuthentication": "Uwierzytelnianie hasłem",
"HeaderPermissions": "Uprawnienia",
"HeaderPlayerQueue": "Kolejka odtwarzania",
+ "HeaderPlayerSettings": "Ustawienia Odtwarzania",
"HeaderPlaylist": "Playlista",
"HeaderPlaylistItems": "Pozycje listy odtwarzania",
"HeaderPodcastsToAdd": "Podcasty do dodania",
@@ -175,7 +177,7 @@
"HeaderSettingsScanner": "Skanowanie",
"HeaderSleepTimer": "Wyłącznik czasowy",
"HeaderStatsLargestItems": "Największe pozycje",
- "HeaderStatsLongestItems": "Najdłuższe pozycje (hrs)",
+ "HeaderStatsLongestItems": "Najdłuższe pozycje (godziny)",
"HeaderStatsMinutesListeningChart": "Czas słuchania w minutach (ostatnie 7 dni)",
"HeaderStatsRecentSessions": "Ostatnie sesje",
"HeaderStatsTop10Authors": "Top 10 Autorów",
@@ -200,8 +202,8 @@
"LabelActivity": "Aktywność",
"LabelAddToCollection": "Dodaj do kolekcji",
"LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji",
- "LabelAddToPlaylist": "Add to Playlist",
- "LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
+ "LabelAddToPlaylist": "Dodaj do playlisty",
+ "LabelAddToPlaylistBatch": "Dodaj {0} pozycji do playlisty",
"LabelAdded": "Dodane",
"LabelAddedAt": "Dodano",
"LabelAdminUsersOnly": "Tylko użytkownicy administracyjni",
@@ -226,14 +228,14 @@
"LabelBackupLocation": "Lokalizacja kopii zapasowej",
"LabelBackupsEnableAutomaticBackups": "Włącz automatyczne kopie zapasowe",
"LabelBackupsEnableAutomaticBackupsHelp": "Kopie zapasowe są zapisywane w folderze /metadata/backups",
- "LabelBackupsMaxBackupSize": "Maksymalny łączny rozmiar backupów (w GB)",
+ "LabelBackupsMaxBackupSize": "Maksymalny rozmiar kopii zapasowej (w GB)",
"LabelBackupsMaxBackupSizeHelp": "Jako zabezpieczenie przed błędną konfiguracją, kopie zapasowe nie będą wykonywane, jeśli przekroczą skonfigurowany rozmiar.",
"LabelBackupsNumberToKeep": "Liczba kopii zapasowych do przechowywania",
"LabelBackupsNumberToKeepHelp": "Tylko 1 kopia zapasowa zostanie usunięta, więc jeśli masz już więcej kopii zapasowych, powinieneś je ręcznie usunąć.",
"LabelBitrate": "Bitrate",
"LabelBooks": "Książki",
"LabelButtonText": "Button Text",
- "LabelByAuthor": "by {0}",
+ "LabelByAuthor": "autorstwa {0}",
"LabelChangePassword": "Zmień hasło",
"LabelChannels": "Kanały",
"LabelChapterTitle": "Tytuł rozdziału",
@@ -247,7 +249,7 @@
"LabelCollections": "Kolekcje",
"LabelComplete": "Ukończone",
"LabelConfirmPassword": "Potwierdź hasło",
- "LabelContinueListening": "Kontynuuj odtwarzanie",
+ "LabelContinueListening": "Kontynuuj słuchanie",
"LabelContinueReading": "Kontynuuj czytanie",
"LabelContinueSeries": "Kontynuuj serię",
"LabelCover": "Okładka",
@@ -319,6 +321,7 @@
"LabelHardDeleteFile": "Usuń trwale plik",
"LabelHasEbook": "Ma ebooka",
"LabelHasSupplementaryEbook": "Posiada dodatkowy ebook",
+ "LabelHideSubtitles": "Ukryj napisy",
"LabelHighestPriority": "Najwyższy priorytet",
"LabelHost": "Host",
"LabelHour": "Godzina",
@@ -413,7 +416,7 @@
"LabelOverwrite": "Nadpisz",
"LabelPassword": "Hasło",
"LabelPath": "Ścieżka",
- "LabelPermanent": "Trwały",
+ "LabelPermanent": "Stałe",
"LabelPermissionsAccessAllLibraries": "Ma dostęp do wszystkich bibliotek",
"LabelPermissionsAccessAllTags": "Ma dostęp do wszystkich tagów",
"LabelPermissionsAccessExplicitContent": "Ma dostęp do treści oznacznych jako nieprzyzwoite",
@@ -446,6 +449,7 @@
"LabelRSSFeedPreventIndexing": "Zapobiegaj indeksowaniu",
"LabelRSSFeedSlug": "RSS Feed Slug",
"LabelRSSFeedURL": "URL kanały RSS",
+ "LabelReAddSeriesToContinueListening": "Ponownie Dodaj Serię do sekcji Kontunuuj Odtwarzanie",
"LabelRead": "Czytaj",
"LabelReadAgain": "Czytaj ponownie",
"LabelReadEbookWithoutProgress": "Czytaj książkę bez zapamiętywania postępu",
@@ -516,6 +520,7 @@
"LabelShareURL": "Link do udziału",
"LabelShowAll": "Pokaż wszystko",
"LabelShowSeconds": "Pokaż sekundy",
+ "LabelShowSubtitles": "Pokaż Napisy",
"LabelSize": "Rozmiar",
"LabelSleepTimer": "Wyłącznik czasowy",
"LabelSlug": "Slug",
@@ -534,10 +539,10 @@
"LabelStatsItemsFinished": "Pozycje zakończone",
"LabelStatsItemsInLibrary": "Pozycje w bibliotece",
"LabelStatsMinutes": "Minuty",
- "LabelStatsMinutesListening": "Minuty odtwarzania",
+ "LabelStatsMinutesListening": "Minuty słuchania",
"LabelStatsOverallDays": "Całkowity czas (dni)",
"LabelStatsOverallHours": "Całkowity czas (godziny)",
- "LabelStatsWeekListening": "Tydzień odtwarzania",
+ "LabelStatsWeekListening": "Tydzień słuchania",
"LabelSubtitle": "Podtytuł",
"LabelSupportedFileTypes": "Obsługiwane typy plików",
"LabelTag": "Tag",
@@ -592,6 +597,7 @@
"LabelVersion": "Wersja",
"LabelViewBookmarks": "Wyświetlaj zakładki",
"LabelViewChapters": "Wyświetlaj rozdziały",
+ "LabelViewPlayerSettings": "Zobacz ustawienia odtwarzacza",
"LabelViewQueue": "Wyświetlaj kolejkę odtwarzania",
"LabelVolume": "Głośność",
"LabelWeekdaysToRun": "Dni tygodnia",
@@ -642,7 +648,7 @@
"MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?",
"MessageConfirmRemoveListeningSessions": "Czy na pewno chcesz usunąć {0} sesji słuchania?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
- "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
+ "MessageConfirmRemovePlaylist": "Czy jesteś pewien, że chcesz usunąć twoją playlistę \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
@@ -663,7 +669,7 @@
"MessageItemsSelected": "{0} zaznaczone elementy",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Dołącz do nas na",
- "MessageListeningSessionsInTheLastYear": "{0} sesje odsłuchowe w ostatnim roku",
+ "MessageListeningSessionsInTheLastYear": "Sesje słuchania w ostatnim roku: {0}",
"MessageLoading": "Ładowanie...",
"MessageLoadingFolders": "Ładowanie folderów...",
"MessageLogsDescription": "Logi zapisane są w
/metadata/logs
jako pliki JSON. Logi awaryjne są zapisane w
/metadata/logs/crash_logs.txt
.",
@@ -692,7 +698,7 @@
"MessageNoIssues": "Brak problemów",
"MessageNoItems": "Brak elementów",
"MessageNoItemsFound": "Nie znaleziono żadnych elementów",
- "MessageNoListeningSessions": "Brak sesji odtwarzania",
+ "MessageNoListeningSessions": "Brak sesji słuchania",
"MessageNoLogs": "Brak logów",
"MessageNoMediaProgress": "Brak postępu",
"MessageNoNotifications": "Brak powiadomień",
@@ -709,7 +715,7 @@
"MessageOr": "lub",
"MessagePauseChapter": "Zatrzymaj odtwarzanie rozdziały",
"MessagePlayChapter": "Rozpocznij odtwarzanie od początku rozdziału",
- "MessagePlaylistCreateFromCollection": "Utwórz listę odtwarznia na podstawie kolekcji",
+ "MessagePlaylistCreateFromCollection": "Utwórz listę odtwarzania na podstawie kolekcji",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nie ma adresu url kanału RSS, który mógłby zostać użyty do dopasowania",
"MessageQuickMatchDescription": "Wypełnij puste informacje i okładkę pierwszym wynikiem dopasowania z '{0}'. Nie nadpisuje szczegółów, chyba że włączone jest ustawienie serwera 'Preferuj dopasowane metadane'.",
"MessageRemoveChapter": "Usuń rozdział",
@@ -724,8 +730,9 @@
"MessageSelected": "{0} wybranych",
"MessageServerCouldNotBeReached": "Nie udało się uzyskać połączenia z serwerem",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
+ "MessageShareExpirationWillBe": "Czas udostępniania
{0} ",
"MessageShareExpiresIn": "Wygaśnie za {0}",
- "MessageShareURLWillBe": "URL udziału będzie
{0} ",
+ "MessageShareURLWillBe": "Udostępnione pod linkiem
{0} ",
"MessageStartPlaybackAtTime": "Rozpoczęcie odtwarzania \"{0}\" od {1}?",
"MessageThinking": "Myślę...",
"MessageUploaderItemFailed": "Nie udało się przesłać",
@@ -746,7 +753,7 @@
"NoteUploaderUnsupportedFiles": "Nieobsługiwane pliki są ignorowane. Podczas dodawania folderu, inne pliki, które nie znajdują się w folderze elementu, są ignorowane.",
"PlaceholderNewCollection": "Nowa nazwa kolekcji",
"PlaceholderNewFolderPath": "Nowa ścieżka folderu",
- "PlaceholderNewPlaylist": "New playlist name",
+ "PlaceholderNewPlaylist": "Nowa nazwa playlisty",
"PlaceholderSearch": "Szukanie..",
"PlaceholderSearchEpisode": "Szukanie odcinka..",
"ToastAccountUpdateFailed": "Nie udało się zaktualizować konta",
@@ -802,12 +809,12 @@
"ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki",
"ToastLibraryUpdateFailed": "Nie udało się zaktualizować biblioteki",
"ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji",
- "ToastPlaylistCreateFailed": "Failed to create playlist",
- "ToastPlaylistCreateSuccess": "Playlist created",
- "ToastPlaylistRemoveFailed": "Failed to remove playlist",
- "ToastPlaylistRemoveSuccess": "Playlist removed",
- "ToastPlaylistUpdateFailed": "Failed to update playlist",
- "ToastPlaylistUpdateSuccess": "Playlist updated",
+ "ToastPlaylistCreateFailed": "Nie udało się utworzyć playlisty",
+ "ToastPlaylistCreateSuccess": "Playlista utworzona",
+ "ToastPlaylistRemoveFailed": "Nie udało się usunąć playlisty",
+ "ToastPlaylistRemoveSuccess": "Playlista usunięta",
+ "ToastPlaylistUpdateFailed": "Nie udało się zaktualizować playlisty",
+ "ToastPlaylistUpdateSuccess": "Playlista zaktualizowana",
"ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu",
"ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony",
"ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się",
diff --git a/client/strings/uk.json b/client/strings/uk.json
index 7e620ed594..ba6575d8d3 100644
--- a/client/strings/uk.json
+++ b/client/strings/uk.json
@@ -59,6 +59,7 @@
"ButtonPurgeItemsCache": "Очистити кеш елементів",
"ButtonQueueAddItem": "Додати до черги",
"ButtonQueueRemoveItem": "Вилучити з черги",
+ "ButtonQuickEmbedMetadata": "Швидко вбудувати метадані",
"ButtonQuickMatch": "Швидкий пошук",
"ButtonReScan": "Пересканувати",
"ButtonRead": "Читати",
@@ -88,6 +89,7 @@
"ButtonShow": "Показати",
"ButtonStartM4BEncode": "Почати кодування у M4B",
"ButtonStartMetadataEmbed": "Почати вбудування метаданих",
+ "ButtonStats": "Статистика",
"ButtonSubmit": "Надіслати",
"ButtonTest": "Перевірити",
"ButtonUpload": "Завантажити",
@@ -154,6 +156,7 @@
"HeaderPasswordAuthentication": "Автентифікація за паролем",
"HeaderPermissions": "Дозволи",
"HeaderPlayerQueue": "Черга відтворення",
+ "HeaderPlayerSettings": "Налаштування програвача",
"HeaderPlaylist": "Список відтворення",
"HeaderPlaylistItems": "Елементи списку відтворення",
"HeaderPodcastsToAdd": "Додати подкасти",
@@ -226,7 +229,7 @@
"LabelBackupLocation": "Розташування резервних копій",
"LabelBackupsEnableAutomaticBackups": "Автоматичне резервне копіювання",
"LabelBackupsEnableAutomaticBackupsHelp": "Резервні копії збережено у /metadata/backups",
- "LabelBackupsMaxBackupSize": "Максимальний розмір резервної копії (у ГБ)",
+ "LabelBackupsMaxBackupSize": "Максимальний розмір резервної копії (у ГБ) (0 — необмежене)",
"LabelBackupsMaxBackupSizeHelp": "У якості захисту від неправильного налаштування, резервну копію не буде збережено, якщо її розмір перевищуватиме вказаний.",
"LabelBackupsNumberToKeep": "Кількість резервних копій",
"LabelBackupsNumberToKeepHelp": "Лиш 1 резервну копію буде видалено за раз, тож якщо їх багато, то вам варто видалити їх вручну.",
@@ -258,6 +261,7 @@
"LabelCurrently": "Поточний:",
"LabelCustomCronExpression": "Спеціальна команда cron:",
"LabelDatetime": "Дата й час",
+ "LabelDays": "Днів",
"LabelDeleteFromFileSystemCheckbox": "Видалити з файлової системи (зніміть прапорець, щоб видалити лише з бази даних)",
"LabelDescription": "Опис",
"LabelDeselectAll": "Скасувати вибір",
@@ -288,13 +292,16 @@
"LabelEmbeddedCover": "Вбудована обкладинка",
"LabelEnable": "Увімкнути",
"LabelEnd": "Кінець",
+ "LabelEndOfChapter": "Кінець глави",
"LabelEpisode": "Епізод",
"LabelEpisodeTitle": "Назва епізоду",
"LabelEpisodeType": "Тип епізоду",
"LabelExample": "Приклад",
+ "LabelExpandSeries": "Розгорнути серії",
"LabelExplicit": "Відверта",
"LabelExplicitChecked": "Відверта (з прапорцем)",
"LabelExplicitUnchecked": "Не відверта (без прапорця)",
+ "LabelExportOPML": "Експорт OPML",
"LabelFeedURL": "Адреса стрічки",
"LabelFetchingMetadata": "Отримання метаданих",
"LabelFile": "Файл",
@@ -318,9 +325,11 @@
"LabelHardDeleteFile": "Остаточно видалити файл",
"LabelHasEbook": "Має електронну книгу",
"LabelHasSupplementaryEbook": "Має додаткову електронну книгу",
+ "LabelHideSubtitles": "Приховати субтитри",
"LabelHighestPriority": "Найвищий пріоритет",
"LabelHost": "Гост",
"LabelHour": "Година",
+ "LabelHours": "Години",
"LabelIcon": "Іконка",
"LabelImageURLFromTheWeb": "URL зображення з мережі",
"LabelInProgress": "У процесі",
@@ -337,6 +346,8 @@
"LabelIntervalEveryHour": "Щогодини",
"LabelInvert": "Інвертувати",
"LabelItem": "Елемент",
+ "LabelJumpBackwardAmount": "Час переходу назад",
+ "LabelJumpForwardAmount": "Час переходу вперед",
"LabelLanguage": "Мова",
"LabelLanguageDefaultServer": "Типова мова сервера",
"LabelLanguages": "Мови",
@@ -371,6 +382,7 @@
"LabelMetadataOrderOfPrecedenceDescription": "Пріоритетніші джерела метаданих перезапишуть менш пріоритетні метадані",
"LabelMetadataProvider": "Джерело метаданих",
"LabelMinute": "Хвилина",
+ "LabelMinutes": "Хвилини",
"LabelMissing": "Бракує",
"LabelMissingEbook": "Без електронної книги",
"LabelMissingSupplementaryEbook": "Без додаткової електронної книги",
@@ -410,6 +422,7 @@
"LabelOverwrite": "Перезаписати",
"LabelPassword": "Пароль",
"LabelPath": "Шлях",
+ "LabelPermanent": "Постійний",
"LabelPermissionsAccessAllLibraries": "Доступ до усіх бібліотек",
"LabelPermissionsAccessAllTags": "Доступ до усіх міток",
"LabelPermissionsAccessExplicitContent": "Доступ до відвертого вмісту",
@@ -442,6 +455,7 @@
"LabelRSSFeedPreventIndexing": "Запобігати індексації",
"LabelRSSFeedSlug": "Назва RSS-каналу",
"LabelRSSFeedURL": "Адреса RSS-каналу",
+ "LabelReAddSeriesToContinueListening": "Заново додати серії до Продовжити слухати",
"LabelRead": "Читати",
"LabelReadAgain": "Читати знову",
"LabelReadEbookWithoutProgress": "Читати книгу без збереження прогресу",
@@ -507,8 +521,12 @@
"LabelSettingsStoreMetadataWithItem": "Зберігати метадані з елементом",
"LabelSettingsStoreMetadataWithItemHelp": "За замовчуванням файли метаданих зберігаються у /metadata/items. Цей параметр увімкне збереження метаданих у теці елемента бібліотеки",
"LabelSettingsTimeFormat": "Формат часу",
+ "LabelShare": "Поділитися",
+ "LabelShareOpen": "Поділитися відкрито",
+ "LabelShareURL": "Поділитися URL",
"LabelShowAll": "Показати все",
"LabelShowSeconds": "Показувати секунди",
+ "LabelShowSubtitles": "Показати субтитри",
"LabelSize": "Розмір",
"LabelSleepTimer": "Таймер вимкнення",
"LabelSlug": "Назва",
@@ -546,6 +564,10 @@
"LabelThemeDark": "Темна",
"LabelThemeLight": "Світла",
"LabelTimeBase": "Шкала часу",
+ "LabelTimeDurationXHours": "{0} години",
+ "LabelTimeDurationXMinutes": "{0} хвилини",
+ "LabelTimeDurationXSeconds": "{0} секунди",
+ "LabelTimeInMinutes": "Час у хвилинах",
"LabelTimeListened": "Часу прослухано",
"LabelTimeListenedToday": "Сьогодні прослухано",
"LabelTimeRemaining": "Лишилося: {0}",
@@ -585,6 +607,7 @@
"LabelVersion": "Версія",
"LabelViewBookmarks": "Переглянути закладки",
"LabelViewChapters": "Переглянути глави",
+ "LabelViewPlayerSettings": "Переглянути налаштування програвача",
"LabelViewQueue": "Переглянути чергу відтворення",
"LabelVolume": "Гучність",
"LabelWeekdaysToRun": "Виконувати у дні",
@@ -597,6 +620,9 @@
"MessageAddToPlayerQueue": "Додати до черги відтворення",
"MessageAppriseDescription": "Щоб скористатися цією функцією, вам потрібно мати запущену
Apprise API або API, що оброблятиме ті ж запити.
Аби надсилати сповіщення, URL-адреса API Apprise мусить бути повною, наприклад, якщо ваш API розміщено за адресою
http://192.168.1.1:8337
, то необхідно вказати адресу
http://192.168.1.1:8337/notify
.",
"MessageBackupsDescription": "Резервні копії містять користувачів, прогрес, подробиці елементів бібліотеки, налаштування сервера та зображення з
/metadata/items
та
/metadata/authors
. Резервні копії
не містять жодних файлів з тек бібліотеки.",
+ "MessageBackupsLocationEditNote": "Примітка: оновлення розташування резервної копії не переносить та не змінює існуючих копій",
+ "MessageBackupsLocationNoEditNote": "Примітка: розташування резервної копії встановлюється за допомогою змінної середовища та не може бути змінене тут.",
+ "MessageBackupsLocationPathEmpty": "Шлях розташування резервної копії не може бути порожнім",
"MessageBatchQuickMatchDescription": "Швидкий пошук спробує знайти відсутні обкладинки та метадані обраних елементів. Увімкніть налаштування нижче, аби дозволити заміну наявних обкладинок та/або метаданих під час швидкого пошуку.",
"MessageBookshelfNoCollections": "Ви не створили жодної добірки",
"MessageBookshelfNoRSSFeeds": "Немає відкритих RSS-каналів",
@@ -696,6 +722,7 @@
"MessageNoUpdatesWereNecessary": "Оновлень не потрібно",
"MessageNoUserPlaylists": "У вас немає списків відтворення",
"MessageNotYetImplemented": "Ще не реалізовано",
+ "MessageOpmlPreviewNote": "Примітка: це попередній перегляд OPML-файлу. Актуальна назва подкасту буде завантажена з RSS-каналу.",
"MessageOr": "або",
"MessagePauseChapter": "Призупинити відтворення глави",
"MessagePlayChapter": "Слухати початок глави",
@@ -714,6 +741,9 @@
"MessageSelected": "Вибрано: {0}",
"MessageServerCouldNotBeReached": "Не вдалося підключитися до сервера",
"MessageSetChaptersFromTracksDescription": "Створити глави з аудіодоріжок, встановивши назви файлів за заголовки",
+ "MessageShareExpirationWillBe": "Термін сплине за
{0} ",
+ "MessageShareExpiresIn": "Сплине за {0}",
+ "MessageShareURLWillBe": "Поширюваний URL -
{0} ",
"MessageStartPlaybackAtTime": "Почати відтворення \"{0}\" з {1}?",
"MessageThinking": "Думаю…",
"MessageUploaderItemFailed": "Не вдалося завантажити",
diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json
index 3f37714dc6..b15eb99e9d 100644
--- a/client/strings/zh-cn.json
+++ b/client/strings/zh-cn.json
@@ -49,7 +49,7 @@
"ButtonOk": "确定",
"ButtonOpenFeed": "打开源",
"ButtonOpenManager": "打开管理器",
- "ButtonPause": "Pause",
+ "ButtonPause": "暂停",
"ButtonPlay": "播放",
"ButtonPlaying": "正在播放",
"ButtonPlaylists": "播放列表",
@@ -59,6 +59,7 @@
"ButtonPurgeItemsCache": "清理项目缓存",
"ButtonQueueAddItem": "添加到队列",
"ButtonQueueRemoveItem": "从队列中移除",
+ "ButtonQuickEmbedMetadata": "快速嵌入元数据",
"ButtonQuickMatch": "快速匹配",
"ButtonReScan": "重新扫描",
"ButtonRead": "读取",
@@ -88,6 +89,7 @@
"ButtonShow": "显示",
"ButtonStartM4BEncode": "开始 M4B 编码",
"ButtonStartMetadataEmbed": "开始嵌入元数据",
+ "ButtonStats": "统计数据",
"ButtonSubmit": "提交",
"ButtonTest": "测试",
"ButtonUpload": "上传",
@@ -154,6 +156,7 @@
"HeaderPasswordAuthentication": "密码认证",
"HeaderPermissions": "权限",
"HeaderPlayerQueue": "播放队列",
+ "HeaderPlayerSettings": "播放器设置",
"HeaderPlaylist": "播放列表",
"HeaderPlaylistItems": "播放列表项目",
"HeaderPodcastsToAdd": "要添加的播客",
@@ -226,7 +229,7 @@
"LabelBackupLocation": "备份位置",
"LabelBackupsEnableAutomaticBackups": "启用自动备份",
"LabelBackupsEnableAutomaticBackupsHelp": "备份保存到 /metadata/backups",
- "LabelBackupsMaxBackupSize": "最大备份大小 (GB)",
+ "LabelBackupsMaxBackupSize": "最大备份大小 (GB) (0 为无限制)",
"LabelBackupsMaxBackupSizeHelp": "为了防止错误配置, 如果备份超过配置的大小, 备份将失败.",
"LabelBackupsNumberToKeep": "要保留的备份个数",
"LabelBackupsNumberToKeepHelp": "一次只能删除一个备份, 因此如果你已经有超过此数量的备份, 则应手动删除它们.",
@@ -258,6 +261,7 @@
"LabelCurrently": "当前:",
"LabelCustomCronExpression": "自定义计划任务表达式:",
"LabelDatetime": "日期时间",
+ "LabelDays": "天",
"LabelDeleteFromFileSystemCheckbox": "从文件系统删除 (取消选中仅从数据库中删除)",
"LabelDescription": "描述",
"LabelDeselectAll": "全部取消选择",
@@ -288,13 +292,16 @@
"LabelEmbeddedCover": "嵌入封面",
"LabelEnable": "启用",
"LabelEnd": "结束",
+ "LabelEndOfChapter": "章节结束",
"LabelEpisode": "剧集",
"LabelEpisodeTitle": "剧集标题",
"LabelEpisodeType": "剧集类型",
"LabelExample": "示例",
+ "LabelExpandSeries": "展开系列",
"LabelExplicit": "信息准确",
"LabelExplicitChecked": "明确(已选中)",
"LabelExplicitUnchecked": "不明确 (未选中)",
+ "LabelExportOPML": "导出 OPML",
"LabelFeedURL": "源 URL",
"LabelFetchingMetadata": "正在获取元数据",
"LabelFile": "文件",
@@ -318,9 +325,11 @@
"LabelHardDeleteFile": "完全删除文件",
"LabelHasEbook": "有电子书",
"LabelHasSupplementaryEbook": "有补充电子书",
+ "LabelHideSubtitles": "隐藏标题",
"LabelHighestPriority": "最高优先级",
"LabelHost": "主机",
"LabelHour": "小时",
+ "LabelHours": "小时",
"LabelIcon": "图标",
"LabelImageURLFromTheWeb": "来自 Web 图像的 URL",
"LabelInProgress": "正在听",
@@ -337,6 +346,8 @@
"LabelIntervalEveryHour": "每小时",
"LabelInvert": "倒转",
"LabelItem": "项目",
+ "LabelJumpBackwardAmount": "向后跳转时间",
+ "LabelJumpForwardAmount": "向前跳转时间",
"LabelLanguage": "语言",
"LabelLanguageDefaultServer": "默认服务器语言",
"LabelLanguages": "语言",
@@ -371,6 +382,7 @@
"LabelMetadataOrderOfPrecedenceDescription": "较高优先级的元数据源将覆盖较低优先级的元数据源",
"LabelMetadataProvider": "元数据提供者",
"LabelMinute": "分钟",
+ "LabelMinutes": "分钟",
"LabelMissing": "丢失",
"LabelMissingEbook": "没有电子书",
"LabelMissingSupplementaryEbook": "没有补充电子书",
@@ -410,6 +422,7 @@
"LabelOverwrite": "覆盖",
"LabelPassword": "密码",
"LabelPath": "路径",
+ "LabelPermanent": "永久的",
"LabelPermissionsAccessAllLibraries": "可以访问所有媒体库",
"LabelPermissionsAccessAllTags": "可以访问所有标签",
"LabelPermissionsAccessExplicitContent": "可以访问显式内容",
@@ -442,6 +455,8 @@
"LabelRSSFeedPreventIndexing": "防止索引",
"LabelRSSFeedSlug": "RSS 源段",
"LabelRSSFeedURL": "RSS 源 URL",
+ "LabelRandomly": "随机",
+ "LabelReAddSeriesToContinueListening": "重新添加系列以继续收听",
"LabelRead": "阅读",
"LabelReadAgain": "再次阅读",
"LabelReadEbookWithoutProgress": "阅读电子书而不保存进度",
@@ -507,8 +522,12 @@
"LabelSettingsStoreMetadataWithItem": "存储项目元数据",
"LabelSettingsStoreMetadataWithItemHelp": "默认情况下元数据文件存储在/metadata/items文件夹中, 启用此设置将存储元数据在你媒体项目文件夹中",
"LabelSettingsTimeFormat": "时间格式",
+ "LabelShare": "分享",
+ "LabelShareOpen": "打开分享",
+ "LabelShareURL": "分享 URL",
"LabelShowAll": "全部显示",
"LabelShowSeconds": "显示秒数",
+ "LabelShowSubtitles": "显示标题",
"LabelSize": "文件大小",
"LabelSleepTimer": "睡眠定时",
"LabelSlug": "Slug",
@@ -546,6 +565,10 @@
"LabelThemeDark": "黑暗",
"LabelThemeLight": "明亮",
"LabelTimeBase": "时间基准",
+ "LabelTimeDurationXHours": "{0} 小时",
+ "LabelTimeDurationXMinutes": "{0} 分钟",
+ "LabelTimeDurationXSeconds": "{0} 秒",
+ "LabelTimeInMinutes": "时间 (分钟)",
"LabelTimeListened": "收听时间",
"LabelTimeListenedToday": "今日收听的时间",
"LabelTimeRemaining": "剩余 {0}",
@@ -585,9 +608,12 @@
"LabelVersion": "版本",
"LabelViewBookmarks": "查看书签",
"LabelViewChapters": "查看章节",
+ "LabelViewPlayerSettings": "查看播放器设置",
"LabelViewQueue": "查看播放列表",
"LabelVolume": "音量",
"LabelWeekdaysToRun": "工作日运行",
+ "LabelXBooks": "{0} 本书",
+ "LabelXItems": "{0} 项目",
"LabelYearReviewHide": "隐藏年度回顾",
"LabelYearReviewShow": "查看年度回顾",
"LabelYourAudiobookDuration": "你的有声读物持续时间",
@@ -598,6 +624,7 @@
"MessageAppriseDescription": "要使用此功能,你需要运行一个
Apprise API 实例或一个可以处理这些相同请求的 API.
Apprise API Url 应该是发送通知的完整 URL 路径, 例如: 如果你的 API 实例运行在
http://192.168.1.1:8337
, 那么你可以输入
http://192.168.1.1:8337/notify
.",
"MessageBackupsDescription": "备份包括用户, 用户进度, 媒体库项目详细信息, 服务器设置和图像, 存储在
/metadata/items
&
/metadata/authors
. 备份不包括存储在你的媒体库文件夹中的任何文件.",
"MessageBackupsLocationEditNote": "注意: 更新备份位置不会移动或修改现有备份",
+ "MessageBackupsLocationNoEditNote": "注意: 备份位置是通过环境变量设置的, 不能在此处更改.",
"MessageBackupsLocationPathEmpty": "备份位置路径不能为空",
"MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.",
"MessageBookshelfNoCollections": "你尚未进行任何收藏",
@@ -644,6 +671,7 @@
"MessageConfirmSendEbookToDevice": "你确定要发送 {0} 电子书 \"{1}\" 到设备 \"{2}\"?",
"MessageDownloadingEpisode": "正在下载剧集",
"MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序",
+ "MessageEmbedFailed": "嵌入失败!",
"MessageEmbedFinished": "嵌入完成!",
"MessageEpisodesQueuedForDownload": "{0} 个剧集排队等待下载",
"MessageEreaderDevices": "为了确保电子书的送达, 你可能需要将上述电子邮件地址添加为下列每台设备的有效发件人.",
@@ -698,6 +726,7 @@
"MessageNoUpdatesWereNecessary": "无需更新",
"MessageNoUserPlaylists": "你没有播放列表",
"MessageNotYetImplemented": "尚未实施",
+ "MessageOpmlPreviewNote": "注意: 这是解析的OPML文件的预览. 实际的播客标题将从 RSS 提要中获取.",
"MessageOr": "或",
"MessagePauseChapter": "暂停章节播放",
"MessagePlayChapter": "开始章节播放",
@@ -716,6 +745,9 @@
"MessageSelected": "{0} 已选择",
"MessageServerCouldNotBeReached": "无法访问服务器",
"MessageSetChaptersFromTracksDescription": "把每个音频文件设置为章节并将章节标题设置为音频文件名",
+ "MessageShareExpirationWillBe": "到期日期为
{0} ",
+ "MessageShareExpiresIn": "到期时间 {0}",
+ "MessageShareURLWillBe": "分享网址是
{0} ",
"MessageStartPlaybackAtTime": "开始播放 \"{0}\" 在 {1}?",
"MessageThinking": "正在查找...",
"MessageUploaderItemFailed": "上传失败",
diff --git a/docs/controllers/EmailController.yaml b/docs/controllers/EmailController.yaml
index 8fb0304317..c5a957142c 100644
--- a/docs/controllers/EmailController.yaml
+++ b/docs/controllers/EmailController.yaml
@@ -25,7 +25,8 @@ components:
paths:
/api/emails/settings:
get:
- description: Get email settings
+ summary: Get email settings
+ description: Get email settings for sending e-books to e-readers.
operationId: getEmailSettings
tags:
- Email
@@ -42,7 +43,7 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/EmailSettings'
+ $ref: '../objects/settings/EmailSettings.yaml#/components/schemas/EmailSettings'
responses:
200:
$ref: '#/components/responses/email200'
diff --git a/docs/controllers/NotificationController.yaml b/docs/controllers/NotificationController.yaml
index 4d3fa09be4..d61494811e 100644
--- a/docs/controllers/NotificationController.yaml
+++ b/docs/controllers/NotificationController.yaml
@@ -18,6 +18,7 @@ paths:
/api/notifications:
get:
operationId: getNotifications
+ summary: Get notification settings
description: Get all Apprise notification events and notification settings for server.
tags:
- Notification
@@ -41,8 +42,9 @@ paths:
'404':
$ref: '#/components/responses/notification404'
patch:
- operationId: updateNotificationSettings
- description: Update Notification settings.
+ operationId: configureNotificationSettings
+ summary: Update select notification settings
+ description: Update the URL, max failed attempts, and maximum notifications that can be queued for Apprise.
tags:
- Notification
requestBody:
@@ -64,7 +66,8 @@ paths:
$ref: '#/components/responses/notification404'
post:
operationId: createNotification
- description: Update Notification settings.
+ summary: Create notification settings
+ description: Create or update Notification settings.
tags:
- Notification
requestBody:
@@ -107,6 +110,7 @@ paths:
/api/notificationdata:
get:
operationId: getNotificationEventData
+ summary: Get notification event data
description: Get all Apprise notification event data for the server.
tags:
- Notification
@@ -127,6 +131,7 @@ paths:
/api/notifications/test:
get:
operationId: sendDefaultTestNotification
+ summary: Send general test notification
description: Send a test notification.
tags:
- Notification
@@ -151,6 +156,7 @@ paths:
$ref: '../objects/Notification.yaml#/components/schemas/notificationId'
delete:
operationId: deleteNotification
+ summary: Delete a notification
description: Delete the notification by ID and return the notification settings.
tags:
- Notification
@@ -168,7 +174,8 @@ paths:
$ref: '#/components/responses/notification404'
patch:
operationId: updateNotification
- description: Update individual Notification
+ summary: Update a notification
+ description: Update an individual Notification by ID
tags:
- Notification
requestBody:
@@ -213,7 +220,8 @@ paths:
$ref: '../objects/Notification.yaml#/components/schemas/notificationId'
get:
operationId: sendTestNotification
- description: Send a test to the given notification.
+ summary: Send a test notification
+ description: Send a test to the given notification by ID.
tags:
- Notification
responses:
diff --git a/docs/controllers/PodcastController.yaml b/docs/controllers/PodcastController.yaml
new file mode 100644
index 0000000000..a410eed6fd
--- /dev/null
+++ b/docs/controllers/PodcastController.yaml
@@ -0,0 +1,393 @@
+paths:
+ /api/podcasts:
+ post:
+ summary: Create a new podcast
+ operationId: createPodcast
+ tags:
+ - Podcasts
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/Podcast'
+ responses:
+ 200:
+ description: Successfully created a podcast
+ content:
+ application/json:
+ schema:
+ $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/Podcast'
+ 400:
+ description: Bad request
+ 403:
+ description: Forbidden
+ 404:
+ description: Not found
+
+ /api/podcasts/feed:
+ post:
+ summary: Get podcast feed
+ operationId: getPodcastFeed
+ tags:
+ - Podcasts
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ rssFeed:
+ type: string
+ description: The RSS feed URL of the podcast
+ responses:
+ 200:
+ description: Successfully retrieved podcast feed
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ podcast:
+ $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/Podcast'
+ 400:
+ description: Bad request
+ 403:
+ description: Forbidden
+ 404:
+ description: Not found
+
+ /api/podcasts/opml/parse:
+ post:
+ summary: Get feeds from OPML text
+ description: Parse OPML text and return an array of feeds
+ operationId: getFeedsFromOPMLText
+ tags:
+ - Podcasts
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ opmlText:
+ type: string
+ responses:
+ '200':
+ description: Successfully parsed OPML text and returned feeds
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ feeds:
+ type: array
+ items:
+ type: object
+ properties:
+ title:
+ type: string
+ feedUrl:
+ type: string
+ '400':
+ description: Bad request, OPML text not provided
+ '403':
+ description: Forbidden, user is not admin
+ /api/podcasts/opml/create:
+ post:
+ summary: Bulk create podcasts from OPML feed URLs
+ operationId: bulkCreatePodcastsFromOpmlFeedUrls
+ tags:
+ - Podcasts
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ feeds:
+ type: array
+ items:
+ type: string
+ libraryId:
+ $ref: '../objects/Library.yaml#/components/schemas/libraryId'
+ folderId:
+ $ref: '../objects/Folder.yaml#/components/schemas/folderId'
+ autoDownloadEpisodes:
+ $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/autoDownloadEpisodes'
+ responses:
+ '200':
+ description: Successfully created podcasts from feed URLs
+ '400':
+ description: Bad request, invalid request body
+ '403':
+ description: Forbidden, user is not admin
+ '404':
+ description: Folder not found
+
+ /api/podcasts/{id}/checknew:
+ parameters:
+ - name: id
+ in: path
+ description: Podcast ID
+ required: true
+ schema:
+ $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/podcastId'
+
+ get:
+ summary: Check and download new episodes
+ operationId: checkNewEpisodes
+ tags:
+ - Podcasts
+ parameters:
+ - name: limit
+ in: query
+ description: Maximum number of episodes to download
+ required: false
+ schema:
+ type: integer
+ responses:
+ 200:
+ description: Successfully checked and downloaded new episodes
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ episodes:
+ type: array
+ items:
+ $ref: '../objects/entities/PodcastEpisode.yaml#/components/schemas/PodcastEpisode'
+ 403:
+ description: Forbidden
+ 404:
+ description: Not found
+ 500:
+ description: Server error
+
+ /api/podcasts/{id}/clear-queue:
+ parameters:
+ - name: id
+ in: path
+ description: Podcast ID
+ required: true
+ schema:
+ $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/podcastId'
+
+ get:
+ summary: Clear episode download queue
+ operationId: clearEpisodeDownloadQueue
+ tags:
+ - Podcasts
+ responses:
+ 200:
+ description: Successfully cleared download queue
+ 403:
+ description: Forbidden
+
+ /api/podcasts/{id}/downloads:
+ parameters:
+ - name: id
+ in: path
+ description: Podcast ID
+ required: true
+ schema:
+ $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/podcastId'
+
+ get:
+ summary: Get episode downloads
+ operationId: getEpisodeDownloads
+ tags:
+ - Podcasts
+ responses:
+ 200:
+ description: Successfully retrieved episode downloads
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ downloads:
+ type: array
+ items:
+ $ref: '../objects/entities/PodcastEpisode.yaml#/components/schemas/PodcastEpisode'
+ 404:
+ description: Not found
+
+ /api/podcasts/{id}/search-episode:
+ parameters:
+ - name: id
+ in: path
+ description: Podcast ID
+ required: true
+ schema:
+ $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/podcastId'
+
+ get:
+ summary: Find episode by title
+ operationId: findEpisode
+ tags:
+ - Podcasts
+ parameters:
+ - name: title
+ in: query
+ description: Title of the episode to search for
+ required: true
+ schema:
+ type: string
+ responses:
+ 200:
+ description: Successfully found episodes
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ episodes:
+ type: array
+ items:
+ $ref: '../objects/entities/PodcastEpisode.yaml#/components/schemas/PodcastEpisode'
+ 404:
+ description: Not found
+ 500:
+ description: Server error
+
+ /api/podcasts/{id}/download-episodes:
+ parameters:
+ - name: id
+ in: path
+ description: Podcast ID
+ required: true
+ schema:
+ $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/podcastId'
+
+ post:
+ summary: Download podcast episodes
+ operationId: downloadEpisodes
+ tags:
+ - Podcasts
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: string
+ responses:
+ 200:
+ description: Successfully started episode download
+ 400:
+ description: Bad request
+ 403:
+ description: Forbidden
+
+ /api/podcasts/{id}/match-episodes:
+ parameters:
+ - name: id
+ in: path
+ description: Podcast ID
+ required: true
+ schema:
+ $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/podcastId'
+
+ post:
+ summary: Quick match podcast episodes
+ operationId: quickMatchEpisodes
+ tags:
+ - Podcasts
+ parameters:
+ - name: override
+ in: query
+ description: Override existing details if set to 1
+ required: false
+ schema:
+ type: string
+ responses:
+ 200:
+ description: Successfully matched episodes
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ numEpisodesUpdated:
+ type: integer
+ 403:
+ description: Forbidden
+
+ /api/podcasts/{id}/episode/{episodeId}:
+ parameters:
+ - name: id
+ in: path
+ description: Podcast ID
+ required: true
+ schema:
+ $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/podcastId'
+ - name: episodeId
+ in: path
+ description: Episode ID
+ required: true
+ schema:
+ $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/podcastId'
+
+ patch:
+ summary: Update a podcast episode
+ operationId: updateEpisode
+ tags:
+ - Podcasts
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ responses:
+ 200:
+ description: Successfully updated episode
+ content:
+ application/json:
+ schema:
+ $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/Podcast'
+ 404:
+ description: Not found
+
+ get:
+ summary: Get a specific podcast episode
+ operationId: getEpisode
+ tags:
+ - Podcasts
+ responses:
+ 200:
+ description: Successfully retrieved episode
+ content:
+ application/json:
+ schema:
+ $ref: '../objects/entities/PodcastEpisode.yaml#/components/schemas/PodcastEpisode'
+ 404:
+ description: Not found
+
+ delete:
+ summary: Remove a podcast episode
+ operationId: removeEpisode
+ tags:
+ - Podcasts
+ parameters:
+ - name: hard
+ in: query
+ description: Hard delete the episode if set to 1
+ required: false
+ schema:
+ type: string
+ responses:
+ 200:
+ description: Successfully removed episode
+ content:
+ application/json:
+ schema:
+ $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/Podcast'
+ 404:
+ description: Not found
+ 500:
+ description: Server error
diff --git a/docs/objects/entities/PodcastEpisode.yaml b/docs/objects/entities/PodcastEpisode.yaml
new file mode 100644
index 0000000000..f10c25ec42
--- /dev/null
+++ b/docs/objects/entities/PodcastEpisode.yaml
@@ -0,0 +1,74 @@
+components:
+ schemas:
+ PodcastEpisode:
+ type: object
+ description: A single episode of a podcast.
+ properties:
+ libraryItemId:
+ $ref: '../LibraryItem.yaml#/components/schemas/libraryItemId'
+ podcastId:
+ $ref: '../mediaTypes/Podcast.yaml#/components/schemas/podcastId'
+ id:
+ $ref: '../mediaTypes/Podcast.yaml#/components/schemas/podcastId'
+ oldEpisodeId:
+ $ref: '../mediaTypes/Podcast.yaml#/components/schemas/oldPodcastId'
+ index:
+ type: integer
+ description: The index of the episode within the podcast.
+ nullable: true
+ season:
+ type: string
+ description: The season number of the episode.
+ nullable: true
+ episode:
+ type: string
+ description: The episode number within the season.
+ nullable: true
+ episodeType:
+ type: string
+ description: The type of episode (e.g., full, trailer).
+ nullable: true
+ title:
+ type: string
+ description: The title of the episode.
+ nullable: true
+ subtitle:
+ type: string
+ description: The subtitle of the episode.
+ nullable: true
+ description:
+ type: string
+ description: The description of the episode.
+ nullable: true
+ enclosure:
+ type: object
+ description: The enclosure object containing additional episode data.
+ nullable: true
+ additionalProperties: true
+ guid:
+ type: string
+ description: The globally unique identifier for the episode.
+ nullable: true
+ pubDate:
+ type: string
+ description: The publication date of the episode.
+ nullable: true
+ chapters:
+ type: array
+ description: The chapters within the episode.
+ items:
+ type: object
+ audioFile:
+ $ref: '../files/AudioFile.yaml#/components/schemas/audioFile'
+ publishedAt:
+ $ref: '../../schemas.yaml#/components/schemas/createdAt'
+ addedAt:
+ $ref: '../../schemas.yaml#/components/schemas/addedAt'
+ updatedAt:
+ $ref: '../../schemas.yaml#/components/schemas/updatedAt'
+ audioTrack:
+ $ref: '../files/AudioTrack.yaml#/components/schemas/AudioTrack'
+ duration:
+ $ref: '../../schemas.yaml#/components/schemas/durationSec'
+ size:
+ $ref: '../../schemas.yaml#/components/schemas/size'
diff --git a/docs/objects/files/AudioTrack.yaml b/docs/objects/files/AudioTrack.yaml
new file mode 100644
index 0000000000..d31c5cef8a
--- /dev/null
+++ b/docs/objects/files/AudioTrack.yaml
@@ -0,0 +1,45 @@
+components:
+ schemas:
+ AudioTrack:
+ type: object
+ description: Represents an audio track with various properties.
+ properties:
+ index:
+ type: integer
+ nullable: true
+ description: The index of the audio track.
+ example: null
+ startOffset:
+ type: number
+ format: float
+ nullable: true
+ description: The start offset of the audio track in seconds.
+ example: null
+ duration:
+ type: number
+ format: float
+ nullable: true
+ description: The duration of the audio track in seconds.
+ example: null
+ title:
+ type: string
+ nullable: true
+ description: The title of the audio track.
+ example: null
+ contentUrl:
+ type: string
+ nullable: true
+ description: The URL where the audio track content is located.
+ example: '`/api/items/${itemId}/file/${audioFile.ino}`'
+ mimeType:
+ type: string
+ nullable: true
+ description: The MIME type of the audio track.
+ example: null
+ codec:
+ type: string
+ nullable: true
+ description: The codec used for the audio track.
+ example: aac
+ metadata:
+ $ref: '../metadata/FileMetadata.yaml#/components/schemas/fileMetadata'
diff --git a/docs/objects/mediaTypes/Podcast.yaml b/docs/objects/mediaTypes/Podcast.yaml
new file mode 100644
index 0000000000..df915fe07a
--- /dev/null
+++ b/docs/objects/mediaTypes/Podcast.yaml
@@ -0,0 +1,76 @@
+components:
+ schemas:
+ podcastId:
+ type: string
+ description: The ID of podcasts and podcast episodes after 2.3.0.
+ format: uuid
+ example: e4bb1afb-4a4f-4dd6-8be0-e615d233185b
+ oldPodcastId:
+ description: The ID of podcasts on server version 2.2.23 and before.
+ type: string
+ nullable: true
+ format: 'pod_[a-z0-9]{18}'
+ example: pod_o78uaoeuh78h6aoeif
+ autoDownloadEpisodes:
+ type: boolean
+ description: Whether episodes are automatically downloaded.
+
+ Podcast:
+ type: object
+ description: A podcast containing multiple episodes.
+ properties:
+ id:
+ $ref: '#/components/schemas/podcastId'
+ libraryItemId:
+ $ref: '../LibraryItem.yaml#/components/schemas/libraryItemId'
+ metadata:
+ $ref: '../metadata/PodcastMetadata.yaml#/components/schemas/PodcastMetadata'
+ coverPath:
+ type: string
+ description: The file path to the podcast's cover image.
+ nullable: true
+ tags:
+ type: array
+ description: The tags associated with the podcast.
+ items:
+ type: string
+ episodes:
+ type: array
+ description: The episodes of the podcast.
+ items:
+ $ref: '../entities/PodcastEpisode.yaml#/components/schemas/PodcastEpisode'
+ autoDownloadEpisodes:
+ $ref: '#/components/schemas/autoDownloadEpisodes'
+ autoDownloadSchedule:
+ type: string
+ description: The schedule for automatic episode downloads, in cron format.
+ nullable: true
+ lastEpisodeCheck:
+ type: integer
+ description: The timestamp of the last episode check.
+ maxEpisodesToKeep:
+ type: integer
+ description: The maximum number of episodes to keep.
+ maxNewEpisodesToDownload:
+ type: integer
+ description: The maximum number of new episodes to download when automatically downloading epsiodes.
+ lastCoverSearch:
+ type: integer
+ description: The timestamp of the last cover search.
+ nullable: true
+ lastCoverSearchQuery:
+ type: string
+ description: The query used for the last cover search.
+ nullable: true
+ size:
+ type: integer
+ description: The total size of all episodes in bytes.
+ duration:
+ type: integer
+ description: The total duration of all episodes in seconds.
+ numTracks:
+ type: integer
+ description: The number of tracks (episodes) in the podcast.
+ latestEpisodePublished:
+ type: integer
+ description: The timestamp of the most recently published episode.
diff --git a/docs/objects/metadata/PodcastMetadata.yaml b/docs/objects/metadata/PodcastMetadata.yaml
new file mode 100644
index 0000000000..a565d697a3
--- /dev/null
+++ b/docs/objects/metadata/PodcastMetadata.yaml
@@ -0,0 +1,59 @@
+components:
+ schemas:
+ PodcastMetadata:
+ type: object
+ description: Metadata for a podcast.
+ properties:
+ title:
+ type: string
+ description: The title of the podcast.
+ nullable: true
+ author:
+ type: string
+ description: The author of the podcast.
+ nullable: true
+ description:
+ type: string
+ description: The description of the podcast.
+ nullable: true
+ releaseDate:
+ type: string
+ format: date-time
+ description: The release date of the podcast.
+ nullable: true
+ genres:
+ type: array
+ description: The genres of the podcast.
+ items:
+ type: string
+ feedUrl:
+ type: string
+ description: The URL of the podcast feed.
+ nullable: true
+ imageUrl:
+ type: string
+ description: The URL of the podcast's image.
+ nullable: true
+ itunesPageUrl:
+ type: string
+ description: The URL of the podcast's iTunes page.
+ nullable: true
+ itunesId:
+ type: string
+ description: The iTunes ID of the podcast.
+ nullable: true
+ itunesArtistId:
+ type: string
+ description: The iTunes artist ID of the podcast.
+ nullable: true
+ explicit:
+ type: boolean
+ description: Whether the podcast contains explicit content.
+ language:
+ type: string
+ description: The language of the podcast.
+ nullable: true
+ type:
+ type: string
+ description: The type of podcast (e.g., episodic, serial).
+ nullable: true
diff --git a/docs/openapi.json b/docs/openapi.json
index 2f79a4223b..9767f57960 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -36,6 +36,10 @@
{
"name": "Notification",
"description": "Notifications endpoints"
+ },
+ {
+ "name": "Podcasts",
+ "description": "Podcast endpoints"
}
],
"paths": {
@@ -422,7 +426,8 @@
},
"/api/emails/settings": {
"get": {
- "description": "Get email settings",
+ "summary": "Get email settings",
+ "description": "Get email settings for sending e-books to e-readers.",
"operationId": "getEmailSettings",
"tags": [
"Email"
@@ -1162,6 +1167,7 @@
"/api/notifications": {
"get": {
"operationId": "getNotifications",
+ "summary": "Get notification settings",
"description": "Get all Apprise notification events and notification settings for server.",
"tags": [
"Notification"
@@ -1199,8 +1205,9 @@
}
},
"patch": {
- "operationId": "updateNotificationSettings",
- "description": "Update Notification settings.",
+ "operationId": "configureNotificationSettings",
+ "summary": "Update select notification settings",
+ "description": "Update the URL, max failed attempts, and maximum notifications that can be queued for Apprise.",
"tags": [
"Notification"
],
@@ -1235,7 +1242,8 @@
},
"post": {
"operationId": "createNotification",
- "description": "Update Notification settings.",
+ "summary": "Create notification settings",
+ "description": "Create or update Notification settings.",
"tags": [
"Notification"
],
@@ -1302,6 +1310,7 @@
"/api/notificationdata": {
"get": {
"operationId": "getNotificationEventData",
+ "summary": "Get notification event data",
"description": "Get all Apprise notification event data for the server.",
"tags": [
"Notification"
@@ -1334,6 +1343,7 @@
"/api/notifications/test": {
"get": {
"operationId": "sendDefaultTestNotification",
+ "summary": "Send general test notification",
"description": "Send a test notification.",
"tags": [
"Notification"
@@ -1372,6 +1382,7 @@
],
"delete": {
"operationId": "deleteNotification",
+ "summary": "Delete a notification",
"description": "Delete the notification by ID and return the notification settings.",
"tags": [
"Notification"
@@ -1399,7 +1410,8 @@
},
"patch": {
"operationId": "updateNotification",
- "description": "Update individual Notification",
+ "summary": "Update a notification",
+ "description": "Update an individual Notification by ID",
"tags": [
"Notification"
],
@@ -1471,7 +1483,8 @@
],
"get": {
"operationId": "sendTestNotification",
- "description": "Send a test to the given notification.",
+ "summary": "Send a test notification",
+ "description": "Send a test to the given notification by ID.",
"tags": [
"Notification"
],
@@ -1485,43 +1498,63 @@
}
}
},
- "/api/series/{id}": {
- "parameters": [
- {
- "name": "id",
- "in": "path",
- "description": "The ID of the series.",
+ "/api/podcasts": {
+ "post": {
+ "summary": "Create a new podcast",
+ "operationId": "createPodcast",
+ "tags": [
+ "Podcasts"
+ ],
+ "requestBody": {
"required": true,
- "schema": {
- "$ref": "#/components/schemas/seriesId"
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Podcast"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successfully created a podcast",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Podcast"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request"
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "404": {
+ "description": "Not found"
}
}
- ],
- "get": {
- "operationId": "getSeries",
+ }
+ },
+ "/api/podcasts/feed": {
+ "post": {
+ "summary": "Get podcast feed",
+ "operationId": "getPodcastFeed",
"tags": [
- "Series"
+ "Podcasts"
],
- "summary": "Get series",
- "description": "Get a series by ID.",
"requestBody": {
- "required": false,
- "description": "A comma separated list of what to include with the series.",
+ "required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
- "include": {
+ "rssFeed": {
"type": "string",
- "description": "A comma separated list of what to include with the series.",
- "example": "progress,rssfeed",
- "enum": [
- "progress",
- "rssfeed",
- "progress,rssfeed",
- "rssfeed,progress"
- ]
+ "description": "The RSS feed URL of the podcast"
}
}
}
@@ -1530,39 +1563,48 @@
},
"responses": {
"200": {
- "description": "OK",
+ "description": "Successfully retrieved podcast feed",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/seriesWithProgressAndRSS"
+ "type": "object",
+ "properties": {
+ "podcast": {
+ "$ref": "#/components/schemas/Podcast"
+ }
+ }
}
}
}
},
+ "400": {
+ "description": "Bad request"
+ },
+ "403": {
+ "description": "Forbidden"
+ },
"404": {
- "$ref": "#/components/responses/series404"
+ "description": "Not found"
}
}
- },
- "patch": {
- "operationId": "updateSeries",
+ }
+ },
+ "/api/podcasts/opml/parse": {
+ "post": {
+ "summary": "Get feeds from OPML text",
+ "description": "Parse OPML text and return an array of feeds",
+ "operationId": "getFeedsFromOPMLText",
"tags": [
- "Series"
+ "Podcasts"
],
- "summary": "Update series",
- "description": "Update a series by ID.",
"requestBody": {
- "required": true,
- "description": "The series to update.",
"content": {
"application/json": {
"schema": {
+ "type": "object",
"properties": {
- "name": {
- "$ref": "#/components/schemas/seriesName"
- },
- "description": {
- "$ref": "#/components/schemas/seriesDescription"
+ "opmlText": {
+ "type": "string"
}
}
}
@@ -1571,149 +1613,713 @@
},
"responses": {
"200": {
- "description": "OK",
+ "description": "Successfully parsed OPML text and returned feeds",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/series"
+ "type": "object",
+ "properties": {
+ "feeds": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "feedUrl": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
}
}
}
},
- "404": {
- "$ref": "#/components/responses/series404"
+ "400": {
+ "description": "Bad request, OPML text not provided"
+ },
+ "403": {
+ "description": "Forbidden, user is not admin"
}
}
}
- }
- },
- "components": {
- "securitySchemes": {
- "BearerAuth": {
- "description": "Bearer authentication",
- "type": "http",
- "scheme": "bearer"
- }
},
- "schemas": {
- "authorId": {
- "type": "string",
- "description": "The ID of the author.",
- "format": "uuid",
- "example": "e4bb1afb-4a4f-4dd6-8be0-e615d233185b"
- },
- "authorAsin": {
- "description": "The Audible identifier (ASIN) of the author. Will be null if unknown. Not the Amazon identifier.",
- "type": "string",
- "nullable": true,
- "example": "B000APZOQA"
- },
- "authorName": {
- "description": "The name of the author.",
- "type": "string",
- "example": "Terry Goodkind"
- },
- "authorDescription": {
- "description": "The new description of the author.",
- "type": "string",
- "nullable": true,
- "example": "Terry Goodkind is a"
- },
- "authorImagePath": {
- "description": "The absolute path for the author image. This will be in the `metadata/` directory. Will be null if there is no image.",
- "type": "string",
- "nullable": true,
- "example": "/metadata/authors/aut_z3leimgybl7uf3y4ab.jpg"
- },
- "addedAt": {
- "type": "integer",
- "description": "The time (in ms since POSIX epoch) when added to the server.",
- "example": 1633522963509
- },
- "updatedAt": {
- "type": "integer",
- "description": "The time (in ms since POSIX epoch) when last updated.",
- "example": 1633522963509
- },
- "libraryItemId": {
- "type": "string",
- "description": "The ID of library items after 2.3.0.",
- "format": "uuid",
- "example": "e4bb1afb-4a4f-4dd6-8be0-e615d233185b"
- },
- "oldLibraryItemId": {
- "description": "The ID of library items on server version 2.2.23 and before.",
- "type": "string",
- "nullable": true,
- "format": "li_[a-z0-9]{18}",
- "example": "li_o78uaoeuh78h6aoeif"
- },
- "inode": {
- "description": "The inode of the item in the file system.",
- "type": "string",
- "format": "[0-9]*",
- "example": "649644248522215260"
- },
- "libraryId": {
- "type": "string",
- "description": "The ID of the library.",
- "format": "uuid",
- "example": "e4bb1afb-4a4f-4dd6-8be0-e615d233185b"
- },
- "folderId": {
- "type": "string",
- "description": "The ID of the folder.",
- "format": "uuid",
- "example": "e4bb1afb-4a4f-4dd6-8be0-e615d233185b"
- },
- "mediaType": {
- "type": "string",
- "description": "The type of media, will be book or podcast.",
- "enum": [
- "book",
- "podcast"
- ]
- },
- "libraryItemBase": {
- "type": "object",
- "description": "Base library item schema",
- "properties": {
- "id": {
- "$ref": "#/components/schemas/libraryItemId"
- },
- "oldLibraryItemId": {
- "$ref": "#/components/schemas/oldLibraryItemId"
- },
- "ino": {
- "$ref": "#/components/schemas/inode"
- },
- "libraryId": {
- "$ref": "#/components/schemas/libraryId"
- },
- "folderId": {
- "$ref": "#/components/schemas/folderId"
- },
- "path": {
- "description": "The path of the library item on the server.",
- "type": "string"
- },
- "relPath": {
- "description": "The path, relative to the library folder, of the library item.",
- "type": "string"
- },
- "isFile": {
- "description": "Whether the library item is a single file in the root of the library folder.",
- "type": "boolean"
+ "/api/podcasts/opml/create": {
+ "post": {
+ "summary": "Bulk create podcasts from OPML feed URLs",
+ "operationId": "bulkCreatePodcastsFromOpmlFeedUrls",
+ "tags": [
+ "Podcasts"
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "feeds": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "libraryId": {
+ "$ref": "#/components/schemas/libraryId"
+ },
+ "folderId": {
+ "$ref": "#/components/schemas/folderId"
+ },
+ "autoDownloadEpisodes": {
+ "$ref": "#/components/schemas/autoDownloadEpisodes"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successfully created podcasts from feed URLs"
},
- "mtimeMs": {
- "description": "The time (in ms since POSIX epoch) when the library item was last modified on disk.",
- "type": "integer"
+ "400": {
+ "description": "Bad request, invalid request body"
},
- "ctimeMs": {
- "description": "The time (in ms since POSIX epoch) when the library item status was changed on disk.",
- "type": "integer"
+ "403": {
+ "description": "Forbidden, user is not admin"
},
- "birthtimeMs": {
+ "404": {
+ "description": "Folder not found"
+ }
+ }
+ }
+ },
+ "/api/podcasts/{id}/checknew": {
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "description": "Podcast ID",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/podcastId"
+ }
+ }
+ ],
+ "get": {
+ "summary": "Check and download new episodes",
+ "operationId": "checkNewEpisodes",
+ "tags": [
+ "Podcasts"
+ ],
+ "parameters": [
+ {
+ "name": "limit",
+ "in": "query",
+ "description": "Maximum number of episodes to download",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successfully checked and downloaded new episodes",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "episodes": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/PodcastEpisode"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "404": {
+ "description": "Not found"
+ },
+ "500": {
+ "description": "Server error"
+ }
+ }
+ }
+ },
+ "/api/podcasts/{id}/clear-queue": {
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "description": "Podcast ID",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/podcastId"
+ }
+ }
+ ],
+ "get": {
+ "summary": "Clear episode download queue",
+ "operationId": "clearEpisodeDownloadQueue",
+ "tags": [
+ "Podcasts"
+ ],
+ "responses": {
+ "200": {
+ "description": "Successfully cleared download queue"
+ },
+ "403": {
+ "description": "Forbidden"
+ }
+ }
+ }
+ },
+ "/api/podcasts/{id}/downloads": {
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "description": "Podcast ID",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/podcastId"
+ }
+ }
+ ],
+ "get": {
+ "summary": "Get episode downloads",
+ "operationId": "getEpisodeDownloads",
+ "tags": [
+ "Podcasts"
+ ],
+ "responses": {
+ "200": {
+ "description": "Successfully retrieved episode downloads",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "downloads": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/PodcastEpisode"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found"
+ }
+ }
+ }
+ },
+ "/api/podcasts/{id}/search-episode": {
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "description": "Podcast ID",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/podcastId"
+ }
+ }
+ ],
+ "get": {
+ "summary": "Find episode by title",
+ "operationId": "findEpisode",
+ "tags": [
+ "Podcasts"
+ ],
+ "parameters": [
+ {
+ "name": "title",
+ "in": "query",
+ "description": "Title of the episode to search for",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successfully found episodes",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "episodes": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/PodcastEpisode"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found"
+ },
+ "500": {
+ "description": "Server error"
+ }
+ }
+ }
+ },
+ "/api/podcasts/{id}/download-episodes": {
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "description": "Podcast ID",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/podcastId"
+ }
+ }
+ ],
+ "post": {
+ "summary": "Download podcast episodes",
+ "operationId": "downloadEpisodes",
+ "tags": [
+ "Podcasts"
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successfully started episode download"
+ },
+ "400": {
+ "description": "Bad request"
+ },
+ "403": {
+ "description": "Forbidden"
+ }
+ }
+ }
+ },
+ "/api/podcasts/{id}/match-episodes": {
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "description": "Podcast ID",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/podcastId"
+ }
+ }
+ ],
+ "post": {
+ "summary": "Quick match podcast episodes",
+ "operationId": "quickMatchEpisodes",
+ "tags": [
+ "Podcasts"
+ ],
+ "parameters": [
+ {
+ "name": "override",
+ "in": "query",
+ "description": "Override existing details if set to 1",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successfully matched episodes",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "numEpisodesUpdated": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden"
+ }
+ }
+ }
+ },
+ "/api/podcasts/{id}/episode/{episodeId}": {
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "description": "Podcast ID",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/podcastId"
+ }
+ },
+ {
+ "name": "episodeId",
+ "in": "path",
+ "description": "Episode ID",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/podcastId"
+ }
+ }
+ ],
+ "patch": {
+ "summary": "Update a podcast episode",
+ "operationId": "updateEpisode",
+ "tags": [
+ "Podcasts"
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successfully updated episode",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Podcast"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found"
+ }
+ }
+ },
+ "get": {
+ "summary": "Get a specific podcast episode",
+ "operationId": "getEpisode",
+ "tags": [
+ "Podcasts"
+ ],
+ "responses": {
+ "200": {
+ "description": "Successfully retrieved episode",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PodcastEpisode"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found"
+ }
+ }
+ },
+ "delete": {
+ "summary": "Remove a podcast episode",
+ "operationId": "removeEpisode",
+ "tags": [
+ "Podcasts"
+ ],
+ "parameters": [
+ {
+ "name": "hard",
+ "in": "query",
+ "description": "Hard delete the episode if set to 1",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successfully removed episode",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Podcast"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found"
+ },
+ "500": {
+ "description": "Server error"
+ }
+ }
+ }
+ },
+ "/api/series/{id}": {
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "description": "The ID of the series.",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/seriesId"
+ }
+ }
+ ],
+ "get": {
+ "operationId": "getSeries",
+ "tags": [
+ "Series"
+ ],
+ "summary": "Get series",
+ "description": "Get a series by ID.",
+ "requestBody": {
+ "required": false,
+ "description": "A comma separated list of what to include with the series.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "include": {
+ "type": "string",
+ "description": "A comma separated list of what to include with the series.",
+ "example": "progress,rssfeed",
+ "enum": [
+ "progress",
+ "rssfeed",
+ "progress,rssfeed",
+ "rssfeed,progress"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/seriesWithProgressAndRSS"
+ }
+ }
+ }
+ },
+ "404": {
+ "$ref": "#/components/responses/series404"
+ }
+ }
+ },
+ "patch": {
+ "operationId": "updateSeries",
+ "tags": [
+ "Series"
+ ],
+ "summary": "Update series",
+ "description": "Update a series by ID.",
+ "requestBody": {
+ "required": true,
+ "description": "The series to update.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "name": {
+ "$ref": "#/components/schemas/seriesName"
+ },
+ "description": {
+ "$ref": "#/components/schemas/seriesDescription"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/series"
+ }
+ }
+ }
+ },
+ "404": {
+ "$ref": "#/components/responses/series404"
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "securitySchemes": {
+ "BearerAuth": {
+ "description": "Bearer authentication",
+ "type": "http",
+ "scheme": "bearer"
+ }
+ },
+ "schemas": {
+ "authorId": {
+ "type": "string",
+ "description": "The ID of the author.",
+ "format": "uuid",
+ "example": "e4bb1afb-4a4f-4dd6-8be0-e615d233185b"
+ },
+ "authorAsin": {
+ "description": "The Audible identifier (ASIN) of the author. Will be null if unknown. Not the Amazon identifier.",
+ "type": "string",
+ "nullable": true,
+ "example": "B000APZOQA"
+ },
+ "authorName": {
+ "description": "The name of the author.",
+ "type": "string",
+ "example": "Terry Goodkind"
+ },
+ "authorDescription": {
+ "description": "The new description of the author.",
+ "type": "string",
+ "nullable": true,
+ "example": "Terry Goodkind is a"
+ },
+ "authorImagePath": {
+ "description": "The absolute path for the author image. This will be in the `metadata/` directory. Will be null if there is no image.",
+ "type": "string",
+ "nullable": true,
+ "example": "/metadata/authors/aut_z3leimgybl7uf3y4ab.jpg"
+ },
+ "addedAt": {
+ "type": "integer",
+ "description": "The time (in ms since POSIX epoch) when added to the server.",
+ "example": 1633522963509
+ },
+ "updatedAt": {
+ "type": "integer",
+ "description": "The time (in ms since POSIX epoch) when last updated.",
+ "example": 1633522963509
+ },
+ "libraryItemId": {
+ "type": "string",
+ "description": "The ID of library items after 2.3.0.",
+ "format": "uuid",
+ "example": "e4bb1afb-4a4f-4dd6-8be0-e615d233185b"
+ },
+ "oldLibraryItemId": {
+ "description": "The ID of library items on server version 2.2.23 and before.",
+ "type": "string",
+ "nullable": true,
+ "format": "li_[a-z0-9]{18}",
+ "example": "li_o78uaoeuh78h6aoeif"
+ },
+ "inode": {
+ "description": "The inode of the item in the file system.",
+ "type": "string",
+ "format": "[0-9]*",
+ "example": "649644248522215260"
+ },
+ "libraryId": {
+ "type": "string",
+ "description": "The ID of the library.",
+ "format": "uuid",
+ "example": "e4bb1afb-4a4f-4dd6-8be0-e615d233185b"
+ },
+ "folderId": {
+ "type": "string",
+ "description": "The ID of the folder.",
+ "format": "uuid",
+ "example": "e4bb1afb-4a4f-4dd6-8be0-e615d233185b"
+ },
+ "mediaType": {
+ "type": "string",
+ "description": "The type of media, will be book or podcast.",
+ "enum": [
+ "book",
+ "podcast"
+ ]
+ },
+ "libraryItemBase": {
+ "type": "object",
+ "description": "Base library item schema",
+ "properties": {
+ "id": {
+ "$ref": "#/components/schemas/libraryItemId"
+ },
+ "oldLibraryItemId": {
+ "$ref": "#/components/schemas/oldLibraryItemId"
+ },
+ "ino": {
+ "$ref": "#/components/schemas/inode"
+ },
+ "libraryId": {
+ "$ref": "#/components/schemas/libraryId"
+ },
+ "folderId": {
+ "$ref": "#/components/schemas/folderId"
+ },
+ "path": {
+ "description": "The path of the library item on the server.",
+ "type": "string"
+ },
+ "relPath": {
+ "description": "The path, relative to the library folder, of the library item.",
+ "type": "string"
+ },
+ "isFile": {
+ "description": "Whether the library item is a single file in the root of the library folder.",
+ "type": "boolean"
+ },
+ "mtimeMs": {
+ "description": "The time (in ms since POSIX epoch) when the library item was last modified on disk.",
+ "type": "integer"
+ },
+ "ctimeMs": {
+ "description": "The time (in ms since POSIX epoch) when the library item status was changed on disk.",
+ "type": "integer"
+ },
+ "birthtimeMs": {
"description": "The time (in ms since POSIX epoch) when the library item was created on disk. Will be 0 if unknown.",
"type": "integer"
},
@@ -1723,247 +2329,724 @@
"updatedAt": {
"$ref": "#/components/schemas/updatedAt"
},
- "isMissing": {
- "description": "Whether the library item was scanned and no longer exists.",
- "type": "boolean"
- },
- "isInvalid": {
- "description": "Whether the library item was scanned and no longer has media files.",
- "type": "boolean"
+ "isMissing": {
+ "description": "Whether the library item was scanned and no longer exists.",
+ "type": "boolean"
+ },
+ "isInvalid": {
+ "description": "Whether the library item was scanned and no longer has media files.",
+ "type": "boolean"
+ },
+ "mediaType": {
+ "$ref": "#/components/schemas/mediaType"
+ }
+ }
+ },
+ "bookMetadataBase": {
+ "type": "object",
+ "description": "The base book metadata object for minified, normal, and extended schemas to inherit from.",
+ "properties": {
+ "title": {
+ "description": "The title of the book. Will be null if unknown.",
+ "type": "string",
+ "nullable": true,
+ "example": "Wizards First Rule"
+ },
+ "subtitle": {
+ "description": "The subtitle of the book. Will be null if there is no subtitle.",
+ "type": "string",
+ "nullable": true
+ },
+ "genres": {
+ "description": "The genres of the book.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "Fantasy",
+ "Sci-Fi",
+ "Nonfiction: History"
+ ]
+ },
+ "publishedYear": {
+ "description": "The year the book was published. Will be null if unknown.",
+ "type": "string",
+ "nullable": true,
+ "example": "2008"
+ },
+ "publishedDate": {
+ "description": "The date the book was published. Will be null if unknown.",
+ "type": "string",
+ "nullable": true
+ },
+ "publisher": {
+ "description": "The publisher of the book. Will be null if unknown.",
+ "type": "string",
+ "nullable": true,
+ "example": "Brilliance Audio"
+ },
+ "description": {
+ "description": "A description for the book. Will be null if empty.",
+ "type": "string",
+ "nullable": true,
+ "example": "The masterpiece that started Terry Goodkind's New York Times bestselling epic Sword of Truth In the aftermath of the brutal murder of his father, a mysterious woman, Kahlan Amnell, appears in Richard Cypher's forest sanctuary seeking help...and more. His world, his very beliefs, are shattered when ancient debts come due with thundering violence. In a dark age it takes courage to live, and more than mere courage to challenge those who hold dominion, Richard and Kahlan must take up that challenge or become the next victims. Beyond awaits a bewitching land where even the best of their hearts could betray them. Yet, Richard fears nothing so much as what secrets his sword might reveal about his own soul. Falling in love would destroy them - for reasons Richard can't imagine and Kahlan dare not say. In their darkest hour, hunted relentlessly, tormented by treachery and loss, Kahlan calls upon Richard to reach beyond his sword - to invoke within himself something more noble. Neither knows that the rules of battle have just changed...or that their time has run out. Wizard's First Rule is the beginning. One book. One Rule. Witness the birth of a legend."
+ },
+ "isbn": {
+ "description": "The ISBN of the book. Will be null if unknown.",
+ "type": "string",
+ "nullable": true
+ },
+ "asin": {
+ "description": "The ASIN of the book. Will be null if unknown.",
+ "type": "string",
+ "nullable": true,
+ "example": "B002V0QK4C"
+ },
+ "language": {
+ "description": "The language of the book. Will be null if unknown.",
+ "type": "string",
+ "nullable": true
+ },
+ "explicit": {
+ "description": "Whether the book has been marked as explicit.",
+ "type": "boolean",
+ "example": false
+ }
+ }
+ },
+ "bookMetadataMinified": {
+ "type": "object",
+ "description": "The minified metadata for a book in the database.",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/bookMetadataBase"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "titleIgnorePrefix": {
+ "description": "The title of the book with any prefix moved to the end.",
+ "type": "string"
+ },
+ "authorName": {
+ "description": "The name of the book's author(s).",
+ "type": "string",
+ "example": "Terry Goodkind"
+ },
+ "authorNameLF": {
+ "description": "The name of the book's author(s) with last names first.",
+ "type": "string",
+ "example": "Goodkind, Terry"
+ },
+ "narratorName": {
+ "description": "The name of the audiobook's narrator(s).",
+ "type": "string",
+ "example": "Sam Tsoutsouvas"
+ },
+ "seriesName": {
+ "description": "The name of the book's series.",
+ "type": "string",
+ "example": "Sword of Truth"
+ }
+ }
+ }
+ ]
+ },
+ "bookCoverPath": {
+ "description": "The absolute path on the server of the cover file. Will be null if there is no cover.",
+ "type": "string",
+ "nullable": true,
+ "example": "/audiobooks/Terry Goodkind/Sword of Truth/Wizards First Rule/cover.jpg"
+ },
+ "tags": {
+ "description": "Tags applied to items.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "To Be Read",
+ "Genre: Nonfiction"
+ ]
+ },
+ "durationSec": {
+ "description": "The total length (in seconds) of the item or file.",
+ "type": "number",
+ "example": 33854.905
+ },
+ "size": {
+ "description": "The total size (in bytes) of the item or file.",
+ "type": "integer",
+ "example": 268824228
+ },
+ "bookMinified": {
+ "type": "object",
+ "description": "Minified book schema. Does not depend on `bookBase` because there's pretty much no overlap.",
+ "properties": {
+ "metadata": {
+ "$ref": "#/components/schemas/bookMetadataMinified"
+ },
+ "coverPath": {
+ "$ref": "#/components/schemas/bookCoverPath"
+ },
+ "tags": {
+ "$ref": "#/components/schemas/tags"
+ },
+ "numTracks": {
+ "description": "The number of tracks the book's audio files have.",
+ "type": "integer",
+ "example": 1
+ },
+ "numAudioFiles": {
+ "description": "The number of audio files the book has.",
+ "type": "integer",
+ "example": 1
+ },
+ "numChapters": {
+ "description": "The number of chapters the book has.",
+ "type": "integer",
+ "example": 1
+ },
+ "numMissingParts": {
+ "description": "The total number of missing parts the book has.",
+ "type": "integer",
+ "example": 0
+ },
+ "numInvalidAudioFiles": {
+ "description": "The number of invalid audio files the book has.",
+ "type": "integer",
+ "example": 0
+ },
+ "duration": {
+ "$ref": "#/components/schemas/durationSec"
+ },
+ "size": {
+ "$ref": "#/components/schemas/size"
+ },
+ "ebookFormat": {
+ "description": "The format of ebook of the book. Will be null if the book is an audiobook.",
+ "type": "string",
+ "nullable": true
+ }
+ }
+ },
+ "mediaMinified": {
+ "description": "The minified media of the library item.",
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/bookMinified"
+ }
+ ]
+ },
+ "libraryItemMinified": {
+ "type": "object",
+ "description": "A single item on the server, like a book or podcast. Minified media format.",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/libraryItemBase"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "media": {
+ "$ref": "#/components/schemas/mediaMinified"
+ }
+ }
+ }
+ ]
+ },
+ "seriesId": {
+ "type": "string",
+ "description": "The ID of the series.",
+ "format": "uuid",
+ "example": "e4bb1afb-4a4f-4dd6-8be0-e615d233185b"
+ },
+ "seriesName": {
+ "description": "The name of the series.",
+ "type": "string",
+ "example": "Sword of Truth"
+ },
+ "authorSeries": {
+ "type": "object",
+ "description": "Series and the included library items that an author has written.",
+ "properties": {
+ "id": {
+ "$ref": "#/components/schemas/seriesId"
+ },
+ "name": {
+ "$ref": "#/components/schemas/seriesName"
+ },
+ "items": {
+ "description": "The items in the series. Each library item's media's metadata will have a `series` attribute, a `Series Sequence`, which is the matching series.",
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/libraryItemMinified"
+ }
+ }
+ }
+ },
+ "author": {
+ "description": "An author object which includes a description and image path. The library items and series associated with the author are optionally included.",
+ "type": "object",
+ "properties": {
+ "id": {
+ "$ref": "#/components/schemas/authorId"
+ },
+ "asin": {
+ "$ref": "#/components/schemas/authorAsin"
+ },
+ "name": {
+ "$ref": "#/components/schemas/authorName"
+ },
+ "description": {
+ "$ref": "#/components/schemas/authorDescription"
+ },
+ "imagePath": {
+ "$ref": "#/components/schemas/authorImagePath"
+ },
+ "addedAt": {
+ "$ref": "#/components/schemas/addedAt"
+ },
+ "updatedAt": {
+ "$ref": "#/components/schemas/updatedAt"
+ },
+ "libraryItems": {
+ "description": "The items associated with the author",
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/libraryItemMinified"
+ }
},
- "mediaType": {
- "$ref": "#/components/schemas/mediaType"
+ "series": {
+ "description": "The series associated with the author",
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/authorSeries"
+ }
}
}
},
- "bookMetadataBase": {
+ "authorUpdated": {
+ "description": "Whether the author was updated without errors. Will not exist if author was merged.",
+ "type": "boolean",
+ "nullable": true
+ },
+ "authorMerged": {
+ "description": "Whether the author was merged with another author. Will not exist if author was updated.",
+ "type": "boolean",
+ "nullable": true
+ },
+ "imageWidth": {
+ "description": "The requested width of image in pixels.",
+ "type": "integer",
+ "default": 400,
+ "example": 400
+ },
+ "imageHeight": {
+ "description": "The requested height of image in pixels. If `null`, the height is scaled to maintain aspect ratio based on the requested width.",
+ "type": "integer",
+ "nullable": true,
+ "default": null,
+ "example": 600
+ },
+ "imageFormat": {
+ "description": "The requested output format.",
+ "type": "string",
+ "default": "jpeg",
+ "example": "webp"
+ },
+ "imageRaw": {
+ "description": "Return the raw image without scaling if true.",
+ "type": "boolean",
+ "default": false
+ },
+ "imageUrl": {
+ "description": "The URL of the image to add to the server",
+ "type": "string",
+ "format": "uri",
+ "example": "https://images-na.ssl-images-amazon.com/images/I/51NoQTm33OL.__01_SX120_CR0,0,120,120__.jpg"
+ },
+ "authorSearchName": {
+ "description": "The name of the author to use for searching.",
+ "type": "string",
+ "example": "Terry Goodkind"
+ },
+ "region": {
+ "description": "The region used to search.",
+ "type": "string",
+ "example": "us",
+ "default": "us"
+ },
+ "ereaderName": {
+ "type": "string",
+ "description": "The name of the e-reader device."
+ },
+ "EreaderDeviceObject": {
"type": "object",
- "description": "The base book metadata object for minified, normal, and extended schemas to inherit from.",
+ "description": "An e-reader device configured to receive EPUB through e-mail.",
"properties": {
- "title": {
- "description": "The title of the book. Will be null if unknown.",
+ "name": {
+ "$ref": "#/components/schemas/ereaderName"
+ },
+ "email": {
+ "type": "string",
+ "description": "The email address associated with the e-reader device."
+ },
+ "availabilityOption": {
+ "type": "string",
+ "description": "The availability option for the device.",
+ "enum": [
+ "adminOrUp",
+ "userOrUp",
+ "guestOrUp",
+ "specificUsers"
+ ]
+ },
+ "users": {
+ "type": "array",
+ "description": "List of specific users allowed to access the device.",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required": [
+ "name",
+ "email",
+ "availabilityOption"
+ ]
+ },
+ "EmailSettings": {
+ "type": "object",
+ "description": "The email settings configuration for the server. This includes the credentials to send e-books and an array of e-reader devices.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The unique identifier for the email settings. Currently this is always `email-settings`",
+ "example": "email-settings"
+ },
+ "host": {
+ "type": "string",
+ "description": "The SMTP host address.",
+ "nullable": true
+ },
+ "port": {
+ "type": "integer",
+ "format": "int32",
+ "description": "The port number for the SMTP server.",
+ "example": 465
+ },
+ "secure": {
+ "type": "boolean",
+ "description": "Indicates if the connection should use SSL/TLS.",
+ "example": true
+ },
+ "rejectUnauthorized": {
+ "type": "boolean",
+ "description": "Indicates if unauthorized SSL/TLS certificates should be rejected.",
+ "example": true
+ },
+ "user": {
+ "type": "string",
+ "description": "The username for SMTP authentication.",
+ "nullable": true
+ },
+ "pass": {
+ "type": "string",
+ "description": "The password for SMTP authentication.",
+ "nullable": true
+ },
+ "testAddress": {
"type": "string",
- "nullable": true,
- "example": "Wizards First Rule"
+ "description": "The test email address used for sending test emails.",
+ "nullable": true
},
- "subtitle": {
- "description": "The subtitle of the book. Will be null if there is no subtitle.",
+ "fromAddress": {
"type": "string",
+ "description": "The default \"from\" email address for outgoing emails.",
"nullable": true
},
- "genres": {
- "description": "The genres of the book.",
+ "ereaderDevices": {
"type": "array",
+ "description": "List of configured e-reader devices.",
"items": {
- "type": "string"
- },
- "example": [
- "Fantasy",
- "Sci-Fi",
- "Nonfiction: History"
- ]
+ "$ref": "#/components/schemas/EreaderDeviceObject"
+ }
+ }
+ },
+ "required": [
+ "id",
+ "port",
+ "secure",
+ "ereaderDevices"
+ ]
+ },
+ "libraryName": {
+ "description": "The name of the library.",
+ "type": "string",
+ "example": "My Audiobooks"
+ },
+ "folder": {
+ "type": "object",
+ "description": "Folder used in library",
+ "properties": {
+ "id": {
+ "$ref": "#/components/schemas/folderId"
},
- "publishedYear": {
- "description": "The year the book was published. Will be null if unknown.",
+ "fullPath": {
+ "description": "The path on the server for the folder. (Read Only)",
"type": "string",
- "nullable": true,
- "example": "2008"
+ "example": "/podcasts"
},
- "publishedDate": {
- "description": "The date the book was published. Will be null if unknown.",
- "type": "string",
- "nullable": true
+ "libraryId": {
+ "$ref": "#/components/schemas/libraryId"
},
- "publisher": {
- "description": "The publisher of the book. Will be null if unknown.",
- "type": "string",
- "nullable": true,
- "example": "Brilliance Audio"
+ "addedAt": {
+ "$ref": "#/components/schemas/addedAt"
+ }
+ }
+ },
+ "librarySettings": {
+ "description": "The settings for the library.",
+ "type": "object",
+ "properties": {
+ "coverAspectRatio": {
+ "description": "Whether the library should use square book covers. Must be 0 (for false) or 1 (for true).",
+ "type": "integer",
+ "example": 1
},
- "description": {
- "description": "A description for the book. Will be null if empty.",
- "type": "string",
- "nullable": true,
- "example": "The masterpiece that started Terry Goodkind's New York Times bestselling epic Sword of Truth In the aftermath of the brutal murder of his father, a mysterious woman, Kahlan Amnell, appears in Richard Cypher's forest sanctuary seeking help...and more. His world, his very beliefs, are shattered when ancient debts come due with thundering violence. In a dark age it takes courage to live, and more than mere courage to challenge those who hold dominion, Richard and Kahlan must take up that challenge or become the next victims. Beyond awaits a bewitching land where even the best of their hearts could betray them. Yet, Richard fears nothing so much as what secrets his sword might reveal about his own soul. Falling in love would destroy them - for reasons Richard can't imagine and Kahlan dare not say. In their darkest hour, hunted relentlessly, tormented by treachery and loss, Kahlan calls upon Richard to reach beyond his sword - to invoke within himself something more noble. Neither knows that the rules of battle have just changed...or that their time has run out. Wizard's First Rule is the beginning. One book. One Rule. Witness the birth of a legend."
+ "disableWatcher": {
+ "description": "Whether to disable the folder watcher for the library.",
+ "type": "boolean",
+ "example": false
},
- "isbn": {
- "description": "The ISBN of the book. Will be null if unknown.",
- "type": "string",
- "nullable": true
+ "skipMatchingMediaWithAsin": {
+ "description": "Whether to skip matching books that already have an ASIN.",
+ "type": "boolean",
+ "example": false
},
- "asin": {
- "description": "The ASIN of the book. Will be null if unknown.",
+ "skipMatchingMediaWithIsbn": {
+ "description": "Whether to skip matching books that already have an ISBN.",
+ "type": "boolean",
+ "example": false
+ },
+ "autoScanCronExpression": {
+ "description": "The cron expression for when to automatically scan the library folders. If null, automatic scanning will be disabled.",
"type": "string",
"nullable": true,
- "example": "B002V0QK4C"
+ "example": "0 0 0 * * *"
},
- "language": {
- "description": "The language of the book. Will be null if unknown.",
- "type": "string",
- "nullable": true
+ "audiobooksOnly": {
+ "description": "Whether the library should ignore ebook files and only allow ebook files to be supplementary.",
+ "type": "boolean",
+ "example": false
},
- "explicit": {
- "description": "Whether the book has been marked as explicit.",
+ "hideSingleBookSeries": {
+ "description": "Whether to hide series with only one book.",
"type": "boolean",
"example": false
- }
- }
- },
- "bookMetadataMinified": {
- "type": "object",
- "description": "The minified metadata for a book in the database.",
- "allOf": [
- {
- "$ref": "#/components/schemas/bookMetadataBase"
},
- {
- "type": "object",
- "properties": {
- "titleIgnorePrefix": {
- "description": "The title of the book with any prefix moved to the end.",
- "type": "string"
- },
- "authorName": {
- "description": "The name of the book's author(s).",
- "type": "string",
- "example": "Terry Goodkind"
- },
- "authorNameLF": {
- "description": "The name of the book's author(s) with last names first.",
- "type": "string",
- "example": "Goodkind, Terry"
- },
- "narratorName": {
- "description": "The name of the audiobook's narrator(s).",
- "type": "string",
- "example": "Sam Tsoutsouvas"
- },
- "seriesName": {
- "description": "The name of the book's series.",
- "type": "string",
- "example": "Sword of Truth"
- }
- }
+ "onlyShowLaterBooksInContinueSeries": {
+ "description": "Whether to only show books in a series after the highest series sequence.",
+ "type": "boolean",
+ "example": false
+ },
+ "metadataPrecedence": {
+ "description": "The precedence of metadata sources. See Metadata Providers for a list of possible providers.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "folderStructure",
+ "audioMetatags",
+ "nfoFile",
+ "txtFiles",
+ "opfFile",
+ "absMetadata"
+ ]
+ },
+ "podcastSearchRegion": {
+ "description": "The region to use when searching for podcasts.",
+ "type": "string",
+ "example": "us"
}
- ]
- },
- "bookCoverPath": {
- "description": "The absolute path on the server of the cover file. Will be null if there is no cover.",
- "type": "string",
- "nullable": true,
- "example": "/audiobooks/Terry Goodkind/Sword of Truth/Wizards First Rule/cover.jpg"
- },
- "tags": {
- "description": "Tags applied to items.",
- "type": "array",
- "items": {
- "type": "string"
- },
- "example": [
- "To Be Read",
- "Genre: Nonfiction"
- ]
- },
- "durationSec": {
- "description": "The total length (in seconds) of the item or file.",
- "type": "number",
- "example": 33854.905
+ }
},
- "size": {
- "description": "The total size (in bytes) of the item or file.",
+ "createdAt": {
"type": "integer",
- "example": 268824228
+ "description": "The time (in ms since POSIX epoch) when was created.",
+ "example": 1633522963509
},
- "bookMinified": {
+ "library": {
+ "description": "A library object which includes either books or podcasts.",
"type": "object",
- "description": "Minified book schema. Does not depend on `bookBase` because there's pretty much no overlap.",
"properties": {
- "metadata": {
- "$ref": "#/components/schemas/bookMetadataMinified"
- },
- "coverPath": {
- "$ref": "#/components/schemas/bookCoverPath"
- },
- "tags": {
- "$ref": "#/components/schemas/tags"
+ "id": {
+ "$ref": "#/components/schemas/libraryId"
},
- "numTracks": {
- "description": "The number of tracks the book's audio files have.",
- "type": "integer",
- "example": 1
+ "name": {
+ "$ref": "#/components/schemas/libraryName"
},
- "numAudioFiles": {
- "description": "The number of audio files the book has.",
- "type": "integer",
- "example": 1
+ "folders": {
+ "description": "The folders that belong to the library.",
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/folder"
+ }
},
- "numChapters": {
- "description": "The number of chapters the book has.",
+ "displayOrder": {
+ "description": "Display position of the library in the list of libraries. Must be >= 1.",
"type": "integer",
"example": 1
},
- "numMissingParts": {
- "description": "The total number of missing parts the book has.",
- "type": "integer",
- "example": 0
+ "icon": {
+ "description": "The selected icon for the library. See Library Icons for a list of possible icons.",
+ "type": "string",
+ "example": "audiobookshelf"
},
- "numInvalidAudioFiles": {
- "description": "The number of invalid audio files the book has.",
- "type": "integer",
- "example": 0
+ "mediaType": {
+ "description": "The type of media that the library contains. Will be `book` or `podcast`. (Read Only)",
+ "type": "string",
+ "example": "book"
},
- "duration": {
- "$ref": "#/components/schemas/durationSec"
+ "provider": {
+ "description": "Preferred metadata provider for the library. See Metadata Providers for a list of possible providers.",
+ "type": "string",
+ "example": "audible"
},
- "size": {
- "$ref": "#/components/schemas/size"
+ "settings": {
+ "$ref": "#/components/schemas/librarySettings"
},
- "ebookFormat": {
- "description": "The format of ebook of the book. Will be null if the book is an audiobook.",
- "type": "string",
- "nullable": true
+ "createdAt": {
+ "$ref": "#/components/schemas/createdAt"
+ },
+ "lastUpdate": {
+ "$ref": "#/components/schemas/updatedAt"
}
}
},
- "mediaMinified": {
- "description": "The minified media of the library item.",
- "oneOf": [
- {
- "$ref": "#/components/schemas/bookMinified"
- }
- ]
+ "libraryFolders": {
+ "description": "The folders of the library. Only specify the fullPath.",
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/folder"
+ }
},
- "libraryItemMinified": {
+ "libraryDisplayOrder": {
+ "description": "The display order of the library. Must be >= 1.",
+ "type": "integer",
+ "minimum": 1,
+ "example": 1
+ },
+ "libraryIcon": {
+ "description": "The icon of the library. See Library Icons for a list of possible icons.",
+ "type": "string",
+ "example": "audiobookshelf"
+ },
+ "libraryMediaType": {
+ "description": "The type of media that the library contains. Must be `book` or `podcast`.",
+ "type": "string",
+ "example": "book"
+ },
+ "libraryProvider": {
+ "description": "Preferred metadata provider for the library. See Metadata Providers for a list of possible providers.",
+ "type": "string",
+ "example": "audible"
+ },
+ "authorExpanded": {
"type": "object",
- "description": "A single item on the server, like a book or podcast. Minified media format.",
+ "description": "The author schema with the total number of books in the library.",
"allOf": [
{
- "$ref": "#/components/schemas/libraryItemBase"
+ "$ref": "#/components/schemas/author"
},
{
"type": "object",
"properties": {
- "media": {
- "$ref": "#/components/schemas/mediaMinified"
+ "numBooks": {
+ "description": "The number of books associated with the author in the library.",
+ "type": "integer",
+ "example": 1
}
}
}
]
},
- "seriesId": {
+ "total": {
+ "description": "The total number of items in the response.",
+ "type": "integer",
+ "example": 100
+ },
+ "limit": {
+ "description": "The number of items to return. If 0, no items are returned.",
+ "type": "integer",
+ "example": 10,
+ "default": 0
+ },
+ "page": {
+ "description": "The page number (zero indexed) to return. If no limit is specified, then page will have no effect.",
+ "type": "integer",
+ "example": 1,
+ "default": 0
+ },
+ "sortBy": {
"type": "string",
- "description": "The ID of the series.",
- "format": "uuid",
- "example": "e4bb1afb-4a4f-4dd6-8be0-e615d233185b"
+ "description": "The field to sort by from the request.",
+ "example": "media.metadata.title"
},
- "seriesName": {
- "description": "The name of the series.",
+ "sortDesc": {
+ "description": "Whether to sort in descending order.",
+ "type": "boolean",
+ "example": true
+ },
+ "filterBy": {
"type": "string",
- "example": "Sword of Truth"
+ "description": "The field to filter by from the request. TODO",
+ "example": "media.metadata.title"
},
- "authorSeries": {
+ "minified": {
+ "description": "Return minified items if true.",
+ "type": "boolean",
+ "example": true,
+ "default": false
+ },
+ "collapseSeries": {
+ "type": "boolean",
+ "description": "Whether collapse series was set in the request.",
+ "example": true
+ },
+ "libraryInclude": {
+ "description": "The fields to include in the response. The only current option is `rssfeed`.",
+ "type": "string",
+ "example": "rssfeed"
+ },
+ "sequence": {
+ "description": "The position in the series the book is.",
+ "type": "string",
+ "nullable": true
+ },
+ "libraryItemSequence": {
"type": "object",
- "description": "Series and the included library items that an author has written.",
+ "description": "A single item on the server, like a book or podcast. Includes series sequence information.",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/libraryItemBase"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "sequence": {
+ "$ref": "#/components/schemas/sequence"
+ }
+ }
+ }
+ ]
+ },
+ "seriesBooks": {
+ "type": "object",
+ "description": "A series object which includes the name and books in the series.",
"properties": {
"id": {
"$ref": "#/components/schemas/seriesId"
@@ -1971,783 +3054,950 @@
"name": {
"$ref": "#/components/schemas/seriesName"
},
- "items": {
- "description": "The items in the series. Each library item's media's metadata will have a `series` attribute, a `Series Sequence`, which is the matching series.",
+ "addedAt": {
+ "$ref": "#/components/schemas/addedAt"
+ },
+ "nameIgnorePrefix": {
+ "description": "The name of the series with any prefix moved to the end.",
+ "type": "string"
+ },
+ "nameIgnorePrefixSort": {
+ "description": "The name of the series with any prefix removed.",
+ "type": "string"
+ },
+ "type": {
+ "description": "Will always be `series`.",
+ "type": "string"
+ },
+ "books": {
+ "description": "The library items that contain the books in the series. A sequence attribute that denotes the position in the series the book is in, is tacked on.",
"type": "array",
"items": {
- "$ref": "#/components/schemas/libraryItemMinified"
+ "$ref": "#/components/schemas/libraryItemSequence"
}
+ },
+ "totalDuration": {
+ "description": "The combined duration (in seconds) of all books in the series.",
+ "type": "number"
}
}
},
- "author": {
- "description": "An author object which includes a description and image path. The library items and series associated with the author are optionally included.",
+ "seriesDescription": {
+ "description": "A description for the series. Will be null if there is none.",
+ "type": "string",
+ "nullable": true,
+ "example": "The Sword of Truth is a series of twenty one epic fantasy novels written by Terry Goodkind."
+ },
+ "series": {
"type": "object",
+ "description": "A series object which includes the name and description of the series.",
"properties": {
"id": {
- "$ref": "#/components/schemas/authorId"
- },
- "asin": {
- "$ref": "#/components/schemas/authorAsin"
+ "$ref": "#/components/schemas/seriesId"
},
"name": {
- "$ref": "#/components/schemas/authorName"
+ "$ref": "#/components/schemas/seriesName"
},
"description": {
- "$ref": "#/components/schemas/authorDescription"
- },
- "imagePath": {
- "$ref": "#/components/schemas/authorImagePath"
+ "$ref": "#/components/schemas/seriesDescription"
},
"addedAt": {
"$ref": "#/components/schemas/addedAt"
},
"updatedAt": {
"$ref": "#/components/schemas/updatedAt"
+ }
+ }
+ },
+ "seriesProgress": {
+ "type": "object",
+ "description": "The user's progress of a series.",
+ "properties": {
+ "libraryItemIds": {
+ "description": "The IDs of the library items in the series.",
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/libraryItemId"
+ }
+ },
+ "libraryItemIdsFinished": {
+ "description": "The IDs of the library items in the series that are finished.",
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/libraryItemId"
+ }
+ },
+ "isFinished": {
+ "description": "Whether the series is finished.",
+ "type": "boolean"
+ }
+ }
+ },
+ "seriesWithProgressAndRSS": {
+ "type": "object",
+ "description": "A series object which includes the name and progress of the series.",
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/series"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "progress": {
+ "$ref": "#/components/schemas/seriesProgress"
+ },
+ "rssFeed": {
+ "description": "The RSS feed for the series.",
+ "type": "string",
+ "example": "TBD"
+ }
+ }
+ }
+ ]
+ },
+ "NotificationEvent": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The name of the notification event. The names and allowable values are defined at https://github.com/advplyr/audiobookshelf/blob/master/server/utils/notifications.js"
+ },
+ "requiresLibrary": {
+ "type": "boolean",
+ "description": "Whether the notification event depends on a library existing."
+ },
+ "libraryMediaType": {
+ "type": "string",
+ "description": "The type of media of the library the notification depends on existing. Will not exist if requiresLibrary is false.",
+ "nullable": true
+ },
+ "description": {
+ "type": "string",
+ "description": "The description of the notification event."
},
- "libraryItems": {
- "description": "The items associated with the author",
+ "variables": {
"type": "array",
"items": {
- "$ref": "#/components/schemas/libraryItemMinified"
+ "type": "string"
+ },
+ "description": "The variables of the notification event that can be used in the notification templates."
+ },
+ "defaults": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string",
+ "description": "The default title template for notifications using the notification event."
+ },
+ "body": {
+ "type": "string",
+ "description": "The default body template for notifications using the notification event."
+ }
}
},
- "series": {
- "description": "The series associated with the author",
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/authorSeries"
+ "testData": {
+ "type": "object",
+ "description": "The keys of the testData object will match the list of variables. The values will be the data used when sending a test notification.",
+ "additionalProperties": {
+ "type": "string"
}
}
}
},
- "authorUpdated": {
- "description": "Whether the author was updated without errors. Will not exist if author was merged.",
- "type": "boolean",
- "nullable": true
- },
- "authorMerged": {
- "description": "Whether the author was merged with another author. Will not exist if author was updated.",
- "type": "boolean",
- "nullable": true
+ "notificationId": {
+ "type": "string",
+ "description": "The ID of the notification.",
+ "example": "notification-settings"
},
- "imageWidth": {
- "description": "The requested width of image in pixels.",
- "type": "integer",
- "default": 400,
- "example": 400
+ "appriseApiUrl": {
+ "type": "string",
+ "nullable": true,
+ "description": "The full URL where the Apprise API to use is located."
},
- "imageHeight": {
- "description": "The requested height of image in pixels. If `null`, the height is scaled to maintain aspect ratio based on the requested width.",
- "type": "integer",
+ "libraryIdNullable": {
+ "type": "string",
+ "description": "The ID of the library. Applies to all libraries if `null`.",
+ "format": "uuid",
"nullable": true,
- "default": null,
- "example": 600
+ "example": "e4bb1afb-4a4f-4dd6-8be0-e615d233185b"
},
- "imageFormat": {
- "description": "The requested output format.",
+ "notificationEventName": {
"type": "string",
- "default": "jpeg",
- "example": "webp"
+ "description": "The name of the event the notification will fire on.",
+ "enum": [
+ "onPodcastEpisodeDownloaded",
+ "onTest"
+ ]
},
- "imageRaw": {
- "description": "Return the raw image without scaling if true.",
- "type": "boolean",
- "default": false
+ "urls": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "The Apprise URLs to use for the notification.",
+ "example": "http://192.168.0.3:8000/notify/my-cool-notification"
},
- "imageUrl": {
- "description": "The URL of the image to add to the server",
+ "titleTemplate": {
"type": "string",
- "format": "uri",
- "example": "https://images-na.ssl-images-amazon.com/images/I/51NoQTm33OL.__01_SX120_CR0,0,120,120__.jpg"
+ "description": "The template for the notification title.",
+ "example": "New {{podcastTitle}} Episode!"
},
- "authorSearchName": {
- "description": "The name of the author to use for searching.",
+ "bodyTemplate": {
"type": "string",
- "example": "Terry Goodkind"
+ "description": "The template for the notification body.",
+ "example": "{{episodeTitle}} has been added to {{libraryName}} library."
},
- "region": {
- "description": "The region used to search.",
- "type": "string",
- "example": "us",
- "default": "us"
+ "enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Whether the notification is enabled."
},
- "ereaderName": {
+ "notificationType": {
"type": "string",
- "description": "The name of the e-reader device."
- },
- "EreaderDeviceObject": {
- "type": "object",
- "description": "An e-reader device configured to receive EPUB through e-mail.",
- "properties": {
- "name": {
- "$ref": "#/components/schemas/ereaderName"
- },
- "email": {
- "type": "string",
- "description": "The email address associated with the e-reader device."
- },
- "availabilityOption": {
- "type": "string",
- "description": "The availability option for the device.",
- "enum": [
- "adminOrUp",
- "userOrUp",
- "guestOrUp",
- "specificUsers"
- ]
- },
- "users": {
- "type": "array",
- "description": "List of specific users allowed to access the device.",
- "items": {
- "type": "string"
- }
- }
- },
- "required": [
- "name",
- "email",
- "availabilityOption"
- ]
+ "enum": [
+ "info",
+ "success",
+ "warning",
+ "failure"
+ ],
+ "nullable": true,
+ "default": "info",
+ "description": "The notification's type."
},
- "EmailSettings": {
+ "Notification": {
"type": "object",
- "description": "The email settings configuration for the server. This includes the credentials to send e-books and an array of e-reader devices.",
"properties": {
"id": {
- "type": "string",
- "description": "The unique identifier for the email settings. Currently this is always `email-settings`",
- "example": "email-settings"
- },
- "host": {
- "type": "string",
- "description": "The SMTP host address.",
- "nullable": true
+ "$ref": "#/components/schemas/notificationId"
},
- "port": {
- "type": "integer",
- "format": "int32",
- "description": "The port number for the SMTP server.",
- "example": 465
+ "libraryId": {
+ "$ref": "#/components/schemas/libraryIdNullable"
},
- "secure": {
- "type": "boolean",
- "description": "Indicates if the connection should use SSL/TLS.",
- "example": true
+ "eventName": {
+ "$ref": "#/components/schemas/notificationEventName"
},
- "rejectUnauthorized": {
- "type": "boolean",
- "description": "Indicates if unauthorized SSL/TLS certificates should be rejected.",
- "example": true
+ "urls": {
+ "$ref": "#/components/schemas/urls"
},
- "user": {
- "type": "string",
- "description": "The username for SMTP authentication.",
- "nullable": true
+ "titleTemplate": {
+ "$ref": "#/components/schemas/titleTemplate"
},
- "pass": {
- "type": "string",
- "description": "The password for SMTP authentication.",
- "nullable": true
+ "bodyTemplate": {
+ "$ref": "#/components/schemas/bodyTemplate"
},
- "testAddress": {
- "type": "string",
- "description": "The test email address used for sending test emails.",
- "nullable": true
+ "enabled": {
+ "$ref": "#/components/schemas/enabled"
},
- "fromAddress": {
- "type": "string",
- "description": "The default \"from\" email address for outgoing emails.",
- "nullable": true
+ "type": {
+ "$ref": "#/components/schemas/notificationType"
},
- "ereaderDevices": {
- "type": "array",
- "description": "List of configured e-reader devices.",
- "items": {
- "$ref": "#/components/schemas/EreaderDeviceObject"
- }
- }
- },
- "required": [
- "id",
- "port",
- "secure",
- "ereaderDevices"
- ]
- },
- "libraryName": {
- "description": "The name of the library.",
- "type": "string",
- "example": "My Audiobooks"
- },
- "folder": {
- "type": "object",
- "description": "Folder used in library",
- "properties": {
- "id": {
- "$ref": "#/components/schemas/folderId"
+ "lastFiredAt": {
+ "type": "integer",
+ "nullable": true,
+ "description": "The time (in ms since POSIX epoch) when the notification was last fired. Will be null if the notification has not fired."
},
- "fullPath": {
- "description": "The path on the server for the folder. (Read Only)",
- "type": "string",
- "example": "/podcasts"
+ "lastAttemptFailed": {
+ "type": "boolean",
+ "description": "Whether the last notification attempt failed."
},
- "libraryId": {
- "$ref": "#/components/schemas/libraryId"
+ "numConsecutiveFailedAttempts": {
+ "type": "integer",
+ "description": "The number of consecutive times the notification has failed.",
+ "default": 0
},
- "addedAt": {
- "$ref": "#/components/schemas/addedAt"
+ "numTimesFired": {
+ "type": "integer",
+ "description": "The number of times the notification has fired.",
+ "default": 0
+ },
+ "createdAt": {
+ "$ref": "#/components/schemas/createdAt"
}
}
},
- "librarySettings": {
- "description": "The settings for the library.",
+ "maxFailedAttempts": {
+ "type": "integer",
+ "minimum": 0,
+ "default": 5,
+ "description": "The maximum number of times a notification fails before being disabled."
+ },
+ "maxNotificationQueue": {
+ "type": "integer",
+ "description": "The maximum number of notifications in the notification queue before events are ignored."
+ },
+ "NotificationSettings": {
"type": "object",
"properties": {
- "coverAspectRatio": {
- "description": "Whether the library should use square book covers. Must be 0 (for false) or 1 (for true).",
- "type": "integer",
- "example": 1
- },
- "disableWatcher": {
- "description": "Whether to disable the folder watcher for the library.",
- "type": "boolean",
- "example": false
- },
- "skipMatchingMediaWithAsin": {
- "description": "Whether to skip matching books that already have an ASIN.",
- "type": "boolean",
- "example": false
- },
- "skipMatchingMediaWithIsbn": {
- "description": "Whether to skip matching books that already have an ISBN.",
- "type": "boolean",
- "example": false
+ "id": {
+ "$ref": "#/components/schemas/notificationId"
},
- "autoScanCronExpression": {
- "description": "The cron expression for when to automatically scan the library folders. If null, automatic scanning will be disabled.",
+ "appriseType": {
"type": "string",
- "nullable": true,
- "example": "0 0 0 * * *"
- },
- "audiobooksOnly": {
- "description": "Whether the library should ignore ebook files and only allow ebook files to be supplementary.",
- "type": "boolean",
- "example": false
- },
- "hideSingleBookSeries": {
- "description": "Whether to hide series with only one book.",
- "type": "boolean",
- "example": false
+ "description": "The type of Apprise that will be used. At the moment, only api is available."
},
- "onlyShowLaterBooksInContinueSeries": {
- "description": "Whether to only show books in a series after the highest series sequence.",
- "type": "boolean",
- "example": false
+ "appriseApiUrl": {
+ "$ref": "#/components/schemas/appriseApiUrl"
},
- "metadataPrecedence": {
- "description": "The precedence of metadata sources. See Metadata Providers for a list of possible providers.",
+ "notifications": {
"type": "array",
"items": {
- "type": "string"
+ "$ref": "#/components/schemas/Notification"
},
- "example": [
- "folderStructure",
- "audioMetatags",
- "nfoFile",
- "txtFiles",
- "opfFile",
- "absMetadata"
- ]
+ "description": "The set notifications."
},
- "podcastSearchRegion": {
- "description": "The region to use when searching for podcasts.",
- "type": "string",
- "example": "us"
+ "maxFailedAttempts": {
+ "$ref": "#/components/schemas/maxFailedAttempts"
+ },
+ "maxNotificationQueue": {
+ "$ref": "#/components/schemas/maxNotificationQueue"
+ },
+ "notificationDelay": {
+ "type": "integer",
+ "description": "The time (in ms) between notification pushes."
}
}
},
- "createdAt": {
- "type": "integer",
- "description": "The time (in ms since POSIX epoch) when was created.",
- "example": 1633522963509
+ "podcastId": {
+ "type": "string",
+ "description": "The ID of podcasts and podcast episodes after 2.3.0.",
+ "format": "uuid",
+ "example": "e4bb1afb-4a4f-4dd6-8be0-e615d233185b"
},
- "library": {
- "description": "A library object which includes either books or podcasts.",
+ "PodcastMetadata": {
"type": "object",
+ "description": "Metadata for a podcast.",
"properties": {
- "id": {
- "$ref": "#/components/schemas/libraryId"
+ "title": {
+ "type": "string",
+ "description": "The title of the podcast.",
+ "nullable": true
},
- "name": {
- "$ref": "#/components/schemas/libraryName"
+ "author": {
+ "type": "string",
+ "description": "The author of the podcast.",
+ "nullable": true
},
- "folders": {
- "description": "The folders that belong to the library.",
+ "description": {
+ "type": "string",
+ "description": "The description of the podcast.",
+ "nullable": true
+ },
+ "releaseDate": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The release date of the podcast.",
+ "nullable": true
+ },
+ "genres": {
"type": "array",
+ "description": "The genres of the podcast.",
"items": {
- "$ref": "#/components/schemas/folder"
+ "type": "string"
}
},
- "displayOrder": {
- "description": "Display position of the library in the list of libraries. Must be >= 1.",
- "type": "integer",
- "example": 1
- },
- "icon": {
- "description": "The selected icon for the library. See Library Icons for a list of possible icons.",
+ "feedUrl": {
"type": "string",
- "example": "audiobookshelf"
+ "description": "The URL of the podcast feed.",
+ "nullable": true
},
- "mediaType": {
- "description": "The type of media that the library contains. Will be `book` or `podcast`. (Read Only)",
+ "imageUrl": {
"type": "string",
- "example": "book"
+ "description": "The URL of the podcast's image.",
+ "nullable": true
},
- "provider": {
- "description": "Preferred metadata provider for the library. See Metadata Providers for a list of possible providers.",
+ "itunesPageUrl": {
"type": "string",
- "example": "audible"
+ "description": "The URL of the podcast's iTunes page.",
+ "nullable": true
},
- "settings": {
- "$ref": "#/components/schemas/librarySettings"
+ "itunesId": {
+ "type": "string",
+ "description": "The iTunes ID of the podcast.",
+ "nullable": true
},
- "createdAt": {
- "$ref": "#/components/schemas/createdAt"
+ "itunesArtistId": {
+ "type": "string",
+ "description": "The iTunes artist ID of the podcast.",
+ "nullable": true
},
- "lastUpdate": {
- "$ref": "#/components/schemas/updatedAt"
- }
- }
- },
- "libraryFolders": {
- "description": "The folders of the library. Only specify the fullPath.",
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/folder"
- }
- },
- "libraryDisplayOrder": {
- "description": "The display order of the library. Must be >= 1.",
- "type": "integer",
- "minimum": 1,
- "example": 1
- },
- "libraryIcon": {
- "description": "The icon of the library. See Library Icons for a list of possible icons.",
- "type": "string",
- "example": "audiobookshelf"
- },
- "libraryMediaType": {
- "description": "The type of media that the library contains. Must be `book` or `podcast`.",
- "type": "string",
- "example": "book"
- },
- "libraryProvider": {
- "description": "Preferred metadata provider for the library. See Metadata Providers for a list of possible providers.",
- "type": "string",
- "example": "audible"
- },
- "authorExpanded": {
- "type": "object",
- "description": "The author schema with the total number of books in the library.",
- "allOf": [
- {
- "$ref": "#/components/schemas/author"
+ "explicit": {
+ "type": "boolean",
+ "description": "Whether the podcast contains explicit content."
},
- {
- "type": "object",
- "properties": {
- "numBooks": {
- "description": "The number of books associated with the author in the library.",
- "type": "integer",
- "example": 1
- }
- }
- }
- ]
- },
- "total": {
- "description": "The total number of items in the response.",
- "type": "integer",
- "example": 100
- },
- "limit": {
- "description": "The number of items to return. If 0, no items are returned.",
- "type": "integer",
- "example": 10,
- "default": 0
- },
- "page": {
- "description": "The page number (zero indexed) to return. If no limit is specified, then page will have no effect.",
- "type": "integer",
- "example": 1,
- "default": 0
- },
- "sortBy": {
- "type": "string",
- "description": "The field to sort by from the request.",
- "example": "media.metadata.title"
- },
- "sortDesc": {
- "description": "Whether to sort in descending order.",
- "type": "boolean",
- "example": true
- },
- "filterBy": {
- "type": "string",
- "description": "The field to filter by from the request. TODO",
- "example": "media.metadata.title"
- },
- "minified": {
- "description": "Return minified items if true.",
- "type": "boolean",
- "example": true,
- "default": false
- },
- "collapseSeries": {
- "type": "boolean",
- "description": "Whether collapse series was set in the request.",
- "example": true
- },
- "libraryInclude": {
- "description": "The fields to include in the response. The only current option is `rssfeed`.",
- "type": "string",
- "example": "rssfeed"
- },
- "sequence": {
- "description": "The position in the series the book is.",
- "type": "string",
- "nullable": true
- },
- "libraryItemSequence": {
- "type": "object",
- "description": "A single item on the server, like a book or podcast. Includes series sequence information.",
- "allOf": [
- {
- "$ref": "#/components/schemas/libraryItemBase"
+ "language": {
+ "type": "string",
+ "description": "The language of the podcast.",
+ "nullable": true
},
- {
- "type": "object",
- "properties": {
- "sequence": {
- "$ref": "#/components/schemas/sequence"
- }
- }
+ "type": {
+ "type": "string",
+ "description": "The type of podcast (e.g., episodic, serial).",
+ "nullable": true
}
- ]
+ }
},
- "seriesBooks": {
+ "oldPodcastId": {
+ "description": "The ID of podcasts on server version 2.2.23 and before.",
+ "type": "string",
+ "nullable": true,
+ "format": "pod_[a-z0-9]{18}",
+ "example": "pod_o78uaoeuh78h6aoeif"
+ },
+ "fileMetadata": {
"type": "object",
- "description": "A series object which includes the name and books in the series.",
+ "description": "The metadata for a file, including the path, size, and unix timestamps of the file.",
+ "nullable": true,
"properties": {
- "id": {
- "$ref": "#/components/schemas/seriesId"
+ "filename": {
+ "description": "The filename of the file.",
+ "type": "string",
+ "example": "Wizards First Rule 01.mp3"
},
- "name": {
- "$ref": "#/components/schemas/seriesName"
+ "ext": {
+ "description": "The file extension of the file.",
+ "type": "string",
+ "example": ".mp3"
},
- "addedAt": {
- "$ref": "#/components/schemas/addedAt"
+ "path": {
+ "description": "The absolute path on the server of the file.",
+ "type": "string",
+ "example": "/audiobooks/Terry Goodkind/Sword of Truth/Wizards First Rule/Terry Goodkind - SOT Bk01 - Wizards First Rule 01.mp3"
},
- "nameIgnorePrefix": {
- "description": "The name of the series with any prefix moved to the end.",
- "type": "string"
+ "relPath": {
+ "description": "The path of the file, relative to the book's or podcast's folder.",
+ "type": "string",
+ "example": "Wizards First Rule 01.mp3"
},
- "nameIgnorePrefixSort": {
- "description": "The name of the series with any prefix removed.",
- "type": "string"
+ "size": {
+ "$ref": "#/components/schemas/size"
},
- "type": {
- "description": "Will always be `series`.",
- "type": "string"
+ "mtimeMs": {
+ "description": "The time (in ms since POSIX epoch) when the file was last modified on disk.",
+ "type": "integer",
+ "example": 1632223180278
},
- "books": {
- "description": "The library items that contain the books in the series. A sequence attribute that denotes the position in the series the book is in, is tacked on.",
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/libraryItemSequence"
- }
+ "ctimeMs": {
+ "description": "The time (in ms since POSIX epoch) when the file status was changed on disk.",
+ "type": "integer",
+ "example": 1645978261001
},
- "totalDuration": {
- "description": "The combined duration (in seconds) of all books in the series.",
- "type": "number"
+ "birthtimeMs": {
+ "description": "The time (in ms since POSIX epoch) when the file was created on disk. Will be 0 if unknown.",
+ "type": "integer",
+ "example": 0
}
}
},
- "seriesDescription": {
- "description": "A description for the series. Will be null if there is none.",
- "type": "string",
- "nullable": true,
- "example": "The Sword of Truth is a series of twenty one epic fantasy novels written by Terry Goodkind."
- },
- "series": {
+ "bookChapter": {
"type": "object",
- "description": "A series object which includes the name and description of the series.",
+ "description": "A book chapter. Includes the title and timestamps.",
"properties": {
"id": {
- "$ref": "#/components/schemas/seriesId"
- },
- "name": {
- "$ref": "#/components/schemas/seriesName"
+ "description": "The ID of the book chapter.",
+ "type": "integer",
+ "example": 0
},
- "description": {
- "$ref": "#/components/schemas/seriesDescription"
+ "start": {
+ "description": "When in the book (in seconds) the chapter starts.",
+ "type": "integer",
+ "example": 0
},
- "addedAt": {
- "$ref": "#/components/schemas/addedAt"
+ "end": {
+ "description": "When in the book (in seconds) the chapter ends.",
+ "type": "number",
+ "example": 6004.6675
},
- "updatedAt": {
- "$ref": "#/components/schemas/updatedAt"
+ "title": {
+ "description": "The title of the chapter.",
+ "type": "string",
+ "example": "Wizards First Rule 01 Chapter 1"
}
}
},
- "seriesProgress": {
+ "audioMetaTags": {
+ "description": "ID3 metadata tags pulled from the audio file on import. Only non-null tags will be returned in requests.",
"type": "object",
- "description": "The user's progress of a series.",
"properties": {
- "libraryItemIds": {
- "description": "The IDs of the library items in the series.",
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/libraryItemId"
- }
+ "tagAlbum": {
+ "type": "string",
+ "nullable": true,
+ "example": "SOT Bk01"
},
- "libraryItemIdsFinished": {
- "description": "The IDs of the library items in the series that are finished.",
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/libraryItemId"
- }
+ "tagArtist": {
+ "type": "string",
+ "nullable": true,
+ "example": "Terry Goodkind"
},
- "isFinished": {
- "description": "Whether the series is finished.",
- "type": "boolean"
+ "tagGenre": {
+ "type": "string",
+ "nullable": true,
+ "example": "Audiobook Fantasy"
+ },
+ "tagTitle": {
+ "type": "string",
+ "nullable": true,
+ "example": "Wizards First Rule 01"
+ },
+ "tagSeries": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagSeriesPart": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagTrack": {
+ "type": "string",
+ "nullable": true,
+ "example": "01/20"
+ },
+ "tagDisc": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagSubtitle": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagAlbumArtist": {
+ "type": "string",
+ "nullable": true,
+ "example": "Terry Goodkind"
+ },
+ "tagDate": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagComposer": {
+ "type": "string",
+ "nullable": true,
+ "example": "Terry Goodkind"
+ },
+ "tagPublisher": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagComment": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagDescription": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagEncoder": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagEncodedBy": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagIsbn": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagLanguage": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagASIN": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagOverdriveMediaMarker": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagOriginalYear": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagReleaseCountry": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagReleaseType": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagReleaseStatus": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagISRC": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagMusicBrainzTrackId": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagMusicBrainzAlbumId": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagMusicBrainzAlbumArtistId": {
+ "type": "string",
+ "nullable": true
+ },
+ "tagMusicBrainzArtistId": {
+ "type": "string",
+ "nullable": true
}
}
},
- "seriesWithProgressAndRSS": {
+ "audioFile": {
"type": "object",
- "description": "A series object which includes the name and progress of the series.",
- "oneOf": [
- {
- "$ref": "#/components/schemas/series"
+ "description": "An audio file for a book. Includes audio metadata and track numbers.",
+ "properties": {
+ "index": {
+ "description": "The index of the audio file.",
+ "type": "integer",
+ "example": 1
},
- {
- "type": "object",
- "properties": {
- "progress": {
- "$ref": "#/components/schemas/seriesProgress"
- },
- "rssFeed": {
- "description": "The RSS feed for the series.",
- "type": "string",
- "example": "TBD"
- }
+ "ino": {
+ "$ref": "#/components/schemas/inode"
+ },
+ "metadata": {
+ "$ref": "#/components/schemas/fileMetadata"
+ },
+ "addedAt": {
+ "$ref": "#/components/schemas/addedAt"
+ },
+ "updatedAt": {
+ "$ref": "#/components/schemas/updatedAt"
+ },
+ "trackNumFromMeta": {
+ "description": "The track number of the audio file as pulled from the file's metadata. Will be null if unknown.",
+ "type": "integer",
+ "nullable": true,
+ "example": 1
+ },
+ "discNumFromMeta": {
+ "description": "The disc number of the audio file as pulled from the file's metadata. Will be null if unknown.",
+ "type": "string",
+ "nullable": true
+ },
+ "trackNumFromFilename": {
+ "description": "The track number of the audio file as determined from the file's name. Will be null if unknown.",
+ "type": "integer",
+ "nullable": true,
+ "example": 1
+ },
+ "discNumFromFilename": {
+ "description": "The disc number of the audio file as determined from the file's name. Will be null if unknown.",
+ "type": "string",
+ "nullable": true
+ },
+ "manuallyVerified": {
+ "description": "Whether the audio file has been manually verified by a user.",
+ "type": "boolean"
+ },
+ "invalid": {
+ "description": "Whether the audio file is missing from the server.",
+ "type": "boolean"
+ },
+ "exclude": {
+ "description": "Whether the audio file has been marked for exclusion.",
+ "type": "boolean"
+ },
+ "error": {
+ "description": "Any error with the audio file. Will be null if there is none.",
+ "type": "string",
+ "nullable": true
+ },
+ "format": {
+ "description": "The format of the audio file.",
+ "type": "string",
+ "example": "MP2/3 (MPEG audio layer 2/3)"
+ },
+ "duration": {
+ "$ref": "#/components/schemas/durationSec"
+ },
+ "bitRate": {
+ "description": "The bit rate (in bit/s) of the audio file.",
+ "type": "integer",
+ "example": 64000
+ },
+ "language": {
+ "description": "The language of the audio file.",
+ "type": "string",
+ "nullable": true
+ },
+ "codec": {
+ "description": "The codec of the audio file.",
+ "type": "string",
+ "example": "mp3"
+ },
+ "timeBase": {
+ "description": "The time base of the audio file.",
+ "type": "string",
+ "example": "1/14112000"
+ },
+ "channels": {
+ "description": "The number of channels the audio file has.",
+ "type": "integer",
+ "example": 2
+ },
+ "channelLayout": {
+ "description": "The layout of the audio file's channels.",
+ "type": "string",
+ "example": "stereo"
+ },
+ "chapters": {
+ "description": "If the audio file is part of an audiobook, the chapters the file contains.",
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/bookChapter"
}
+ },
+ "embeddedCoverArt": {
+ "description": "The type of embedded cover art in the audio file. Will be null if none exists.",
+ "type": "string",
+ "nullable": true
+ },
+ "metaTags": {
+ "$ref": "#/components/schemas/audioMetaTags"
+ },
+ "mimeType": {
+ "description": "The MIME type of the audio file.",
+ "type": "string",
+ "example": "audio/mpeg"
}
- ]
+ }
},
- "NotificationEvent": {
+ "AudioTrack": {
"type": "object",
+ "description": "Represents an audio track with various properties.",
"properties": {
- "name": {
- "type": "string",
- "description": "The name of the notification event. The names and allowable values are defined at https://github.com/advplyr/audiobookshelf/blob/master/server/utils/notifications.js"
+ "index": {
+ "type": "integer",
+ "nullable": true,
+ "description": "The index of the audio track.",
+ "example": null
},
- "requiresLibrary": {
- "type": "boolean",
- "description": "Whether the notification event depends on a library existing."
+ "startOffset": {
+ "type": "number",
+ "format": "float",
+ "nullable": true,
+ "description": "The start offset of the audio track in seconds.",
+ "example": null
},
- "libraryMediaType": {
+ "duration": {
+ "type": "number",
+ "format": "float",
+ "nullable": true,
+ "description": "The duration of the audio track in seconds.",
+ "example": null
+ },
+ "title": {
"type": "string",
- "description": "The type of media of the library the notification depends on existing. Will not exist if requiresLibrary is false.",
- "nullable": true
+ "nullable": true,
+ "description": "The title of the audio track.",
+ "example": null
},
- "description": {
+ "contentUrl": {
"type": "string",
- "description": "The description of the notification event."
+ "nullable": true,
+ "description": "The URL where the audio track content is located.",
+ "example": "`/api/items/${itemId}/file/${audioFile.ino}`"
},
- "variables": {
- "type": "array",
- "items": {
- "type": "string"
- },
- "description": "The variables of the notification event that can be used in the notification templates."
+ "mimeType": {
+ "type": "string",
+ "nullable": true,
+ "description": "The MIME type of the audio track.",
+ "example": null
},
- "defaults": {
- "type": "object",
- "properties": {
- "title": {
- "type": "string",
- "description": "The default title template for notifications using the notification event."
- },
- "body": {
- "type": "string",
- "description": "The default body template for notifications using the notification event."
- }
- }
+ "codec": {
+ "type": "string",
+ "nullable": true,
+ "description": "The codec used for the audio track.",
+ "example": "aac"
},
- "testData": {
- "type": "object",
- "description": "The keys of the testData object will match the list of variables. The values will be the data used when sending a test notification.",
- "additionalProperties": {
- "type": "string"
- }
+ "metadata": {
+ "$ref": "#/components/schemas/fileMetadata"
}
}
},
- "notificationId": {
- "type": "string",
- "description": "The ID of the notification.",
- "example": "notification-settings"
- },
- "appriseApiUrl": {
- "type": "string",
- "nullable": true,
- "description": "The full URL where the Apprise API to use is located."
- },
- "libraryIdNullable": {
- "type": "string",
- "description": "The ID of the library. Applies to all libraries if `null`.",
- "format": "uuid",
- "nullable": true,
- "example": "e4bb1afb-4a4f-4dd6-8be0-e615d233185b"
- },
- "notificationEventName": {
- "type": "string",
- "description": "The name of the event the notification will fire on.",
- "enum": [
- "onPodcastEpisodeDownloaded",
- "onTest"
- ]
- },
- "urls": {
- "type": "array",
- "items": {
- "type": "string"
- },
- "description": "The Apprise URLs to use for the notification.",
- "example": "http://192.168.0.3:8000/notify/my-cool-notification"
- },
- "titleTemplate": {
- "type": "string",
- "description": "The template for the notification title.",
- "example": "New {{podcastTitle}} Episode!"
- },
- "bodyTemplate": {
- "type": "string",
- "description": "The template for the notification body.",
- "example": "{{episodeTitle}} has been added to {{libraryName}} library."
- },
- "enabled": {
- "type": "boolean",
- "default": false,
- "description": "Whether the notification is enabled."
- },
- "notificationType": {
- "type": "string",
- "enum": [
- "info",
- "success",
- "warning",
- "failure"
- ],
- "nullable": true,
- "default": "info",
- "description": "The notification's type."
- },
- "Notification": {
+ "PodcastEpisode": {
"type": "object",
+ "description": "A single episode of a podcast.",
"properties": {
+ "libraryItemId": {
+ "$ref": "#/components/schemas/libraryItemId"
+ },
+ "podcastId": {
+ "$ref": "#/components/schemas/podcastId"
+ },
"id": {
- "$ref": "#/components/schemas/notificationId"
+ "$ref": "#/components/schemas/podcastId"
},
- "libraryId": {
- "$ref": "#/components/schemas/libraryIdNullable"
+ "oldEpisodeId": {
+ "$ref": "#/components/schemas/oldPodcastId"
},
- "eventName": {
- "$ref": "#/components/schemas/notificationEventName"
+ "index": {
+ "type": "integer",
+ "description": "The index of the episode within the podcast.",
+ "nullable": true
},
- "urls": {
- "$ref": "#/components/schemas/urls"
+ "season": {
+ "type": "string",
+ "description": "The season number of the episode.",
+ "nullable": true
},
- "titleTemplate": {
- "$ref": "#/components/schemas/titleTemplate"
+ "episode": {
+ "type": "string",
+ "description": "The episode number within the season.",
+ "nullable": true
},
- "bodyTemplate": {
- "$ref": "#/components/schemas/bodyTemplate"
+ "episodeType": {
+ "type": "string",
+ "description": "The type of episode (e.g., full, trailer).",
+ "nullable": true
},
- "enabled": {
- "$ref": "#/components/schemas/enabled"
+ "title": {
+ "type": "string",
+ "description": "The title of the episode.",
+ "nullable": true
},
- "type": {
- "$ref": "#/components/schemas/notificationType"
+ "subtitle": {
+ "type": "string",
+ "description": "The subtitle of the episode.",
+ "nullable": true
},
- "lastFiredAt": {
- "type": "integer",
+ "description": {
+ "type": "string",
+ "description": "The description of the episode.",
+ "nullable": true
+ },
+ "enclosure": {
+ "type": "object",
+ "description": "The enclosure object containing additional episode data.",
"nullable": true,
- "description": "The time (in ms since POSIX epoch) when the notification was last fired. Will be null if the notification has not fired."
+ "additionalProperties": true
},
- "lastAttemptFailed": {
- "type": "boolean",
- "description": "Whether the last notification attempt failed."
+ "guid": {
+ "type": "string",
+ "description": "The globally unique identifier for the episode.",
+ "nullable": true
},
- "numConsecutiveFailedAttempts": {
- "type": "integer",
- "description": "The number of consecutive times the notification has failed.",
- "default": 0
+ "pubDate": {
+ "type": "string",
+ "description": "The publication date of the episode.",
+ "nullable": true
},
- "numTimesFired": {
- "type": "integer",
- "description": "The number of times the notification has fired.",
- "default": 0
+ "chapters": {
+ "type": "array",
+ "description": "The chapters within the episode.",
+ "items": {
+ "type": "object"
+ }
},
- "createdAt": {
+ "audioFile": {
+ "$ref": "#/components/schemas/audioFile"
+ },
+ "publishedAt": {
"$ref": "#/components/schemas/createdAt"
+ },
+ "addedAt": {
+ "$ref": "#/components/schemas/addedAt"
+ },
+ "updatedAt": {
+ "$ref": "#/components/schemas/updatedAt"
+ },
+ "audioTrack": {
+ "$ref": "#/components/schemas/AudioTrack"
+ },
+ "duration": {
+ "$ref": "#/components/schemas/durationSec"
+ },
+ "size": {
+ "$ref": "#/components/schemas/size"
}
}
},
- "maxFailedAttempts": {
- "type": "integer",
- "minimum": 0,
- "default": 5,
- "description": "The maximum number of times a notification fails before being disabled."
- },
- "maxNotificationQueue": {
- "type": "integer",
- "description": "The maximum number of notifications in the notification queue before events are ignored."
+ "autoDownloadEpisodes": {
+ "type": "boolean",
+ "description": "Whether episodes are automatically downloaded."
},
- "NotificationSettings": {
+ "Podcast": {
"type": "object",
+ "description": "A podcast containing multiple episodes.",
"properties": {
"id": {
- "$ref": "#/components/schemas/notificationId"
+ "$ref": "#/components/schemas/podcastId"
},
- "appriseType": {
+ "libraryItemId": {
+ "$ref": "#/components/schemas/libraryItemId"
+ },
+ "metadata": {
+ "$ref": "#/components/schemas/PodcastMetadata"
+ },
+ "coverPath": {
"type": "string",
- "description": "The type of Apprise that will be used. At the moment, only api is available."
+ "description": "The file path to the podcast's cover image.",
+ "nullable": true
},
- "appriseApiUrl": {
- "$ref": "#/components/schemas/appriseApiUrl"
+ "tags": {
+ "type": "array",
+ "description": "The tags associated with the podcast.",
+ "items": {
+ "type": "string"
+ }
},
- "notifications": {
+ "episodes": {
"type": "array",
+ "description": "The episodes of the podcast.",
"items": {
- "$ref": "#/components/schemas/Notification"
- },
- "description": "The set notifications."
+ "$ref": "#/components/schemas/PodcastEpisode"
+ }
},
- "maxFailedAttempts": {
- "$ref": "#/components/schemas/maxFailedAttempts"
+ "autoDownloadEpisodes": {
+ "$ref": "#/components/schemas/autoDownloadEpisodes"
},
- "maxNotificationQueue": {
- "$ref": "#/components/schemas/maxNotificationQueue"
+ "autoDownloadSchedule": {
+ "type": "string",
+ "description": "The schedule for automatic episode downloads, in cron format.",
+ "nullable": true
},
- "notificationDelay": {
+ "lastEpisodeCheck": {
"type": "integer",
- "description": "The time (in ms) between notification pushes."
+ "description": "The timestamp of the last episode check."
+ },
+ "maxEpisodesToKeep": {
+ "type": "integer",
+ "description": "The maximum number of episodes to keep."
+ },
+ "maxNewEpisodesToDownload": {
+ "type": "integer",
+ "description": "The maximum number of new episodes to download when automatically downloading epsiodes."
+ },
+ "lastCoverSearch": {
+ "type": "integer",
+ "description": "The timestamp of the last cover search.",
+ "nullable": true
+ },
+ "lastCoverSearchQuery": {
+ "type": "string",
+ "description": "The query used for the last cover search.",
+ "nullable": true
+ },
+ "size": {
+ "type": "integer",
+ "description": "The total size of all episodes in bytes."
+ },
+ "duration": {
+ "type": "integer",
+ "description": "The total duration of all episodes in seconds."
+ },
+ "numTracks": {
+ "type": "integer",
+ "description": "The number of tracks (episodes) in the podcast."
+ },
+ "latestEpisodePublished": {
+ "type": "integer",
+ "description": "The timestamp of the most recently published episode."
}
}
}
diff --git a/docs/root.yaml b/docs/root.yaml
index 4ac22abc17..4d6c055db7 100644
--- a/docs/root.yaml
+++ b/docs/root.yaml
@@ -53,6 +53,28 @@ paths:
$ref: './controllers/NotificationController.yaml#/paths/~1api~1notifications~1{id}'
/api/notifications/{id}/test:
$ref: './controllers/NotificationController.yaml#/paths/~1api~1notifications~1{id}~1test'
+ /api/podcasts:
+ $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts'
+ /api/podcasts/feed:
+ $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1feed'
+ /api/podcasts/opml/parse:
+ $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1opml~1parse'
+ /api/podcasts/opml/create:
+ $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1opml~1create'
+ /api/podcasts/{id}/checknew:
+ $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1{id}~1checknew'
+ /api/podcasts/{id}/clear-queue:
+ $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1{id}~1clear-queue'
+ /api/podcasts/{id}/downloads:
+ $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1{id}~1downloads'
+ /api/podcasts/{id}/search-episode:
+ $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1{id}~1search-episode'
+ /api/podcasts/{id}/download-episodes:
+ $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1{id}~1download-episodes'
+ /api/podcasts/{id}/match-episodes:
+ $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1{id}~1match-episodes'
+ /api/podcasts/{id}/episode/{episodeId}:
+ $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1{id}~1episode~1{episodeId}'
/api/series/{id}:
$ref: './controllers/SeriesController.yaml#/paths/~1api~1series~1{id}'
tags:
@@ -66,3 +88,5 @@ tags:
description: Email endpoints
- name: Notification
description: Notifications endpoints
+ - name: Podcasts
+ description: Podcast endpoints
diff --git a/docs/schemas.yaml b/docs/schemas.yaml
index 072895616d..e4e05e8090 100644
--- a/docs/schemas.yaml
+++ b/docs/schemas.yaml
@@ -20,6 +20,10 @@ components:
description: The total length (in seconds) of the item or file.
type: number
example: 33854.905
+ duration:
+ description: The total length of the item or file.
+ type: string
+ example: '01:23:45'
tags:
description: Tags applied to items.
type: array
diff --git a/package-lock.json b/package-lock.json
index e15c942fd1..168401a47f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
- "version": "2.11.0",
+ "version": "2.12.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
- "version": "2.11.0",
+ "version": "2.12.3",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",
diff --git a/package.json b/package.json
index c5c79b6bf8..c064288998 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
- "version": "2.11.0",
+ "version": "2.12.3",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
diff --git a/readme.md b/readme.md
index 0ff5541e30..ce2781ccd6 100644
--- a/readme.md
+++ b/readme.md
@@ -39,7 +39,7 @@ Audiobookshelf is a self-hosted audiobook and podcast server.
Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/audiobookshelf/issues/new/choose)
-Join us on [Discord](https://discord.gg/HQgCbd6E75) or [Matrix](https://matrix.to/#/#audiobookshelf:matrix.org)
+Join us on [Discord](https://discord.gg/HQgCbd6E75)
### Android App (beta)
@@ -47,7 +47,7 @@ Try it out on the [Google Play Store](https://play.google.com/store/apps/details
### iOS App (beta)
-**Beta is currently full. Apple has a hard limit of 10k beta testers. Updates will be posted in Discord/Matrix.**
+**Beta is currently full. Apple has a hard limit of 10k beta testers. Updates will be posted in Discord.**
Using Test Flight: https://testflight.apple.com/join/wiic7QIW **_(beta is full)_**
diff --git a/server/Auth.js b/server/Auth.js
index 827870b01b..3e61477b67 100644
--- a/server/Auth.js
+++ b/server/Auth.js
@@ -1,5 +1,6 @@
const axios = require('axios')
const passport = require('passport')
+const { Request, Response, NextFunction } = require('express')
const bcrypt = require('./libs/bcryptjs')
const jwt = require('./libs/jsonwebtoken')
const LocalStrategy = require('./libs/passportLocal')
@@ -13,7 +14,6 @@ const Logger = require('./Logger')
* @class Class for handling all the authentication related functionality.
*/
class Auth {
-
constructor() {
// Map of openId sessions indexed by oauth2 state-variable
this.openIdAuthSession = new Map()
@@ -24,40 +24,52 @@ class Auth {
*/
async initPassportJs() {
// Check if we should load the local strategy (username + password login)
- if (global.ServerSettings.authActiveAuthMethods.includes("local")) {
+ if (global.ServerSettings.authActiveAuthMethods.includes('local')) {
this.initAuthStrategyPassword()
}
// Check if we should load the openid strategy
- if (global.ServerSettings.authActiveAuthMethods.includes("openid")) {
+ if (global.ServerSettings.authActiveAuthMethods.includes('openid')) {
this.initAuthStrategyOpenID()
}
- // Load the JwtStrategy (always) -> for bearer token auth
- passport.use(new JwtStrategy({
- jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]),
- secretOrKey: Database.serverSettings.tokenSecret
- }, this.jwtAuthCheck.bind(this)))
+ // Load the JwtStrategy (always) -> for bearer token auth
+ passport.use(
+ new JwtStrategy(
+ {
+ jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]),
+ secretOrKey: Database.serverSettings.tokenSecret
+ },
+ this.jwtAuthCheck.bind(this)
+ )
+ )
// define how to seralize a user (to be put into the session)
passport.serializeUser(function (user, cb) {
process.nextTick(function () {
// only store id to session
- return cb(null, JSON.stringify({
- id: user.id,
- }))
+ return cb(
+ null,
+ JSON.stringify({
+ id: user.id
+ })
+ )
})
})
// define how to deseralize a user (use the ID to get it from the database)
- passport.deserializeUser((function (user, cb) {
- process.nextTick((async function () {
- const parsedUserInfo = JSON.parse(user)
- // load the user by ID that is stored in the session
- const dbUser = await Database.userModel.getUserById(parsedUserInfo.id)
- return cb(null, dbUser)
- }).bind(this))
- }).bind(this))
+ passport.deserializeUser(
+ function (user, cb) {
+ process.nextTick(
+ async function () {
+ const parsedUserInfo = JSON.parse(user)
+ // load the user by ID that is stored in the session
+ const dbUser = await Database.userModel.getUserById(parsedUserInfo.id)
+ return cb(null, dbUser)
+ }.bind(this)
+ )
+ }.bind(this)
+ )
}
/**
@@ -92,48 +104,56 @@ class Auth {
client_secret: global.ServerSettings.authOpenIDClientSecret,
id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm
})
- passport.use('openid-client', new OpenIDClient.Strategy({
- client: openIdClient,
- params: {
- redirect_uri: '/auth/openid/callback',
- scope: 'openid profile email'
- }
- }, async (tokenset, userinfo, done) => {
- try {
- Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2))
+ passport.use(
+ 'openid-client',
+ new OpenIDClient.Strategy(
+ {
+ client: openIdClient,
+ params: {
+ redirect_uri: '/auth/openid/callback',
+ scope: 'openid profile email'
+ }
+ },
+ async (tokenset, userinfo, done) => {
+ try {
+ Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2))
- if (!userinfo.sub) {
- throw new Error('Invalid userinfo, no sub')
- }
+ if (!userinfo.sub) {
+ throw new Error('Invalid userinfo, no sub')
+ }
- if (!this.validateGroupClaim(userinfo)) {
- throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)
- }
+ if (!this.validateGroupClaim(userinfo)) {
+ throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)
+ }
- let user = await this.findOrCreateUser(userinfo)
+ let user = await this.findOrCreateUser(userinfo)
- if (!user?.isActive) {
- throw new Error('User not active or not found')
- }
+ if (!user?.isActive) {
+ throw new Error('User not active or not found')
+ }
- await this.setUserGroup(user, userinfo)
- await this.updateUserPermissions(user, userinfo)
+ await this.setUserGroup(user, userinfo)
+ await this.updateUserPermissions(user, userinfo)
- // We also have to save the id_token for later (used for logout) because we cannot set cookies here
- user.openid_id_token = tokenset.id_token
+ // We also have to save the id_token for later (used for logout) because we cannot set cookies here
+ user.openid_id_token = tokenset.id_token
- return done(null, user)
- } catch (error) {
- Logger.error(`[Auth] openid callback error: ${error?.message}\n${error?.stack}`)
+ return done(null, user)
+ } catch (error) {
+ Logger.error(`[Auth] openid callback error: ${error?.message}\n${error?.stack}`)
- return done(null, null, 'Unauthorized')
- }
- }))
+ return done(null, null, 'Unauthorized')
+ }
+ }
+ )
+ )
}
/**
* Finds an existing user by OpenID subject identifier, or by email/username based on server settings,
* or creates a new user if configured to do so.
+ *
+ * @returns {import('./models/User')|null}
*/
async findOrCreateUser(userinfo) {
let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub)
@@ -181,7 +201,6 @@ class Auth {
return null
}
-
user = await Database.userModel.getUserByUsername(username)
if (user?.authOpenIDSub) {
@@ -197,8 +216,11 @@ class Auth {
return null
}
- user.authOpenIDSub = userinfo.sub
- await Database.userModel.updateFromOld(user)
+ // Update user with OpenID sub
+ if (!user.extraData) user.extraData = {}
+ user.extraData.authOpenIDSub = userinfo.sub
+ user.changed('extraData', true)
+ await user.save()
Logger.debug(`[Auth] openid: User found by email/username`)
return user
@@ -220,7 +242,8 @@ class Auth {
*/
validateGroupClaim(userinfo) {
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
- if (!groupClaimName) // Allow no group claim when configured like this
+ if (!groupClaimName)
+ // Allow no group claim when configured like this
return true
// If configured it must exist in userinfo
@@ -232,22 +255,22 @@ class Auth {
/**
* Sets the user group based on group claim in userinfo.
- *
+ *
* @param {import('./objects/user/User')} user
* @param {Object} userinfo
*/
async setUserGroup(user, userinfo) {
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
- if (!groupClaimName) // No group claim configured, don't set anything
+ if (!groupClaimName)
+ // No group claim configured, don't set anything
return
- if (!userinfo[groupClaimName])
- throw new Error(`Group claim ${groupClaimName} not found in userinfo`)
+ if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`)
- const groupsList = userinfo[groupClaimName].map(group => group.toLowerCase())
+ const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase())
const rolesInOrderOfPriority = ['admin', 'user', 'guest']
- let userType = rolesInOrderOfPriority.find(role => groupsList.includes(role))
+ let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role))
if (userType) {
if (user.type === 'root') {
// Check OpenID Group
@@ -271,32 +294,30 @@ class Auth {
/**
* Updates user permissions based on the advanced permissions claim.
- *
+ *
* @param {import('./objects/user/User')} user
* @param {Object} userinfo
*/
async updateUserPermissions(user, userinfo) {
const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim
- if (!absPermissionsClaim) // No advanced permissions claim configured, don't set anything
+ if (!absPermissionsClaim)
+ // No advanced permissions claim configured, don't set anything
return
- if (user.type === 'admin' || user.type === 'root')
- return
+ if (user.type === 'admin' || user.type === 'root') return
const absPermissions = userinfo[absPermissionsClaim]
- if (!absPermissions)
- throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`)
+ if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`)
- if (user.updatePermissionsFromExternalJSON(absPermissions)) {
+ if (await user.updatePermissionsFromExternalJSON(absPermissions)) {
Logger.info(`[Auth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`)
- await Database.userModel.updateFromOld(user)
}
}
/**
* Unuse strategy
- *
- * @param {string} name
+ *
+ * @param {string} name
*/
unuseAuthStrategy(name) {
passport.unuse(name)
@@ -304,8 +325,8 @@ class Auth {
/**
* Use strategy
- *
- * @param {string} name
+ *
+ * @param {string} name
*/
useAuthStrategy(name) {
if (name === 'openid') {
@@ -335,9 +356,9 @@ class Auth {
* - 'api': Authentication for API use
* - 'openid': OpenID authentication directly over web
* - 'openid-mobile': OpenID authentication, but done via an mobile device
- *
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {Request} req
+ * @param {Response} res
* @param {string} authMethod - The authentication method, default is 'local'.
*/
paramsToCookies(req, res, authMethod = 'local') {
@@ -365,9 +386,9 @@ class Auth {
/**
* Informs the client in the right mode about a successfull login and the token
* (clients choise is restored from cookies).
- *
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {Request} req
+ * @param {Response} res
*/
async handleLoginSuccessBasedOnCookie(req, res) {
// get userLogin json (information about the user, server and the session)
@@ -391,8 +412,8 @@ class Auth {
/**
* Creates all (express) routes required for authentication.
- *
- * @param {import('express').Router} router
+ *
+ * @param {import('express').Router} router
*/
async initAuthRoutes(router) {
// Local strategy login route (takes username and password)
@@ -422,7 +443,7 @@ class Auth {
}
// Generate a state on web flow or if no state supplied
- const state = (!isMobileFlow || !req.query.state) ? OpenIDClient.generators.random() : req.query.state
+ const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state
// Redirect URL for the SSO provider
let redirectUri
@@ -508,8 +529,7 @@ class Auth {
function isValidRedirectUri(uri) {
// Check if the redirect_uri is in the whitelist
- return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) ||
- (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')
+ return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')
}
})
@@ -545,77 +565,79 @@ class Auth {
})
// openid strategy callback route (this receives the token from the configured openid login provider)
- router.get('/auth/openid/callback', (req, res, next) => {
- const oidcStrategy = passport._strategy('openid-client')
- const sessionKey = oidcStrategy._key
-
- if (!req.session[sessionKey]) {
- return res.status(400).send('No session')
- }
-
- // If the client sends us a code_verifier, we will tell passport to use this to send this in the token request
- // The code_verifier will be validated by the oauth2 provider by comparing it to the code_challenge in the first request
- // Crucial for API/Mobile clients
- if (req.query.code_verifier) {
- req.session[sessionKey].code_verifier = req.query.code_verifier
- }
-
- function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) {
- Logger.error(JSON.stringify(logMessage, null, 2))
- if (response) {
- // Depending on the error, it can also have a body
- // We also log the request header the passport plugin sents for the URL
- const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED')
- Logger.debug(header + '\n' + JSON.stringify(response.body, null, 2))
+ router.get(
+ '/auth/openid/callback',
+ (req, res, next) => {
+ const oidcStrategy = passport._strategy('openid-client')
+ const sessionKey = oidcStrategy._key
+
+ if (!req.session[sessionKey]) {
+ return res.status(400).send('No session')
}
- if (isMobile) {
- return res.status(errorCode).send(errorMessage)
- } else {
- return res.redirect(`/login?error=${encodeURIComponent(errorMessage)}&autoLaunch=0`)
+ // If the client sends us a code_verifier, we will tell passport to use this to send this in the token request
+ // The code_verifier will be validated by the oauth2 provider by comparing it to the code_challenge in the first request
+ // Crucial for API/Mobile clients
+ if (req.query.code_verifier) {
+ req.session[sessionKey].code_verifier = req.query.code_verifier
}
- }
- function passportCallback(req, res, next) {
- return (err, user, info) => {
- const isMobile = req.session[sessionKey]?.mobile === true
- if (err) {
- return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response)
+ function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) {
+ Logger.error(JSON.stringify(logMessage, null, 2))
+ if (response) {
+ // Depending on the error, it can also have a body
+ // We also log the request header the passport plugin sents for the URL
+ const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED')
+ Logger.debug(header + '\n' + JSON.stringify(response.body, null, 2))
}
- if (!user) {
- // Info usually contains the error message from the SSO provider
- return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No data in openid callback - ${info}`, info?.response)
+ if (isMobile) {
+ return res.status(errorCode).send(errorMessage)
+ } else {
+ return res.redirect(`/login?error=${encodeURIComponent(errorMessage)}&autoLaunch=0`)
}
+ }
- req.logIn(user, (loginError) => {
- if (loginError) {
- return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`)
+ function passportCallback(req, res, next) {
+ return (err, user, info) => {
+ const isMobile = req.session[sessionKey]?.mobile === true
+ if (err) {
+ return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response)
}
- // The id_token does not provide access to the user, but is used to identify the user to the SSO provider
- // instead it containts a JWT with userinfo like user email, username, etc.
- // the client will get to know it anyway in the logout url according to the oauth2 spec
- // so it is safe to send it to the client, but we use strict settings
- res.cookie('openid_id_token', user.openid_id_token, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true, secure: true, sameSite: 'Strict' })
- next()
- })
- }
- }
+ if (!user) {
+ // Info usually contains the error message from the SSO provider
+ return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No data in openid callback - ${info}`, info?.response)
+ }
+ req.logIn(user, (loginError) => {
+ if (loginError) {
+ return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`)
+ }
+
+ // The id_token does not provide access to the user, but is used to identify the user to the SSO provider
+ // instead it containts a JWT with userinfo like user email, username, etc.
+ // the client will get to know it anyway in the logout url according to the oauth2 spec
+ // so it is safe to send it to the client, but we use strict settings
+ res.cookie('openid_id_token', user.openid_id_token, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true, secure: true, sameSite: 'Strict' })
+ next()
+ })
+ }
+ }
- // While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request
- // We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided
- // We set it here again because the passport param can change between requests
- return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(req, res, next)
- },
+ // While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request
+ // We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided
+ // We set it here again because the passport param can change between requests
+ return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(req, res, next)
+ },
// on a successfull login: read the cookies and react like the client requested (callback or json)
- this.handleLoginSuccessBasedOnCookie.bind(this))
+ this.handleLoginSuccessBasedOnCookie.bind(this)
+ )
/**
* Helper route used to auto-populate the openid URLs in config/authentication
* Takes an issuer URL as a query param and requests the config data at "/.well-known/openid-configuration"
- *
+ *
* @example /auth/openid/config?issuer=http://192.168.1.66:9000/application/o/audiobookshelf/
*/
router.get('/auth/openid/config', this.isAuthenticated, async (req, res) => {
@@ -625,7 +647,7 @@ class Auth {
}
if (!req.query.issuer) {
- return res.status(400).send('Invalid request. Query param \'issuer\' is required')
+ return res.status(400).send("Invalid request. Query param 'issuer' is required")
}
// Strip trailing slash
@@ -641,23 +663,26 @@ class Auth {
}
} catch (error) {
Logger.error(`[Auth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error)
- return res.status(400).send('Invalid request. Query param \'issuer\' is invalid')
+ return res.status(400).send("Invalid request. Query param 'issuer' is invalid")
}
- axios.get(configUrl.toString()).then(({ data }) => {
- res.json({
- issuer: data.issuer,
- authorization_endpoint: data.authorization_endpoint,
- token_endpoint: data.token_endpoint,
- userinfo_endpoint: data.userinfo_endpoint,
- end_session_endpoint: data.end_session_endpoint,
- jwks_uri: data.jwks_uri,
- id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported
+ axios
+ .get(configUrl.toString())
+ .then(({ data }) => {
+ res.json({
+ issuer: data.issuer,
+ authorization_endpoint: data.authorization_endpoint,
+ token_endpoint: data.token_endpoint,
+ userinfo_endpoint: data.userinfo_endpoint,
+ end_session_endpoint: data.end_session_endpoint,
+ jwks_uri: data.jwks_uri,
+ id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported
+ })
+ })
+ .catch((error) => {
+ Logger.error(`[Auth] Failed to get openid configuration at "${configUrl}"`, error)
+ res.status(error.statusCode || 400).send(`${error.code || 'UNKNOWN'}: Failed to get openid configuration`)
})
- }).catch((error) => {
- Logger.error(`[Auth] Failed to get openid configuration at "${configUrl}"`, error)
- res.status(error.statusCode || 400).send(`${error.code || 'UNKNOWN'}: Failed to get openid configuration`)
- })
})
// Logout route
@@ -683,7 +708,7 @@ class Auth {
let postLogoutRedirectUri = null
if (authMethod === 'openid') {
- const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http'
+ const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
const host = req.get('host')
// TODO: ABS does currently not support subfolders for installation
// If we want to support it we need to include a config for the serverurl
@@ -717,9 +742,9 @@ class Auth {
/**
* middleware to use in express to only allow authenticated users.
- * @param {import('express').Request} req
- * @param {import('express').Response} res
- * @param {import('express').NextFunction} next
+ * @param {Request} req
+ * @param {Response} res
+ * @param {NextFunction} next
*/
isAuthenticated(req, res, next) {
// check if session cookie says that we are authenticated
@@ -727,14 +752,14 @@ class Auth {
next()
} else {
// try JWT to authenticate
- passport.authenticate("jwt")(req, res, next)
+ passport.authenticate('jwt')(req, res, next)
}
}
/**
* Function to generate a jwt token for a given user
- *
- * @param {{ id:string, username:string }} user
+ *
+ * @param {{ id:string, username:string }} user
* @returns {string} token
*/
generateAccessToken(user) {
@@ -743,15 +768,14 @@ class Auth {
/**
* Function to validate a jwt token for a given user
- *
- * @param {string} token
+ *
+ * @param {string} token
* @returns {Object} tokens data
*/
static validateAccessToken(token) {
try {
return jwt.verify(token, global.ServerSettings.tokenSecret)
- }
- catch (err) {
+ } catch (err) {
return null
}
}
@@ -760,7 +784,8 @@ class Auth {
* Generate a token which is used to encrpt/protect the jwts.
*/
async initTokenSecret() {
- if (process.env.TOKEN_SECRET) { // User can supply their own token secret
+ if (process.env.TOKEN_SECRET) {
+ // User can supply their own token secret
Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET
} else {
Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')
@@ -768,19 +793,21 @@ class Auth {
await Database.updateServerSettings()
// New token secret creation added in v2.1.0 so generate new API tokens for each user
- const users = await Database.userModel.getOldUsers()
+ const users = await Database.userModel.findAll({
+ attributes: ['id', 'username', 'token']
+ })
if (users.length) {
for (const user of users) {
user.token = await this.generateAccessToken(user)
+ await user.save({ hooks: false })
}
- await Database.updateBulkUsers(users)
}
}
/**
* Checks if the user in the validated jwt_payload really exists and is active.
- * @param {Object} jwt_payload
- * @param {function} done
+ * @param {Object} jwt_payload
+ * @param {function} done
*/
async jwtAuthCheck(jwt_payload, done) {
// load user by id from the jwt token
@@ -798,9 +825,9 @@ class Auth {
/**
* Checks if a username and password tuple is valid and the user active.
- * @param {string} username
- * @param {string} password
- * @param {Promise
} done
+ * @param {string} username
+ * @param {string} password
+ * @param {Promise} done
*/
async localAuthCheckUserPw(username, password, done) {
// Load the user given it's username
@@ -841,8 +868,8 @@ class Auth {
/**
* Hashes a password with bcrypt.
- * @param {string} password
- * @returns {Promise} hash
+ * @param {string} password
+ * @returns {Promise} hash
*/
hashPass(password) {
return new Promise((resolve) => {
@@ -858,14 +885,14 @@ class Auth {
/**
* Return the login info payload for a user
- *
- * @param {Object} user
+ *
+ * @param {import('./models/User')} user
* @returns {Promise} jsonPayload
*/
async getUserLoginResponsePayload(user) {
const libraryIds = await Database.libraryModel.getAllLibraryIds()
return {
- user: user.toJSONForBrowser(),
+ user: user.toOldJSONForBrowser(),
userDefaultLibraryId: user.getDefaultLibraryId(libraryIds),
serverSettings: Database.serverSettings.toJSONForBrowser(),
ereaderDevices: Database.emailSettings.getEReaderDevices(user),
@@ -874,9 +901,9 @@ class Auth {
}
/**
- *
- * @param {string} password
- * @param {import('./models/User')} user
+ *
+ * @param {string} password
+ * @param {import('./models/User')} user
* @returns {Promise}
*/
comparePassword(password, user) {
@@ -887,9 +914,10 @@ class Auth {
/**
* User changes their password from request
- *
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * TODO: Update responses to use error status codes
+ *
+ * @param {import('./controllers/MeController').RequestWithUser} req
+ * @param {Response} res
*/
async userChangePassword(req, res) {
let { password, newPassword } = req.body
@@ -921,20 +949,28 @@ class Auth {
}
}
- matchingUser.pash = pw
-
- const success = await Database.updateUser(matchingUser)
- if (success) {
- Logger.info(`[Auth] User "${matchingUser.username}" changed password`)
- res.json({
- success: true
+ Database.userModel
+ .update(
+ {
+ pash: pw
+ },
+ {
+ where: { id: matchingUser.id }
+ }
+ )
+ .then(() => {
+ Logger.info(`[Auth] User "${matchingUser.username}" changed password`)
+ res.json({
+ success: true
+ })
})
- } else {
- res.json({
- error: 'Unknown error'
+ .catch((error) => {
+ Logger.error(`[Auth] User "${matchingUser.username}" failed to change password`, error)
+ res.json({
+ error: 'Unknown error'
+ })
})
- }
}
}
-module.exports = Auth
\ No newline at end of file
+module.exports = Auth
diff --git a/server/Database.js b/server/Database.js
index 1274bb4bdb..4d55c72834 100644
--- a/server/Database.js
+++ b/server/Database.js
@@ -215,6 +215,34 @@ class Database {
}
}
+ /**
+ * TODO: Temporarily disabled
+ * @param {string[]} extensions paths to extension binaries
+ */
+ async loadExtensions(extensions) {
+ // This is a hack to get the db connection for loading extensions.
+ // The proper way would be to use the 'afterConnect' hook, but that hook is never called for sqlite due to a bug in sequelize.
+ // See https://github.com/sequelize/sequelize/issues/12487
+ // This is not a public API and may break in the future.
+ const db = await this.sequelize.dialect.connectionManager.getConnection()
+ if (typeof db?.loadExtension !== 'function') throw new Error('Failed to get db connection for loading extensions')
+
+ for (const ext of extensions) {
+ Logger.info(`[Database] Loading extension ${ext}`)
+ await new Promise((resolve, reject) => {
+ db.loadExtension(ext, (err) => {
+ if (err) {
+ Logger.error(`[Database] Failed to load extension ${ext}`, err)
+ reject(err)
+ return
+ }
+ Logger.info(`[Database] Successfully loaded extension ${ext}`)
+ resolve()
+ })
+ })
+ }
+ }
+
/**
* Disconnect from db
*/
@@ -335,7 +363,7 @@ class Database {
*/
async createRootUser(username, pash, auth) {
if (!this.sequelize) return false
- await this.models.user.createRootUser(username, pash, auth)
+ await this.userModel.createRootUser(username, pash, auth)
this.hasRootUser = true
return true
}
@@ -362,26 +390,6 @@ class Database {
return this.models.user.updateFromOld(oldUser)
}
- updateBulkUsers(oldUsers) {
- if (!this.sequelize) return false
- return Promise.all(oldUsers.map((u) => this.updateUser(u)))
- }
-
- removeUser(userId) {
- if (!this.sequelize) return false
- return this.models.user.removeById(userId)
- }
-
- upsertMediaProgress(oldMediaProgress) {
- if (!this.sequelize) return false
- return this.models.mediaProgress.upsertFromOld(oldMediaProgress)
- }
-
- removeMediaProgress(mediaProgressId) {
- if (!this.sequelize) return false
- return this.models.mediaProgress.removeById(mediaProgressId)
- }
-
updateBulkBooks(oldBooks) {
if (!this.sequelize) return false
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
@@ -440,11 +448,6 @@ class Database {
return updated
}
- async removeLibraryItem(libraryItemId) {
- if (!this.sequelize) return false
- await this.models.libraryItem.removeById(libraryItemId)
- }
-
async createFeed(oldFeed) {
if (!this.sequelize) return false
await this.models.feed.fullCreateFromOld(oldFeed)
@@ -801,6 +804,39 @@ class Database {
Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`)
}
}
+
+ /**
+ * TODO: Temporarily unused
+ * @param {string} value
+ * @returns {string}
+ */
+ normalize(value) {
+ return `lower(unaccent(${value}))`
+ }
+
+ /**
+ * TODO: Temporarily unused
+ * @param {string} query
+ * @returns {Promise}
+ */
+ async getNormalizedQuery(query) {
+ const escapedQuery = this.sequelize.escape(query)
+ const normalizedQuery = this.normalize(escapedQuery)
+ const normalizedQueryResult = await this.sequelize.query(`SELECT ${normalizedQuery} as normalized_query`)
+ return normalizedQueryResult[0][0].normalized_query
+ }
+
+ /**
+ *
+ * @param {string} column
+ * @param {string} normalizedQuery
+ * @returns {string}
+ */
+ matchExpression(column, normalizedQuery) {
+ const normalizedPattern = this.sequelize.escape(`%${normalizedQuery}%`)
+ const normalizedColumn = column
+ return `${normalizedColumn} LIKE ${normalizedPattern}`
+ }
}
module.exports = new Database()
diff --git a/server/Server.js b/server/Server.js
index 76d8466db5..f1cfc7f43f 100644
--- a/server/Server.js
+++ b/server/Server.js
@@ -89,6 +89,13 @@ class Server {
this.io = null
}
+ /**
+ * Middleware to check if the current request is authenticated
+ *
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res
+ * @param {import('express').NextFunction} next
+ */
authMiddleware(req, res, next) {
// ask passportjs if the current request is authenticated
this.auth.isAuthenticated(req, res, next)
@@ -105,9 +112,20 @@ class Server {
async init() {
Logger.info('[Server] Init v' + version)
Logger.info('[Server] Node.js Version:', process.version)
+ Logger.info('[Server] Platform:', process.platform)
+ Logger.info('[Server] Arch:', process.arch)
await this.playbackSessionManager.removeOrphanStreams()
+ /**
+ * Docker container ffmpeg/ffprobe binaries are included in the image.
+ * Docker is currently using ffmpeg/ffprobe v6.1 instead of v5.1 so skipping the check
+ * TODO: Support binary check for all sources
+ */
+ if (global.Source !== 'docker') {
+ await this.binaryManager.init()
+ }
+
await Database.init(false)
await Logger.logManager.init()
@@ -128,11 +146,6 @@ class Server {
await this.cronManager.init(libraries)
this.apiCacheManager.init()
- // Download ffmpeg & ffprobe if not found (Currently only in use for Windows installs)
- if (global.isWin || Logger.isDev) {
- await this.binaryManager.init()
- }
-
if (Database.serverSettings.scannerDisableWatcher) {
Logger.info(`[Server] Watcher is disabled`)
this.watcher.disabled = true
@@ -285,6 +298,7 @@ class Server {
'/library/:library/bookshelf/:id?',
'/library/:library/authors',
'/library/:library/narrators',
+ '/library/:library/stats',
'/library/:library/series/:id?',
'/library/:library/podcast/search',
'/library/:library/podcast/latest',
@@ -385,31 +399,24 @@ class Server {
}
// Remove series from hide from continue listening that no longer exist
- const users = await Database.userModel.getOldUsers()
- for (const _user of users) {
- let hasUpdated = false
- if (_user.seriesHideFromContinueListening.length) {
- const seriesHiding = (
- await Database.seriesModel.findAll({
- where: {
- id: _user.seriesHideFromContinueListening
- },
- attributes: ['id'],
- raw: true
- })
- ).map((se) => se.id)
- _user.seriesHideFromContinueListening = _user.seriesHideFromContinueListening.filter((seriesId) => {
- if (!seriesHiding.includes(seriesId)) {
- // Series removed
- hasUpdated = true
- return false
- }
- return true
- })
- }
- if (hasUpdated) {
- await Database.updateUser(_user)
+ try {
+ const users = await Database.sequelize.query(`SELECT u.id, u.username, u.extraData, json_group_array(value) AS seriesIdsToRemove FROM users u, json_each(u.extraData->"seriesHideFromContinueListening") LEFT JOIN series se ON se.id = value WHERE se.id IS NULL GROUP BY u.id;`, {
+ model: Database.userModel,
+ type: Sequelize.QueryTypes.SELECT
+ })
+ for (const user of users) {
+ const extraData = JSON.parse(user.extraData)
+ const existingSeriesIds = extraData.seriesHideFromContinueListening
+ const seriesIdsToRemove = JSON.parse(user.dataValues.seriesIdsToRemove)
+ Logger.info(`[Server] Found ${seriesIdsToRemove.length} non-existent series in seriesHideFromContinueListening for user "${user.username}" - Removing (${seriesIdsToRemove.join(',')})`)
+ const newExtraData = {
+ ...extraData,
+ seriesHideFromContinueListening: existingSeriesIds.filter((s) => !seriesIdsToRemove.includes(s))
+ }
+ await user.update({ extraData: newExtraData })
}
+ } catch (error) {
+ Logger.error(`[Server] Failed to cleanup users seriesHideFromContinueListening`, error)
}
}
diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js
index 930037a84c..af8204c606 100644
--- a/server/SocketAuthority.js
+++ b/server/SocketAuthority.js
@@ -3,11 +3,20 @@ const Logger = require('./Logger')
const Database = require('./Database')
const Auth = require('./Auth')
+/**
+ * @typedef SocketClient
+ * @property {string} id socket id
+ * @property {SocketIO.Socket} socket
+ * @property {number} connected_at
+ * @property {import('./models/User')} user
+ */
+
class SocketAuthority {
constructor() {
this.Server = null
this.io = null
+ /** @type {Object.} */
this.clients = {}
}
@@ -18,27 +27,29 @@ class SocketAuthority {
*/
getUsersOnline() {
const onlineUsersMap = {}
- Object.values(this.clients).filter(c => c.user).forEach(client => {
- if (onlineUsersMap[client.user.id]) {
- onlineUsersMap[client.user.id].connections++
- } else {
- onlineUsersMap[client.user.id] = {
- ...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions),
- connections: 1
+ Object.values(this.clients)
+ .filter((c) => c.user)
+ .forEach((client) => {
+ if (onlineUsersMap[client.user.id]) {
+ onlineUsersMap[client.user.id].connections++
+ } else {
+ onlineUsersMap[client.user.id] = {
+ ...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions),
+ connections: 1
+ }
}
- }
- })
+ })
return Object.values(onlineUsersMap)
}
getClientsForUser(userId) {
- return Object.values(this.clients).filter(c => c.user && c.user.id === userId)
+ return Object.values(this.clients).filter((c) => c.user?.id === userId)
}
/**
* Emits event to all authorized clients
- * @param {string} evt
- * @param {any} data
+ * @param {string} evt
+ * @param {any} data
* @param {Function} [filter] optional filter function to only send event to specific users
*/
emitter(evt, data, filter = null) {
@@ -67,7 +78,7 @@ class SocketAuthority {
// Emits event to all admin user clients
adminEmitter(evt, data) {
for (const socketId in this.clients) {
- if (this.clients[socketId].user && this.clients[socketId].user.isAdminOrUp) {
+ if (this.clients[socketId].user?.isAdminOrUp) {
this.clients[socketId].socket.emit(evt, data)
}
}
@@ -75,16 +86,14 @@ class SocketAuthority {
/**
* Closes the Socket.IO server and disconnect all clients
- *
- * @param {Function} callback
+ *
+ * @param {Function} callback
*/
close(callback) {
Logger.info('[SocketAuthority] Shutting down')
// This will close all open socket connections, and also close the underlying http server
- if (this.io)
- this.io.close(callback)
- else
- callback()
+ if (this.io) this.io.close(callback)
+ else callback()
}
initialize(Server) {
@@ -93,7 +102,7 @@ class SocketAuthority {
this.io = new SocketIO.Server(this.Server.server, {
cors: {
origin: '*',
- methods: ["GET", "POST"]
+ methods: ['GET', 'POST']
}
})
@@ -144,7 +153,7 @@ class SocketAuthority {
// admin user can send a message to all authenticated users
// displays on the web app as a toast
const client = this.clients[socket.id] || {}
- if (client.user && client.user.isAdminOrUp) {
+ if (client.user?.isAdminOrUp) {
this.emitter('admin_message', payload.message || '')
} else {
Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`)
@@ -162,8 +171,8 @@ class SocketAuthority {
/**
* When setting up a socket connection the user needs to be associated with a socket id
* for this the client will send a 'auth' event that includes the users API token
- *
- * @param {SocketIO.Socket} socket
+ *
+ * @param {SocketIO.Socket} socket
* @param {string} token JWT
*/
async authenticateSocket(socket, token) {
@@ -176,6 +185,7 @@ class SocketAuthority {
Logger.error('Cannot validate socket - invalid token')
return socket.emit('invalid_token')
}
+
// get the user via the id from the decoded jwt.
const user = await Database.userModel.getUserByIdOrOldId(token_data.userId)
if (!user) {
@@ -196,18 +206,13 @@ class SocketAuthority {
client.user = user
- if (!client.user.toJSONForBrowser) {
- Logger.error('Invalid user...', client.user)
- return
- }
-
Logger.debug(`[SocketAuthority] User Online ${client.user.username}`)
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
// Update user lastSeen without firing sequelize bulk update hooks
user.lastSeen = Date.now()
- await Database.userModel.updateFromOld(user, false)
+ await user.save({ hooks: false })
const initialPayload = {
userId: client.user.id,
@@ -224,4 +229,4 @@ class SocketAuthority {
this.Server.cancelLibraryScan(id)
}
}
-module.exports = new SocketAuthority()
\ No newline at end of file
+module.exports = new SocketAuthority()
diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js
index 57eebb4316..22b11b3c3e 100644
--- a/server/controllers/AuthorController.js
+++ b/server/controllers/AuthorController.js
@@ -1,3 +1,4 @@
+const { Request, Response, NextFunction } = require('express')
const sequelize = require('sequelize')
const fs = require('../libs/fsExtra')
const { createNewSortInstance } = require('../libs/fastSort')
@@ -14,9 +15,23 @@ const { reqSupportsWebp, isValidASIN } = require('../utils/index')
const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
})
+
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
+
class AuthorController {
constructor() {}
+ /**
+ * GET: /api/authors/:id
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async findOne(req, res) {
const include = (req.query.include || '').split(',')
@@ -63,9 +78,10 @@ class AuthorController {
}
/**
+ * PATCH: /api/authors/:id
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async update(req, res) {
const payload = req.body
@@ -194,8 +210,8 @@ class AuthorController {
* DELETE: /api/authors/:id
* Remove author from all books and delete
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async delete(req, res) {
Logger.info(`[AuthorController] Removing author "${req.author.name}"`)
@@ -218,12 +234,12 @@ class AuthorController {
* POST: /api/authors/:id/image
* Upload author image from web URL
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async uploadImage(req, res) {
if (!req.user.canUpload) {
- Logger.warn('User attempted to upload an image without permission', req.user)
+ Logger.warn(`User "${req.user.username}" attempted to upload an image without permission`)
return res.sendStatus(403)
}
if (!req.body.url) {
@@ -263,8 +279,8 @@ class AuthorController {
* DELETE: /api/authors/:id/image
* Remove author image & delete image file
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async deleteImage(req, res) {
if (!req.author.imagePath) {
@@ -284,6 +300,12 @@ class AuthorController {
})
}
+ /**
+ * POST: /api/authors/:id/match
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async match(req, res) {
let authorData = null
const region = req.body.region || 'us'
@@ -334,7 +356,12 @@ class AuthorController {
})
}
- // GET api/authors/:id/image
+ /**
+ * GET: /api/authors/:id/image
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async getImage(req, res) {
const {
query: { width, height, format, raw },
@@ -358,15 +385,21 @@ class AuthorController {
return CacheManager.handleAuthorCache(res, author, options)
}
+ /**
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ * @param {NextFunction} next
+ */
async middleware(req, res, next) {
const author = await Database.authorModel.getOldById(req.params.id)
if (!author) return res.sendStatus(404)
if (req.method == 'DELETE' && !req.user.canDelete) {
- Logger.warn(`[AuthorController] User attempted to delete without permission`, req.user)
+ Logger.warn(`[AuthorController] User "${req.user.username}" attempted to delete without permission`)
return res.sendStatus(403)
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
- Logger.warn('[AuthorController] User attempted to update without permission', req.user)
+ Logger.warn(`[AuthorController] User "${req.user.username}" attempted to update without permission`)
return res.sendStatus(403)
}
diff --git a/server/controllers/BackupController.js b/server/controllers/BackupController.js
index df33aa1d80..317827d09e 100644
--- a/server/controllers/BackupController.js
+++ b/server/controllers/BackupController.js
@@ -1,12 +1,28 @@
+const { Request, Response, NextFunction } = require('express')
const Path = require('path')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const Database = require('../Database')
const fileUtils = require('../utils/fileUtils')
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
+
class BackupController {
constructor() {}
+ /**
+ * GET: /api/backups
+ *
+ * @this import('../routers/ApiRouter')
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
getAll(req, res) {
res.json({
backups: this.backupManager.backups.map((b) => b.toJSON()),
@@ -15,10 +31,26 @@ class BackupController {
})
}
+ /**
+ * POST: /api/backups
+ *
+ * @this import('../routers/ApiRouter')
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
create(req, res) {
this.backupManager.requestCreateBackup(res)
}
+ /**
+ * DELETE: /api/backups/:id
+ *
+ * @this import('../routers/ApiRouter')
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async delete(req, res) {
await this.backupManager.removeBackup(req.backup)
@@ -27,6 +59,14 @@ class BackupController {
})
}
+ /**
+ * POST: /api/backups/upload
+ *
+ * @this import('../routers/ApiRouter')
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
upload(req, res) {
if (!req.files.file) {
Logger.error('[BackupController] Upload backup invalid')
@@ -41,8 +81,8 @@ class BackupController {
*
* @this import('../routers/ApiRouter')
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async updatePath(req, res) {
// Validate path is not empty and is a string
@@ -86,10 +126,10 @@ class BackupController {
}
/**
- * api/backups/:id/download
+ * GET: /api/backups/:id/download
*
- * @param {*} req
- * @param {*} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
download(req, res) {
if (global.XAccel) {
@@ -104,17 +144,26 @@ class BackupController {
}
/**
+ * GET: /api/backups/:id/apply
+ *
+ * @this import('../routers/ApiRouter')
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
apply(req, res) {
this.backupManager.requestApplyBackup(this.apiCacheManager, req.backup, res)
}
+ /**
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ * @param {NextFunction} next
+ */
middleware(req, res, next) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[BackupController] Non-admin user attempting to access backups`, req.user)
+ Logger.error(`[BackupController] Non-admin user "${req.user.username}" attempting to access backups`)
return res.sendStatus(403)
}
diff --git a/server/controllers/CacheController.js b/server/controllers/CacheController.js
index 95c5fe0c87..92dbbd5f6f 100644
--- a/server/controllers/CacheController.js
+++ b/server/controllers/CacheController.js
@@ -1,9 +1,22 @@
+const { Request, Response } = require('express')
const CacheManager = require('../managers/CacheManager')
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
+
class CacheController {
- constructor() { }
+ constructor() {}
- // POST: api/cache/purge
+ /**
+ * POST: /api/cache/purge
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async purgeCache(req, res) {
if (!req.user.isAdminOrUp) {
return res.sendStatus(403)
@@ -12,7 +25,12 @@ class CacheController {
res.sendStatus(200)
}
- // POST: api/cache/items/purge
+ /**
+ * POST: /api/cache/items/purge
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async purgeItemsCache(req, res) {
if (!req.user.isAdminOrUp) {
return res.sendStatus(403)
@@ -21,4 +39,4 @@ class CacheController {
res.sendStatus(200)
}
}
-module.exports = new CacheController()
\ No newline at end of file
+module.exports = new CacheController()
diff --git a/server/controllers/CollectionController.js b/server/controllers/CollectionController.js
index 5357a5dcd5..708c00b5fc 100644
--- a/server/controllers/CollectionController.js
+++ b/server/controllers/CollectionController.js
@@ -1,3 +1,4 @@
+const { Request, Response, NextFunction } = require('express')
const Sequelize = require('sequelize')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
@@ -5,14 +6,22 @@ const Database = require('../Database')
const Collection = require('../objects/Collection')
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
+
class CollectionController {
- constructor() { }
+ constructor() {}
/**
* POST: /api/collections
* Create new collection
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async create(req, res) {
const newCollection = new Collection()
@@ -31,7 +40,7 @@ class CollectionController {
let order = 1
const collectionBooksToAdd = []
for (const libraryItemId of newCollection.books) {
- const libraryItem = libraryItemsInCollection.find(li => li.id === libraryItemId)
+ const libraryItem = libraryItemsInCollection.find((li) => li.id === libraryItemId)
if (libraryItem) {
collectionBooksToAdd.push({
collectionId: newCollection.id,
@@ -49,6 +58,12 @@ class CollectionController {
res.json(jsonExpanded)
}
+ /**
+ * GET: /api/collections
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async findAll(req, res) {
const collectionsExpanded = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user)
res.json({
@@ -56,6 +71,12 @@ class CollectionController {
})
}
+ /**
+ * GET: /api/collections/:id
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async findOne(req, res) {
const includeEntities = (req.query.include || '').split(',')
@@ -71,8 +92,9 @@ class CollectionController {
/**
* PATCH: /api/collections/:id
* Update collection
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async update(req, res) {
let wasUpdated = false
@@ -102,8 +124,8 @@ class CollectionController {
order: [['order', 'ASC']]
})
collectionBooks.sort((a, b) => {
- const aIndex = req.body.books.findIndex(lid => lid === a.book.libraryItem.id)
- const bIndex = req.body.books.findIndex(lid => lid === b.book.libraryItem.id)
+ const aIndex = req.body.books.findIndex((lid) => lid === a.book.libraryItem.id)
+ const bIndex = req.body.books.findIndex((lid) => lid === b.book.libraryItem.id)
return aIndex - bIndex
})
for (let i = 0; i < collectionBooks.length; i++) {
@@ -123,6 +145,12 @@ class CollectionController {
res.json(jsonExpanded)
}
+ /**
+ * DELETE: /api/collections/:id
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async delete(req, res) {
const jsonExpanded = await req.collection.getOldJsonExpanded()
@@ -139,8 +167,9 @@ class CollectionController {
* POST: /api/collections/:id/book
* Add a single book to a collection
* Req.body { id: }
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async addBook(req, res) {
const libraryItem = await Database.libraryItemModel.getOldById(req.body.id)
@@ -153,7 +182,7 @@ class CollectionController {
// Check if book is already in collection
const collectionBooks = await req.collection.getCollectionBooks()
- if (collectionBooks.some(cb => cb.bookId === libraryItem.media.id)) {
+ if (collectionBooks.some((cb) => cb.bookId === libraryItem.media.id)) {
return res.status(400).send('Book already in collection')
}
@@ -172,8 +201,9 @@ class CollectionController {
* DELETE: /api/collections/:id/book/:bookId
* Remove a single book from a collection. Re-order books
* TODO: bookId is actually libraryItemId. Clients need updating to use bookId
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async removeBook(req, res) {
const libraryItem = await Database.libraryItemModel.getOldById(req.params.bookId)
@@ -187,7 +217,7 @@ class CollectionController {
})
let jsonExpanded = null
- const collectionBookToRemove = collectionBooks.find(cb => cb.bookId === libraryItem.media.id)
+ const collectionBookToRemove = collectionBooks.find((cb) => cb.bookId === libraryItem.media.id)
if (collectionBookToRemove) {
// Remove collection book record
await collectionBookToRemove.destroy()
@@ -216,12 +246,13 @@ class CollectionController {
* POST: /api/collections/:id/batch/add
* Add multiple books to collection
* Req.body { books: }
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async addBatch(req, res) {
// filter out invalid libraryItemIds
- const bookIdsToAdd = (req.body.books || []).filter(b => !!b && typeof b == 'string')
+ const bookIdsToAdd = (req.body.books || []).filter((b) => !!b && typeof b == 'string')
if (!bookIdsToAdd.length) {
return res.status(500).send('Invalid request body')
}
@@ -247,7 +278,7 @@ class CollectionController {
// Check and set new collection books to add
for (const libraryItem of libraryItems) {
- if (!collectionBooks.some(cb => cb.bookId === libraryItem.media.id)) {
+ if (!collectionBooks.some((cb) => cb.bookId === libraryItem.media.id)) {
collectionBooksToAdd.push({
collectionId: req.collection.id,
bookId: libraryItem.media.id,
@@ -274,12 +305,13 @@ class CollectionController {
* POST: /api/collections/:id/batch/remove
* Remove multiple books from collection
* Req.body { books: }
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async removeBatch(req, res) {
// filter out invalid libraryItemIds
- const bookIdsToRemove = (req.body.books || []).filter(b => !!b && typeof b == 'string')
+ const bookIdsToRemove = (req.body.books || []).filter((b) => !!b && typeof b == 'string')
if (!bookIdsToRemove.length) {
return res.status(500).send('Invalid request body')
}
@@ -305,7 +337,7 @@ class CollectionController {
let order = 1
let hasUpdated = false
for (const collectionBook of collectionBooks) {
- if (libraryItems.some(li => li.media.id === collectionBook.bookId)) {
+ if (libraryItems.some((li) => li.media.id === collectionBook.bookId)) {
await collectionBook.destroy()
hasUpdated = true
continue
@@ -325,6 +357,12 @@ class CollectionController {
res.json(jsonExpanded)
}
+ /**
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ * @param {NextFunction} next
+ */
async middleware(req, res, next) {
if (req.params.id) {
const collection = await Database.collectionModel.findByPk(req.params.id)
@@ -335,14 +373,14 @@ class CollectionController {
}
if (req.method == 'DELETE' && !req.user.canDelete) {
- Logger.warn(`[CollectionController] User attempted to delete without permission`, req.user.username)
+ Logger.warn(`[CollectionController] User "${req.user.username}" attempted to delete without permission`)
return res.sendStatus(403)
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
- Logger.warn('[CollectionController] User attempted to update without permission', req.user.username)
+ Logger.warn(`[CollectionController] User "${req.user.username}" attempted to update without permission`)
return res.sendStatus(403)
}
next()
}
}
-module.exports = new CollectionController()
\ No newline at end of file
+module.exports = new CollectionController()
diff --git a/server/controllers/CustomMetadataProviderController.js b/server/controllers/CustomMetadataProviderController.js
index fdb4df2d01..790a850122 100644
--- a/server/controllers/CustomMetadataProviderController.js
+++ b/server/controllers/CustomMetadataProviderController.js
@@ -1,20 +1,25 @@
+const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const { validateUrl } = require('../utils/index')
-//
-// This is a controller for routes that don't have a home yet :(
-//
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
+
class CustomMetadataProviderController {
- constructor() { }
+ constructor() {}
/**
* GET: /api/custom-metadata-providers
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getAll(req, res) {
const providers = await Database.customMetadataProviderModel.findAll()
@@ -27,8 +32,8 @@ class CustomMetadataProviderController {
/**
* POST: /api/custom-metadata-providers
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async create(req, res) {
const { name, url, mediaType, authHeaderValue } = req.body
@@ -47,7 +52,7 @@ class CustomMetadataProviderController {
name,
mediaType,
url,
- authHeaderValue: !authHeaderValue ? null : authHeaderValue,
+ authHeaderValue: !authHeaderValue ? null : authHeaderValue
})
// TODO: Necessary to emit to all clients?
@@ -60,9 +65,9 @@ class CustomMetadataProviderController {
/**
* DELETE: /api/custom-metadata-providers/:id
- *
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async delete(req, res) {
const slug = `custom-${req.params.id}`
@@ -76,13 +81,16 @@ class CustomMetadataProviderController {
await provider.destroy()
// Libraries using this provider fallback to default provider
- await Database.libraryModel.update({
- provider: fallbackProvider
- }, {
- where: {
- provider: slug
+ await Database.libraryModel.update(
+ {
+ provider: fallbackProvider
+ },
+ {
+ where: {
+ provider: slug
+ }
}
- })
+ )
// TODO: Necessary to emit to all clients?
SocketAuthority.emitter('custom_metadata_provider_removed', providerClientJson)
@@ -92,10 +100,10 @@ class CustomMetadataProviderController {
/**
* Middleware that requires admin or up
- *
- * @param {import('express').Request} req
- * @param {import('express').Response} res
- * @param {import('express').NextFunction} next
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ * @param {NextFunction} next
*/
async middleware(req, res, next) {
if (!req.user.isAdminOrUp) {
diff --git a/server/controllers/EmailController.js b/server/controllers/EmailController.js
index fcbc49054d..916b4268ef 100644
--- a/server/controllers/EmailController.js
+++ b/server/controllers/EmailController.js
@@ -1,16 +1,36 @@
+const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
+
class EmailController {
- constructor() { }
+ constructor() {}
+ /**
+ * GET: /api/emails/settings
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
getSettings(req, res) {
res.json({
settings: Database.emailSettings
})
}
+ /**
+ * PATCH: /api/emails/settings
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async updateSettings(req, res) {
const updated = Database.emailSettings.update(req.body)
if (updated) {
@@ -21,10 +41,24 @@ class EmailController {
})
}
+ /**
+ * POST: /api/emails/test
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async sendTest(req, res) {
this.emailManager.sendTest(res)
}
+ /**
+ * POST: /api/emails/ereader-devices
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async updateEReaderDevices(req, res) {
if (!req.body.ereaderDevices || !Array.isArray(req.body.ereaderDevices)) {
return res.status(400).send('Invalid payload. ereaderDevices array required')
@@ -52,11 +86,12 @@ class EmailController {
}
/**
+ * POST: /api/emails/send-ebook-to-device
* Send ebook to device
* User must have access to device and library item
- *
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async sendEBookToDevice(req, res) {
Logger.debug(`[EmailController] Send ebook to device requested by user "${req.user.username}" for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`)
@@ -89,6 +124,12 @@ class EmailController {
this.emailManager.sendEBookToDevice(ebookFile, device, res)
}
+ /**
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ * @param {NextFunction} next
+ */
adminMiddleware(req, res, next) {
if (!req.user.isAdminOrUp) {
return res.sendStatus(404)
@@ -97,4 +138,4 @@ class EmailController {
next()
}
}
-module.exports = new EmailController()
\ No newline at end of file
+module.exports = new EmailController()
diff --git a/server/controllers/FileSystemController.js b/server/controllers/FileSystemController.js
index 88459e51af..e923c4951a 100644
--- a/server/controllers/FileSystemController.js
+++ b/server/controllers/FileSystemController.js
@@ -1,20 +1,28 @@
+const { Request, Response } = require('express')
const Path = require('path')
const Logger = require('../Logger')
const fs = require('../libs/fsExtra')
const { toNumber } = require('../utils/index')
const fileUtils = require('../utils/fileUtils')
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
+
class FileSystemController {
- constructor() { }
+ constructor() {}
/**
- *
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getPaths(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[FileSystemController] Non-admin user attempting to get filesystem paths`, req.user)
+ Logger.error(`[FileSystemController] Non-admin user "${req.user.username}" attempting to get filesystem paths`)
return res.sendStatus(403)
}
@@ -22,7 +30,7 @@ class FileSystemController {
const level = toNumber(req.query.level, 0)
// Validate path. Must be absolute
- if (relpath && (!Path.isAbsolute(relpath) || !await fs.pathExists(relpath))) {
+ if (relpath && (!Path.isAbsolute(relpath) || !(await fs.pathExists(relpath)))) {
Logger.error(`[FileSystemController] Invalid path in query string "${relpath}"`)
return res.status(400).send('Invalid "path" query string')
}
@@ -40,7 +48,7 @@ class FileSystemController {
return []
})
if (drives.length) {
- directories = drives.map(d => {
+ directories = drives.map((d) => {
return {
path: d,
dirname: d,
@@ -54,10 +62,10 @@ class FileSystemController {
}
// Exclude some dirs from this project to be cleaner in Docker
- const excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc', '.devcontainer', '.nyc_output', '.github', '.vscode'].map(dirname => {
+ const excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc', '.devcontainer', '.nyc_output', '.github', '.vscode'].map((dirname) => {
return fileUtils.filePathToPOSIX(Path.join(global.appRoot, dirname))
})
- directories = directories.filter(dir => {
+ directories = directories.filter((dir) => {
return !excludedDirs.includes(dir.path)
})
@@ -67,10 +75,15 @@ class FileSystemController {
})
}
- // POST: api/filesystem/pathexists
+ /**
+ * POST: /api/filesystem/pathexists
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async checkPathExists(req, res) {
if (!req.user.canUpload) {
- Logger.error(`[FileSystemController] Non-admin user attempting to check path exists`, req.user)
+ Logger.error(`[FileSystemController] Non-admin user "${req.user.username}" attempting to check path exists`)
return res.sendStatus(403)
}
@@ -85,4 +98,4 @@ class FileSystemController {
})
}
}
-module.exports = new FileSystemController()
\ No newline at end of file
+module.exports = new FileSystemController()
diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js
index 0f30c410db..48021a3047 100644
--- a/server/controllers/LibraryController.js
+++ b/server/controllers/LibraryController.js
@@ -1,3 +1,4 @@
+const { Request, Response, NextFunction } = require('express')
const Sequelize = require('sequelize')
const Path = require('path')
const fs = require('../libs/fsExtra')
@@ -22,9 +23,23 @@ const libraryFilters = require('../utils/queries/libraryFilters')
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
const authorFilters = require('../utils/queries/authorFilters')
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
+
class LibraryController {
constructor() {}
+ /**
+ * POST: /api/libraries
+ * Create a new library
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async create(req, res) {
const newLibraryPayload = {
...req.body
@@ -83,7 +98,7 @@ class LibraryController {
async findAll(req, res) {
const libraries = await Database.libraryModel.getAllOldLibraries()
- const librariesAccessible = req.user.librariesAccessible || []
+ const librariesAccessible = req.user.permissions?.librariesAccessible || []
if (librariesAccessible.length) {
return res.json({
libraries: libraries.filter((lib) => librariesAccessible.includes(lib.id)).map((lib) => lib.toJSON())
@@ -98,8 +113,8 @@ class LibraryController {
/**
* GET: /api/libraries/:id
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async findOne(req, res) {
const includeArray = (req.query.include || '').split(',')
@@ -121,8 +136,8 @@ class LibraryController {
/**
* GET: /api/libraries/:id/episode-downloads
* Get podcast episodes in download queue
- * @param {*} req
- * @param {*} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getEpisodeDownloadQueue(req, res) {
const libraryDownloadQueueDetails = this.podcastManager.getDownloadQueueDetails(req.library.id)
@@ -132,8 +147,8 @@ class LibraryController {
/**
* PATCH: /api/libraries/:id
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async update(req, res) {
/** @type {import('../objects/Library')} */
@@ -223,7 +238,7 @@ class LibraryController {
// Only emit to users with access to library
const userFilter = (user) => {
- return user.checkCanAccessLibrary && user.checkCanAccessLibrary(library.id)
+ return user.checkCanAccessLibrary?.(library.id)
}
SocketAuthority.emitter('library_updated', library.toJSON(), userFilter)
@@ -235,8 +250,9 @@ class LibraryController {
/**
* DELETE: /api/libraries/:id
* Delete a library
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async delete(req, res) {
const library = req.library
@@ -298,8 +314,8 @@ class LibraryController {
/**
* GET /api/libraries/:id/items
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getLibraryItems(req, res) {
const include = (req.query.include || '')
@@ -340,8 +356,8 @@ class LibraryController {
/**
* DELETE: /libraries/:id/issues
* Remove all library items missing or invalid
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async removeLibraryItemsWithIssues(req, res) {
const libraryItemsWithIssues = await Database.libraryItemModel.findAll({
@@ -398,8 +414,8 @@ class LibraryController {
* GET: /api/libraries/:id/series
* Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getAllSeriesForLibrary(req, res) {
const include = (req.query.include || '')
@@ -434,8 +450,8 @@ class LibraryController {
* rssfeed: adds `rssFeed` to series object if a feed is open
* progress: adds `progress` to series object with { libraryItemIds:Array, libraryItemIdsFinished:Array, isFinished:boolean }
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res - Series
+ * @param {RequestWithUser} req
+ * @param {Response} res - Series
*/
async getSeriesForLibrary(req, res) {
const include = (req.query.include || '')
@@ -451,7 +467,7 @@ class LibraryController {
const seriesJson = oldSeries.toJSON()
if (include.includes('progress')) {
- const libraryItemsFinished = libraryItemsInSeries.filter((li) => !!req.user.getMediaProgress(li.id)?.isFinished)
+ const libraryItemsFinished = libraryItemsInSeries.filter((li) => !!req.user.getMediaProgress(li.media.id)?.isFinished)
seriesJson.progress = {
libraryItemIds: libraryItemsInSeries.map((li) => li.id),
libraryItemIdsFinished: libraryItemsFinished.map((li) => li.id),
@@ -470,8 +486,9 @@ class LibraryController {
/**
* GET: /api/libraries/:id/collections
* Get all collections for library
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getCollectionsForLibrary(req, res) {
const include = (req.query.include || '')
@@ -508,8 +525,9 @@ class LibraryController {
/**
* GET: /api/libraries/:id/playlists
* Get playlists for user in library
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getUserPlaylistsForLibrary(req, res) {
let playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id, req.library.id)
@@ -532,8 +550,9 @@ class LibraryController {
/**
* GET: /api/libraries/:id/filterdata
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getLibraryFilterData(req, res) {
const filterData = await libraryFilters.getFilterData(req.library.mediaType, req.library.id)
@@ -543,8 +562,9 @@ class LibraryController {
/**
* GET: /api/libraries/:id/personalized
* Home page shelves
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getUserPersonalizedShelves(req, res) {
const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) || 10 : 10
@@ -559,12 +579,13 @@ class LibraryController {
/**
* POST: /api/libraries/order
* Change the display order of libraries
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async reorder(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error('[LibraryController] ReorderLibraries invalid user', req.user)
+ Logger.error(`[LibraryController] Non-admin user "${req.user}" attempted to reorder libraries`)
return res.sendStatus(403)
}
const libraries = await Database.libraryModel.getAllOldLibraries()
@@ -598,9 +619,10 @@ class LibraryController {
/**
* GET: /api/libraries/:id/search
* Search library items with query
+ *
* ?q=search
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async search(req, res) {
if (!req.query.q || typeof req.query.q !== 'string') {
@@ -616,8 +638,9 @@ class LibraryController {
/**
* GET: /api/libraries/:id/stats
* Get stats for library
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async stats(req, res) {
const stats = {
@@ -658,8 +681,9 @@ class LibraryController {
/**
* GET: /api/libraries/:id/authors
* Get authors for library
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getAuthors(req, res) {
const { bookWhere, replacements } = libraryItemsBookFilters.getUserPermissionBookWhereQuery(req.user)
@@ -696,8 +720,9 @@ class LibraryController {
/**
* GET: /api/libraries/:id/narrators
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getNarrators(req, res) {
// Get all books with narrators
@@ -742,8 +767,9 @@ class LibraryController {
* Update narrator name
* :narratorId is base64 encoded name
* req.body { name }
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async updateNarrator(req, res) {
if (!req.user.canUpdate) {
@@ -792,8 +818,9 @@ class LibraryController {
* DELETE: /api/libraries/:id/narrators/:narratorId
* Remove narrator
* :narratorId is base64 encoded name
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async removeNarrator(req, res) {
if (!req.user.canUpdate) {
@@ -835,12 +862,12 @@ class LibraryController {
* GET: /api/libraries/:id/matchall
* Quick match all library items. Book libraries only.
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async matchAll(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[LibraryController] Non-root user attempted to match library items`, req.user)
+ Logger.error(`[LibraryController] Non-root user "${req.user.username}" attempted to match library items`)
return res.sendStatus(403)
}
Scanner.matchLibraryItems(req.library)
@@ -852,12 +879,12 @@ class LibraryController {
* Optional query:
* ?force=1
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async scan(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[LibraryController] Non-root user attempted to scan library`, req.user)
+ Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to scan library`)
return res.sendStatus(403)
}
res.sendStatus(200)
@@ -872,8 +899,9 @@ class LibraryController {
/**
* GET: /api/libraries/:id/recent-episodes
* Used for latest page
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getRecentEpisodes(req, res) {
if (!req.library.isPodcast) {
@@ -894,8 +922,9 @@ class LibraryController {
/**
* GET: /api/libraries/:id/opml
* Get OPML file for a podcast library
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getOPMLFile(req, res) {
const userPermissionPodcastWhere = libraryItemsPodcastFilters.getUserPermissionPodcastWhereQuery(req.user)
@@ -920,12 +949,12 @@ class LibraryController {
/**
* Remove all metadata.json or metadata.abs files in library item folders
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async removeAllMetadataFiles(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[LibraryController] Non-admin user attempted to remove all metadata files`, req.user)
+ Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to remove all metadata files`)
return res.sendStatus(403)
}
@@ -968,10 +997,10 @@ class LibraryController {
}
/**
- * Middleware that is not using libraryItems from memory
- * @param {import('express').Request} req
- * @param {import('express').Response} res
- * @param {import('express').NextFunction} next
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ * @param {NextFunction} next
*/
async middleware(req, res, next) {
if (!req.user.checkCanAccessLibrary(req.params.id)) {
diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js
index 5442097886..0ec2b49e73 100644
--- a/server/controllers/LibraryItemController.js
+++ b/server/controllers/LibraryItemController.js
@@ -1,3 +1,4 @@
+const { Request, Response, NextFunction } = require('express')
const Path = require('path')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
@@ -15,6 +16,13 @@ const CacheManager = require('../managers/CacheManager')
const CoverManager = require('../managers/CoverManager')
const ShareManager = require('../managers/ShareManager')
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
+
class LibraryItemController {
constructor() {}
@@ -24,8 +32,8 @@ class LibraryItemController {
* ?include=progress,rssfeed,downloads,share
* ?expanded=1
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async findOne(req, res) {
const includeEntities = (req.query.include || '').split(',')
@@ -35,7 +43,7 @@ class LibraryItemController {
// Include users media progress
if (includeEntities.includes('progress')) {
var episodeId = req.query.episode || null
- item.userMediaProgress = req.user.getMediaProgress(item.id, episodeId)
+ item.userMediaProgress = req.user.getOldMediaProgress(item.id, episodeId)
}
if (includeEntities.includes('rssfeed')) {
@@ -60,6 +68,11 @@ class LibraryItemController {
res.json(req.libraryItem)
}
+ /**
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async update(req, res) {
var libraryItem = req.libraryItem
// Item has cover and update is removing cover so purge it from cache
@@ -76,10 +89,21 @@ class LibraryItemController {
res.json(libraryItem.toJSON())
}
+ /**
+ * DELETE: /api/items/:id
+ * Delete library item. Will delete from database and file system if hard delete is requested.
+ * Optional query params:
+ * ?hard=1
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async delete(req, res) {
const hardDelete = req.query.hard == 1 // Delete from file system
const libraryItemPath = req.libraryItem.path
- await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, [req.libraryItem.media.id])
+
+ const mediaItemIds = req.libraryItem.mediaType === 'podcast' ? req.libraryItem.media.episodes.map((ep) => ep.id) : [req.libraryItem.media.id]
+ await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, mediaItemIds)
if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => {
@@ -94,29 +118,29 @@ class LibraryItemController {
* GET: /api/items/:id/download
* Download library item. Zip file if multiple files.
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
download(req, res) {
if (!req.user.canDownload) {
- Logger.warn('User attempted to download without permission', req.user)
+ Logger.warn(`User "${req.user.username}" attempted to download without permission`)
return res.sendStatus(403)
}
+ const libraryItemPath = req.libraryItem.path
+ const itemTitle = req.libraryItem.media.metadata.title
// If library item is a single file in root dir then no need to zip
if (req.libraryItem.isFile) {
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
- const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(req.libraryItem.path))
+ const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryItemPath))
if (audioMimeType) {
res.setHeader('Content-Type', audioMimeType)
}
-
- res.download(req.libraryItem.path, req.libraryItem.relPath)
+ Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)
+ res.download(libraryItemPath, req.libraryItem.relPath)
return
}
- const libraryItemPath = req.libraryItem.path
- const itemTitle = req.libraryItem.media.metadata.title
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)
const filename = `${itemTitle}.zip`
zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res)
@@ -126,8 +150,8 @@ class LibraryItemController {
* PATCH: /items/:id/media
* Update media for a library item. Will create new authors & series when necessary
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async updateMedia(req, res) {
const libraryItem = req.libraryItem
@@ -187,10 +211,16 @@ class LibraryItemController {
})
}
- // POST: api/items/:id/cover
+ /**
+ * POST: /api/items/:id/cover
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ * @param {boolean} [updateAndReturnJson=true]
+ */
async uploadCover(req, res, updateAndReturnJson = true) {
if (!req.user.canUpload) {
- Logger.warn('User attempted to upload a cover without permission', req.user)
+ Logger.warn(`User "${req.user.username}" attempted to upload a cover without permission`)
return res.sendStatus(403)
}
@@ -223,7 +253,12 @@ class LibraryItemController {
}
}
- // PATCH: api/items/:id/cover
+ /**
+ * PATCH: /api/items/:id/cover
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async updateCover(req, res) {
const libraryItem = req.libraryItem
if (!req.body.cover) {
@@ -244,7 +279,12 @@ class LibraryItemController {
})
}
- // DELETE: api/items/:id/cover
+ /**
+ * DELETE: /api/items/:id/cover
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async removeCover(req, res) {
var libraryItem = req.libraryItem
@@ -259,10 +299,10 @@ class LibraryItemController {
}
/**
- * GET: api/items/:id/cover
+ * GET: /api/items/:id/cover
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getCover(req, res) {
const {
@@ -288,7 +328,7 @@ class LibraryItemController {
}
// Check if user can access this library item
- if (!req.user.checkCanAccessLibraryItemWithData(libraryItem.libraryId, libraryItem.media.explicit, libraryItem.media.tags)) {
+ if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
return res.sendStatus(403)
}
@@ -317,7 +357,14 @@ class LibraryItemController {
return CacheManager.handleCoverCache(res, libraryItem.id, libraryItem.media.coverPath, options)
}
- // POST: api/items/:id/play
+ /**
+ * POST: /api/items/:id/play
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
startPlaybackSession(req, res) {
if (!req.libraryItem.media.numTracks && req.libraryItem.mediaType !== 'video') {
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
@@ -327,7 +374,14 @@ class LibraryItemController {
this.playbackSessionManager.startSessionRequest(req, res, null)
}
- // POST: api/items/:id/play/:episodeId
+ /**
+ * POST: /api/items/:id/play/:episodeId
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
startEpisodePlaybackSession(req, res) {
var libraryItem = req.libraryItem
if (!libraryItem.media.numTracks) {
@@ -343,7 +397,12 @@ class LibraryItemController {
this.playbackSessionManager.startSessionRequest(req, res, episodeId)
}
- // PATCH: api/items/:id/tracks
+ /**
+ * PATCH: /api/items/:id/tracks
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async updateTracks(req, res) {
var libraryItem = req.libraryItem
var orderedFileData = req.body.orderedFileData
@@ -357,7 +416,12 @@ class LibraryItemController {
res.json(libraryItem.toJSON())
}
- // POST api/items/:id/match
+ /**
+ * POST /api/items/:id/match
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async match(req, res) {
var libraryItem = req.libraryItem
@@ -366,10 +430,18 @@ class LibraryItemController {
res.json(matchResult)
}
- // POST: api/items/batch/delete
+ /**
+ * POST: /api/items/batch/delete
+ * Batch delete library items. Will delete from database and file system if hard delete is requested.
+ * Optional query params:
+ * ?hard=1
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async batchDelete(req, res) {
if (!req.user.canDelete) {
- Logger.warn(`[LibraryItemController] User attempted to delete without permission`, req.user)
+ Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to delete without permission`)
return res.sendStatus(403)
}
const hardDelete = req.query.hard == 1 // Delete files from filesystem
@@ -391,7 +463,8 @@ class LibraryItemController {
for (const libraryItem of itemsToDelete) {
const libraryItemPath = libraryItem.path
Logger.info(`[LibraryItemController] Deleting Library Item "${libraryItem.media.metadata.title}"`)
- await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, [libraryItem.media.id])
+ const mediaItemIds = libraryItem.mediaType === 'podcast' ? libraryItem.media.episodes.map((ep) => ep.id) : [libraryItem.media.id]
+ await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => {
@@ -404,7 +477,12 @@ class LibraryItemController {
res.sendStatus(200)
}
- // POST: api/items/batch/update
+ /**
+ * POST: /api/items/batch/update
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async batchUpdate(req, res) {
const updatePayloads = req.body
if (!updatePayloads?.length) {
@@ -450,7 +528,12 @@ class LibraryItemController {
})
}
- // POST: api/items/batch/get
+ /**
+ * POST: /api/items/batch/get
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async batchGet(req, res) {
const libraryItemIds = req.body.libraryItemIds || []
if (!libraryItemIds.length) {
@@ -464,10 +547,15 @@ class LibraryItemController {
})
}
- // POST: api/items/batch/quickmatch
+ /**
+ * POST: /api/items/batch/quickmatch
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async batchQuickMatch(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.warn('User other than admin attempted to batch quick match library items', req.user)
+ Logger.warn(`Non-admin user "${req.user.username}" other than admin attempted to batch quick match library items`)
return res.sendStatus(403)
}
@@ -505,10 +593,15 @@ class LibraryItemController {
SocketAuthority.clientEmitter(req.user.id, 'batch_quickmatch_complete', result)
}
- // POST: api/items/batch/scan
+ /**
+ * POST: /api/items/batch/scan
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async batchScan(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.warn('User other than admin attempted to batch scan library items', req.user)
+ Logger.warn(`Non-admin user "${req.user.username}" other than admin attempted to batch scan library items`)
return res.sendStatus(403)
}
@@ -540,10 +633,15 @@ class LibraryItemController {
await Database.resetLibraryIssuesFilterData(libraryId)
}
- // POST: api/items/:id/scan
+ /**
+ * POST: /api/items/:id/scan
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async scan(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[LibraryItemController] Non-admin user attempted to scan library item`, req.user)
+ Logger.error(`[LibraryItemController] Non-admin user "${req.user.username}" attempted to scan library item`)
return res.sendStatus(403)
}
@@ -559,9 +657,15 @@ class LibraryItemController {
})
}
+ /**
+ * GET: /api/items/:id/metadata-object
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
getMetadataObject(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[LibraryItemController] Non-admin user attempted to get metadata object`, req.user)
+ Logger.error(`[LibraryItemController] Non-admin user "${req.user.username}" attempted to get metadata object`)
return res.sendStatus(403)
}
@@ -573,10 +677,15 @@ class LibraryItemController {
res.json(this.audioMetadataManager.getMetadataObjectForApi(req.libraryItem))
}
- // POST: api/items/:id/chapters
+ /**
+ * POST: /api/items/:id/chapters
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async updateMediaChapters(req, res) {
if (!req.user.canUpdate) {
- Logger.error(`[LibraryItemController] User attempted to update chapters with invalid permissions`, req.user.username)
+ Logger.error(`[LibraryItemController] User "${req.user.username}" attempted to update chapters with invalid permissions`)
return res.sendStatus(403)
}
@@ -604,15 +713,15 @@ class LibraryItemController {
}
/**
- * GET api/items/:id/ffprobe/:fileid
+ * GET: /api/items/:id/ffprobe/:fileid
* FFProbe JSON result from audio file
*
- * @param {express.Request} req
- * @param {express.Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getFFprobeData(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[LibraryItemController] Non-admin user attempted to get ffprobe data`, req.user)
+ Logger.error(`[LibraryItemController] Non-admin user "${req.user.username}" attempted to get ffprobe data`)
return res.sendStatus(403)
}
if (req.libraryFile.fileType !== 'audio') {
@@ -633,8 +742,8 @@ class LibraryItemController {
/**
* GET api/items/:id/file/:fileid
*
- * @param {express.Request} req
- * @param {express.Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getLibraryFile(req, res) {
const libraryFile = req.libraryFile
@@ -656,8 +765,8 @@ class LibraryItemController {
/**
* DELETE api/items/:id/file/:fileid
*
- * @param {express.Request} req
- * @param {express.Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async deleteLibraryFile(req, res) {
const libraryFile = req.libraryFile
@@ -684,18 +793,19 @@ class LibraryItemController {
/**
* GET api/items/:id/file/:fileid/download
* Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads
- * @param {express.Request} req
- * @param {express.Response} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async downloadLibraryFile(req, res) {
const libraryFile = req.libraryFile
if (!req.user.canDownload) {
- Logger.error(`[LibraryItemController] User without download permission attempted to download file "${libraryFile.metadata.path}"`, req.user)
+ Logger.error(`[LibraryItemController] User "${req.user.username}" without download permission attempted to download file "${libraryFile.metadata.path}"`)
return res.sendStatus(403)
}
- Logger.info(`[LibraryItemController] User "${req.user.username}" requested file download at "${libraryFile.metadata.path}"`)
+ Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.metadata.title}" file at "${libraryFile.metadata.path}"`)
if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + libraryFile.metadata.path)
@@ -718,8 +828,8 @@ class LibraryItemController {
* fileid is only required when reading a supplementary ebook
* when no fileid is passed in the primary ebook will be returned
*
- * @param {express.Request} req
- * @param {express.Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getEBookFile(req, res) {
let ebookFile = null
@@ -739,6 +849,8 @@ class LibraryItemController {
}
const ebookFilePath = ebookFile.metadata.path
+ Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.metadata.title}" ebook at "${ebookFilePath}"`)
+
if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + ebookFilePath)
Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)
@@ -754,8 +866,8 @@ class LibraryItemController {
* if an ebook file is the primary ebook, then it will be changed to supplementary
* if an ebook file is supplementary, then it will be changed to primary
*
- * @param {express.Request} req
- * @param {express.Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async updateEbookFileStatus(req, res) {
const ebookLibraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid)
@@ -779,6 +891,12 @@ class LibraryItemController {
res.sendStatus(200)
}
+ /**
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ * @param {NextFunction} next
+ */
async middleware(req, res, next) {
req.libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
if (!req.libraryItem?.media) return res.sendStatus(404)
@@ -800,10 +918,10 @@ class LibraryItemController {
if (req.path.includes('/play')) {
// allow POST requests using /play and /play/:episodeId
} else if (req.method == 'DELETE' && !req.user.canDelete) {
- Logger.warn(`[LibraryItemController] User attempted to delete without permission`, req.user)
+ Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to delete without permission`)
return res.sendStatus(403)
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
- Logger.warn('[LibraryItemController] User attempted to update without permission', req.user.username)
+ Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to update without permission`)
return res.sendStatus(403)
}
diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js
index 7126d45b29..c7abbc2327 100644
--- a/server/controllers/MeController.js
+++ b/server/controllers/MeController.js
@@ -1,20 +1,41 @@
+const { Request, Response } = require('express')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const { sort } = require('../libs/fastSort')
-const { toNumber } = require('../utils/index')
+const { toNumber, isNullOrNaN } = require('../utils/index')
const userStats = require('../utils/queries/userStats')
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
+
class MeController {
constructor() {}
+ /**
+ * GET: /api/me
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
getCurrentUser(req, res) {
- res.json(req.user.toJSONForBrowser())
+ res.json(req.user.toOldJSONForBrowser())
}
- // GET: api/me/listening-sessions
+ /**
+ * GET: /api/me/listening-sessions
+ *
+ * @this import('../routers/ApiRouter')
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async getListeningSessions(req, res) {
- var listeningSessions = await this.getUserListeningSessionsHelper(req.user.id)
+ const listeningSessions = await this.getUserListeningSessionsHelper(req.user.id)
const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10
const page = toNumber(req.query.page, 0)
@@ -38,8 +59,8 @@ class MeController {
*
* @this import('../routers/ApiRouter')
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getItemListeningSessions(req, res) {
const libraryItem = await Database.libraryItemModel.findByPk(req.params.libraryItemId)
@@ -70,245 +91,234 @@ class MeController {
res.json(payload)
}
- // GET: api/me/listening-stats
+ /**
+ * GET: /api/me/listening-stats
+ *
+ * @this import('../routers/ApiRouter')
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async getListeningStats(req, res) {
const listeningStats = await this.getUserListeningStatsHelpers(req.user.id)
res.json(listeningStats)
}
- // GET: api/me/progress/:id/:episodeId?
+ /**
+ * GET: /api/me/progress/:id/:episodeId?
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async getMediaProgress(req, res) {
- const mediaProgress = req.user.getMediaProgress(req.params.id, req.params.episodeId || null)
+ const mediaProgress = req.user.getOldMediaProgress(req.params.id, req.params.episodeId || null)
if (!mediaProgress) {
return res.sendStatus(404)
}
res.json(mediaProgress)
}
- // DELETE: api/me/progress/:id
+ /**
+ * DELETE: /api/me/progress/:id
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async removeMediaProgress(req, res) {
- if (!req.user.removeMediaProgress(req.params.id)) {
- return res.sendStatus(200)
- }
- await Database.removeMediaProgress(req.params.id)
- SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
- res.sendStatus(200)
- }
-
- // PATCH: api/me/progress/:id
- async createUpdateMediaProgress(req, res) {
- const libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
- if (!libraryItem) {
- return res.status(404).send('Item not found')
- }
+ await Database.mediaProgressModel.removeById(req.params.id)
+ req.user.mediaProgresses = req.user.mediaProgresses.filter((mp) => mp.id !== req.params.id)
- if (req.user.createUpdateMediaProgress(libraryItem, req.body)) {
- const mediaProgress = req.user.getMediaProgress(libraryItem.id)
- if (mediaProgress) await Database.upsertMediaProgress(mediaProgress)
- SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
- }
+ SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
res.sendStatus(200)
}
- // PATCH: api/me/progress/:id/:episodeId
- async createUpdateEpisodeMediaProgress(req, res) {
- const episodeId = req.params.episodeId
- const libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
- if (!libraryItem) {
- return res.status(404).send('Item not found')
+ /**
+ * PATCH: /api/me/progress/:libraryItemId/:episodeId?
+ * TODO: Update to use mediaItemId and mediaItemType
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
+ async createUpdateMediaProgress(req, res) {
+ const progressUpdatePayload = {
+ ...req.body,
+ libraryItemId: req.params.libraryItemId,
+ episodeId: req.params.episodeId
}
- if (!libraryItem.media.episodes.find((ep) => ep.id === episodeId)) {
- Logger.error(`[MeController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
- return res.status(404).send('Episode not found')
+ const mediaProgressResponse = await req.user.createUpdateMediaProgressFromPayload(progressUpdatePayload)
+ if (mediaProgressResponse.error) {
+ return res.status(mediaProgressResponse.statusCode || 400).send(mediaProgressResponse.error)
}
- if (req.user.createUpdateMediaProgress(libraryItem, req.body, episodeId)) {
- const mediaProgress = req.user.getMediaProgress(libraryItem.id, episodeId)
- if (mediaProgress) await Database.upsertMediaProgress(mediaProgress)
- SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
- }
+ SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
res.sendStatus(200)
}
- // PATCH: api/me/progress/batch/update
+ /**
+ * PATCH: /api/me/progress/batch/update
+ * TODO: Update to use mediaItemId and mediaItemType
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async batchUpdateMediaProgress(req, res) {
const itemProgressPayloads = req.body
if (!itemProgressPayloads?.length) {
return res.status(400).send('Missing request payload')
}
- let shouldUpdate = false
+ let hasUpdated = false
for (const itemProgress of itemProgressPayloads) {
- const libraryItem = await Database.libraryItemModel.getOldById(itemProgress.libraryItemId)
- if (libraryItem) {
- if (req.user.createUpdateMediaProgress(libraryItem, itemProgress, itemProgress.episodeId)) {
- const mediaProgress = req.user.getMediaProgress(libraryItem.id, itemProgress.episodeId)
- if (mediaProgress) await Database.upsertMediaProgress(mediaProgress)
- shouldUpdate = true
- }
+ const mediaProgressResponse = await req.user.createUpdateMediaProgressFromPayload(itemProgress)
+ if (mediaProgressResponse.error) {
+ Logger.error(`[MeController] batchUpdateMediaProgress: ${mediaProgressResponse.error}`)
+ continue
} else {
- Logger.error(`[MeController] batchUpdateMediaProgress: Library Item does not exist ${itemProgress.id}`)
+ hasUpdated = true
}
}
- if (shouldUpdate) {
- SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
+ if (hasUpdated) {
+ SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
}
res.sendStatus(200)
}
- // POST: api/me/item/:id/bookmark
+ /**
+ * POST: /api/me/item/:id/bookmark
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async createBookmark(req, res) {
if (!(await Database.libraryItemModel.checkExistsById(req.params.id))) return res.sendStatus(404)
const { time, title } = req.body
- const bookmark = req.user.createBookmark(req.params.id, time, title)
- await Database.updateUser(req.user)
- SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
+ if (isNullOrNaN(time)) {
+ Logger.error(`[MeController] createBookmark invalid time`, time)
+ return res.status(400).send('Invalid time')
+ }
+ if (!title || typeof title !== 'string') {
+ Logger.error(`[MeController] createBookmark invalid title`, title)
+ return res.status(400).send('Invalid title')
+ }
+
+ const bookmark = await req.user.createBookmark(req.params.id, time, title)
+ SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
res.json(bookmark)
}
- // PATCH: api/me/item/:id/bookmark
+ /**
+ * PATCH: /api/me/item/:id/bookmark
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async updateBookmark(req, res) {
if (!(await Database.libraryItemModel.checkExistsById(req.params.id))) return res.sendStatus(404)
const { time, title } = req.body
- if (!req.user.findBookmark(req.params.id, time)) {
- Logger.error(`[MeController] updateBookmark not found`)
- return res.sendStatus(404)
+ if (isNullOrNaN(time)) {
+ Logger.error(`[MeController] updateBookmark invalid time`, time)
+ return res.status(400).send('Invalid time')
+ }
+ if (!title || typeof title !== 'string') {
+ Logger.error(`[MeController] updateBookmark invalid title`, title)
+ return res.status(400).send('Invalid title')
}
- const bookmark = req.user.updateBookmark(req.params.id, time, title)
- if (!bookmark) return res.sendStatus(500)
+ const bookmark = await req.user.updateBookmark(req.params.id, time, title)
+ if (!bookmark) {
+ Logger.error(`[MeController] updateBookmark not found for library item id "${req.params.id}" and time "${time}"`)
+ return res.sendStatus(404)
+ }
- await Database.updateUser(req.user)
- SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
+ SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
res.json(bookmark)
}
- // DELETE: api/me/item/:id/bookmark/:time
+ /**
+ * DELETE: /api/me/item/:id/bookmark/:time
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async removeBookmark(req, res) {
if (!(await Database.libraryItemModel.checkExistsById(req.params.id))) return res.sendStatus(404)
const time = Number(req.params.time)
- if (isNaN(time)) return res.sendStatus(500)
+ if (isNaN(time)) {
+ return res.status(400).send('Invalid time')
+ }
if (!req.user.findBookmark(req.params.id, time)) {
Logger.error(`[MeController] removeBookmark not found`)
return res.sendStatus(404)
}
- req.user.removeBookmark(req.params.id, time)
- await Database.updateUser(req.user)
- SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
+ await req.user.removeBookmark(req.params.id, time)
+
+ SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
res.sendStatus(200)
}
- // PATCH: api/me/password
+ /**
+ * PATCH: /api/me/password
+ * User change password. Requires current password.
+ * Guest users cannot change password.
+ *
+ * @this import('../routers/ApiRouter')
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
updatePassword(req, res) {
if (req.user.isGuest) {
- Logger.error(`[MeController] Guest user attempted to change password`, req.user.username)
+ Logger.error(`[MeController] Guest user "${req.user.username}" attempted to change password`)
return res.sendStatus(500)
}
this.auth.userChangePassword(req, res)
}
- // TODO: Deprecated. Removed from Android. Only used in iOS app now.
- // POST: api/me/sync-local-progress
- async syncLocalMediaProgress(req, res) {
- if (!req.body.localMediaProgress) {
- Logger.error(`[MeController] syncLocalMediaProgress invalid post body`)
- return res.sendStatus(500)
- }
- const updatedLocalMediaProgress = []
- let numServerProgressUpdates = 0
- const updatedServerMediaProgress = []
- const localMediaProgress = req.body.localMediaProgress || []
-
- for (const localProgress of localMediaProgress) {
- if (!localProgress.libraryItemId) {
- Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object`, localProgress)
- continue
- }
-
- const libraryItem = await Database.libraryItemModel.getOldById(localProgress.libraryItemId)
- if (!libraryItem) {
- Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object no library item with id "${localProgress.libraryItemId}"`, localProgress)
- continue
- }
-
- let mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId)
- if (!mediaProgress) {
- // New media progress from mobile
- Logger.debug(`[MeController] syncLocalMediaProgress local progress is new - creating ${localProgress.id}`)
- req.user.createUpdateMediaProgress(libraryItem, localProgress, localProgress.episodeId)
- mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId)
- if (mediaProgress) await Database.upsertMediaProgress(mediaProgress)
- updatedServerMediaProgress.push(mediaProgress)
- numServerProgressUpdates++
- } else if (mediaProgress.lastUpdate < localProgress.lastUpdate) {
- Logger.debug(`[MeController] syncLocalMediaProgress local progress is more recent - updating ${mediaProgress.id}`)
- req.user.createUpdateMediaProgress(libraryItem, localProgress, localProgress.episodeId)
- mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId)
- if (mediaProgress) await Database.upsertMediaProgress(mediaProgress)
- updatedServerMediaProgress.push(mediaProgress)
- numServerProgressUpdates++
- } else if (mediaProgress.lastUpdate > localProgress.lastUpdate) {
- const updateTimeDifference = mediaProgress.lastUpdate - localProgress.lastUpdate
- Logger.debug(`[MeController] syncLocalMediaProgress server progress is more recent by ${updateTimeDifference}ms - ${mediaProgress.id}`)
-
- for (const key in localProgress) {
- // Local media progress ID uses the local library item id and server media progress uses the library item id
- if (key !== 'id' && mediaProgress[key] != undefined && localProgress[key] !== mediaProgress[key]) {
- // Logger.debug(`[MeController] syncLocalMediaProgress key ${key} changed from ${localProgress[key]} to ${mediaProgress[key]} - ${mediaProgress.id}`)
- localProgress[key] = mediaProgress[key]
- }
- }
- updatedLocalMediaProgress.push(localProgress)
- } else {
- Logger.debug(`[MeController] syncLocalMediaProgress server and local are in sync - ${mediaProgress.id}`)
- }
- }
-
- Logger.debug(`[MeController] syncLocalMediaProgress server updates = ${numServerProgressUpdates}, local updates = ${updatedLocalMediaProgress.length}`)
- if (numServerProgressUpdates > 0) {
- SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
- }
-
- res.json({
- numServerProgressUpdates,
- localProgressUpdates: updatedLocalMediaProgress, // Array of LocalMediaProgress that were updated from server (server more recent)
- serverProgressUpdates: updatedServerMediaProgress // Array of MediaProgress that made updates to server (local more recent)
- })
- }
-
- // GET: api/me/items-in-progress
+ /**
+ * GET: /api/me/items-in-progress
+ * Pull items in progress for all libraries
+ * Used in Android Auto in progress list since there is no easy library selection
+ * TODO: Update to use mediaItemId and mediaItemType. Use sort & limit in query
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async getAllLibraryItemsInProgress(req, res) {
const limit = !isNaN(req.query.limit) ? Number(req.query.limit) || 25 : 25
+ const mediaProgressesInProgress = req.user.mediaProgresses.filter((mp) => !mp.isFinished && (mp.currentTime > 0 || mp.ebookProgress > 0))
+
+ const libraryItemsIds = [...new Set(mediaProgressesInProgress.map((mp) => mp.extraData?.libraryItemId).filter((id) => id))]
+ const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ id: libraryItemsIds })
+
let itemsInProgress = []
- // TODO: More efficient to do this in a single query
- for (const mediaProgress of req.user.mediaProgress) {
- if (!mediaProgress.isFinished && (mediaProgress.progress > 0 || mediaProgress.ebookProgress > 0)) {
- const libraryItem = await Database.libraryItemModel.getOldById(mediaProgress.libraryItemId)
- if (libraryItem) {
- if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
- const episode = libraryItem.media.episodes.find((ep) => ep.id === mediaProgress.episodeId)
- if (episode) {
- const libraryItemWithEpisode = {
- ...libraryItem.toJSONMinified(),
- recentEpisode: episode.toJSON(),
- progressLastUpdate: mediaProgress.lastUpdate
- }
- itemsInProgress.push(libraryItemWithEpisode)
- }
- } else if (!mediaProgress.episodeId) {
- itemsInProgress.push({
+
+ for (const mediaProgress of mediaProgressesInProgress) {
+ const oldMediaProgress = mediaProgress.getOldMediaProgress()
+ const libraryItem = libraryItems.find((li) => li.id === oldMediaProgress.libraryItemId)
+ if (libraryItem) {
+ if (oldMediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
+ const episode = libraryItem.media.episodes.find((ep) => ep.id === oldMediaProgress.episodeId)
+ if (episode) {
+ const libraryItemWithEpisode = {
...libraryItem.toJSONMinified(),
- progressLastUpdate: mediaProgress.lastUpdate
- })
+ recentEpisode: episode.toJSON(),
+ progressLastUpdate: oldMediaProgress.lastUpdate
+ }
+ itemsInProgress.push(libraryItemWithEpisode)
}
+ } else if (!oldMediaProgress.episodeId) {
+ itemsInProgress.push({
+ ...libraryItem.toJSONMinified(),
+ progressLastUpdate: oldMediaProgress.lastUpdate
+ })
}
}
}
@@ -321,59 +331,67 @@ class MeController {
})
}
- // GET: api/me/series/:id/remove-from-continue-listening
+ /**
+ * GET: /api/me/series/:id/remove-from-continue-listening
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async removeSeriesFromContinueListening(req, res) {
- const series = await Database.seriesModel.getOldById(req.params.id)
- if (!series) {
+ if (!(await Database.seriesModel.checkExistsById(req.params.id))) {
Logger.error(`[MeController] removeSeriesFromContinueListening: Series ${req.params.id} not found`)
return res.sendStatus(404)
}
- const hasUpdated = req.user.addSeriesToHideFromContinueListening(req.params.id)
+ const hasUpdated = await req.user.addSeriesToHideFromContinueListening(req.params.id)
if (hasUpdated) {
- await Database.updateUser(req.user)
- SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
+ SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
}
- res.json(req.user.toJSONForBrowser())
+ res.json(req.user.toOldJSONForBrowser())
}
- // GET: api/me/series/:id/readd-to-continue-listening
+ /**
+ * GET: api/me/series/:id/readd-to-continue-listening
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async readdSeriesFromContinueListening(req, res) {
- const series = await Database.seriesModel.getOldById(req.params.id)
- if (!series) {
+ if (!(await Database.seriesModel.checkExistsById(req.params.id))) {
Logger.error(`[MeController] readdSeriesFromContinueListening: Series ${req.params.id} not found`)
return res.sendStatus(404)
}
- const hasUpdated = req.user.removeSeriesFromHideFromContinueListening(req.params.id)
+ const hasUpdated = await req.user.removeSeriesFromHideFromContinueListening(req.params.id)
if (hasUpdated) {
- await Database.updateUser(req.user)
- SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
+ SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
}
- res.json(req.user.toJSONForBrowser())
+ res.json(req.user.toOldJSONForBrowser())
}
- // GET: api/me/progress/:id/remove-from-continue-listening
+ /**
+ * GET: api/me/progress/:id/remove-from-continue-listening
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async removeItemFromContinueListening(req, res) {
- const mediaProgress = req.user.mediaProgress.find((mp) => mp.id === req.params.id)
+ const mediaProgress = req.user.mediaProgresses.find((mp) => mp.id === req.params.id)
if (!mediaProgress) {
return res.sendStatus(404)
}
- const hasUpdated = req.user.removeProgressFromContinueListening(req.params.id)
- if (hasUpdated) {
- await Database.mediaProgressModel.update(
- {
- hideFromContinueListening: true
- },
- {
- where: {
- id: mediaProgress.id
- }
- }
- )
- SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
+
+ // Already hidden
+ if (mediaProgress.hideFromContinueListening) {
+ return res.json(req.user.toOldJSONForBrowser())
}
- res.json(req.user.toJSONForBrowser())
+
+ mediaProgress.hideFromContinueListening = true
+ await mediaProgress.save()
+
+ SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
+
+ res.json(req.user.toOldJSONForBrowser())
}
/**
@@ -388,7 +406,7 @@ class MeController {
Logger.error(`[MeController] Invalid year "${year}"`)
return res.status(400).send('Invalid year')
}
- const data = await userStats.getStatsForYear(req.user, year)
+ const data = await userStats.getStatsForYear(req.user.id, year)
res.json(data)
}
}
diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js
index 8bf0c31ec5..ac6afff727 100644
--- a/server/controllers/MiscController.js
+++ b/server/controllers/MiscController.js
@@ -1,5 +1,6 @@
const Sequelize = require('sequelize')
const Path = require('path')
+const { Request, Response } = require('express')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
@@ -13,21 +14,26 @@ const { sanitizeFilename } = require('../utils/fileUtils')
const TaskManager = require('../managers/TaskManager')
const adminStats = require('../utils/queries/adminStats')
-//
-// This is a controller for routes that don't have a home yet :(
-//
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
+
class MiscController {
- constructor() { }
+ constructor() {}
/**
* POST: /api/upload
* Update library item
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async handleUpload(req, res) {
if (!req.user.canUpload) {
- Logger.warn('User attempted to upload without permission', req.user)
+ Logger.warn(`User "${req.user.username}" attempted to upload without permission`)
return res.sendStatus(403)
}
if (!req.files) {
@@ -42,7 +48,7 @@ class MiscController {
if (!library) {
return res.status(404).send(`Library not found with id ${libraryId}`)
}
- const folder = library.folders.find(fold => fold.id === folderId)
+ const folder = library.folders.find((fold) => fold.id === folderId)
if (!folder) {
return res.status(404).send(`Folder not found with id ${folderId} in library ${library.name}`)
}
@@ -56,7 +62,7 @@ class MiscController {
// `.filter(Boolean)` to strip out all the potentially missing details (eg: `author`)
// before sanitizing all the directory parts to remove illegal chars and finally prepending
// the base folder path
- const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map(part => sanitizeFilename(part))
+ const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map((part) => sanitizeFilename(part))
const outputDirectory = Path.join(...[folder.fullPath, ...cleanedOutputDirectoryParts])
await fs.ensureDir(outputDirectory)
@@ -66,7 +72,8 @@ class MiscController {
for (const file of files) {
const path = Path.join(outputDirectory, sanitizeFilename(file.name))
- await file.mv(path)
+ await file
+ .mv(path)
.then(() => {
return true
})
@@ -82,14 +89,15 @@ class MiscController {
/**
* GET: /api/tasks
* Get tasks for task manager
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
getTasks(req, res) {
const includeArray = (req.query.include || '').split(',')
const data = {
- tasks: TaskManager.tasks.map(t => t.toJSON())
+ tasks: TaskManager.tasks.map((t) => t.toJSON())
}
if (includeArray.includes('queue')) {
@@ -104,13 +112,13 @@ class MiscController {
/**
* PATCH: /api/settings
* Update server settings
- *
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async updateServerSettings(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error('User other than admin attempting to update server settings', req.user)
+ Logger.error(`User "${req.user.username}" other than admin attempting to update server settings`)
return res.sendStatus(403)
}
const settingsUpdate = req.body
@@ -135,20 +143,20 @@ class MiscController {
/**
* PATCH: /api/sorting-prefixes
- *
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async updateSortingPrefixes(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error('User other than admin attempting to update server sorting prefixes', req.user)
+ Logger.error(`User "${req.user.username}" other than admin attempting to update server sorting prefixes`)
return res.sendStatus(403)
}
let sortingPrefixes = req.body.sortingPrefixes
if (!sortingPrefixes?.length || !Array.isArray(sortingPrefixes)) {
return res.status(400).send('Invalid request body')
}
- sortingPrefixes = [...new Set(sortingPrefixes.map(p => p?.trim?.().toLowerCase()).filter(p => p))]
+ sortingPrefixes = [...new Set(sortingPrefixes.map((p) => p?.trim?.().toLowerCase()).filter((p) => p))]
if (!sortingPrefixes.length) {
return res.status(400).send('Invalid sortingPrefixes in request body')
}
@@ -233,15 +241,13 @@ class MiscController {
/**
* POST: /api/authorize
* Used to authorize an API token
- *
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @this import('../routers/ApiRouter')
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async authorize(req, res) {
- if (!req.user) {
- Logger.error('Invalid user in authorize')
- return res.sendStatus(401)
- }
const userResponse = await this.auth.getUserLoginResponsePayload(req.user)
res.json(userResponse)
}
@@ -249,13 +255,14 @@ class MiscController {
/**
* GET: /api/tags
* Get all tags
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getAllTags(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[MiscController] Non-admin user attempted to getAllTags`)
- return res.sendStatus(404)
+ Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to getAllTags`)
+ return res.sendStatus(403)
}
const tags = []
@@ -292,13 +299,14 @@ class MiscController {
* POST: /api/tags/rename
* Rename tag
* Req.body { tag, newTag }
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async renameTag(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[MiscController] Non-admin user attempted to renameTag`)
- return res.sendStatus(404)
+ Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to renameTag`)
+ return res.sendStatus(403)
}
const tag = req.body.tag
@@ -321,7 +329,7 @@ class MiscController {
}
if (libraryItem.media.tags.includes(tag)) {
- libraryItem.media.tags = libraryItem.media.tags.filter(t => t !== tag) // Remove old tag
+ libraryItem.media.tags = libraryItem.media.tags.filter((t) => t !== tag) // Remove old tag
if (!libraryItem.media.tags.includes(newTag)) {
libraryItem.media.tags.push(newTag)
}
@@ -346,13 +354,14 @@ class MiscController {
* DELETE: /api/tags/:tag
* Remove a tag
* :tag param is base64 encoded
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async deleteTag(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[MiscController] Non-admin user attempted to deleteTag`)
- return res.sendStatus(404)
+ Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to deleteTag`)
+ return res.sendStatus(403)
}
const tag = Buffer.from(decodeURIComponent(req.params.tag), 'base64').toString()
@@ -367,7 +376,7 @@ class MiscController {
// Remove tag from items
for (const libraryItem of libraryItemsWithTag) {
Logger.debug(`[MiscController] Remove tag "${tag}" from item "${libraryItem.media.title}"`)
- libraryItem.media.tags = libraryItem.media.tags.filter(t => t !== tag)
+ libraryItem.media.tags = libraryItem.media.tags.filter((t) => t !== tag)
await libraryItem.media.update({
tags: libraryItem.media.tags
})
@@ -385,13 +394,14 @@ class MiscController {
/**
* GET: /api/genres
* Get all genres
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getAllGenres(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[MiscController] Non-admin user attempted to getAllGenres`)
- return res.sendStatus(404)
+ Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to getAllGenres`)
+ return res.sendStatus(403)
}
const genres = []
const books = await Database.bookModel.findAll({
@@ -427,13 +437,14 @@ class MiscController {
* POST: /api/genres/rename
* Rename genres
* Req.body { genre, newGenre }
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async renameGenre(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[MiscController] Non-admin user attempted to renameGenre`)
- return res.sendStatus(404)
+ Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to renameGenre`)
+ return res.sendStatus(403)
}
const genre = req.body.genre
@@ -456,7 +467,7 @@ class MiscController {
}
if (libraryItem.media.genres.includes(genre)) {
- libraryItem.media.genres = libraryItem.media.genres.filter(t => t !== genre) // Remove old genre
+ libraryItem.media.genres = libraryItem.media.genres.filter((t) => t !== genre) // Remove old genre
if (!libraryItem.media.genres.includes(newGenre)) {
libraryItem.media.genres.push(newGenre)
}
@@ -481,13 +492,14 @@ class MiscController {
* DELETE: /api/genres/:genre
* Remove a genre
* :genre param is base64 encoded
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async deleteGenre(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[MiscController] Non-admin user attempted to deleteGenre`)
- return res.sendStatus(404)
+ Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to deleteGenre`)
+ return res.sendStatus(403)
}
const genre = Buffer.from(decodeURIComponent(req.params.genre), 'base64').toString()
@@ -502,7 +514,7 @@ class MiscController {
// Remove genre from items
for (const libraryItem of libraryItemsWithGenre) {
Logger.debug(`[MiscController] Remove genre "${genre}" from item "${libraryItem.media.title}"`)
- libraryItem.media.genres = libraryItem.media.genres.filter(g => g !== genre)
+ libraryItem.media.genres = libraryItem.media.genres.filter((g) => g !== genre)
await libraryItem.media.update({
genres: libraryItem.media.genres
})
@@ -520,18 +532,19 @@ class MiscController {
/**
* POST: /api/watcher/update
* Update a watch path
- * Req.body { libraryId, path, type, [oldPath] }
+ * Req.body { libraryId, path, type, [oldPath] }
* type = add, unlink, rename
* oldPath = required only for rename
+ *
* @this import('../routers/ApiRouter')
- *
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
updateWatchedPath(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[MiscController] Non-admin user attempted to updateWatchedPath`)
- return res.sendStatus(404)
+ Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to updateWatchedPath`)
+ return res.sendStatus(403)
}
const libraryId = req.body.libraryId
@@ -582,9 +595,9 @@ class MiscController {
/**
* GET: api/auth-settings (admin only)
- *
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
getAuthSettings(req, res) {
if (!req.user.isAdminOrUp) {
@@ -597,9 +610,9 @@ class MiscController {
/**
* PATCH: api/auth-settings
* @this import('../routers/ApiRouter')
- *
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async updateAuthSettings(req, res) {
if (!req.user.isAdminOrUp) {
@@ -642,15 +655,13 @@ class MiscController {
}
const uris = settingsUpdate[key]
- if (!Array.isArray(uris) ||
- (uris.includes('*') && uris.length > 1) ||
- uris.some(uri => uri !== '*' && !isValidRedirectURI(uri))) {
+ if (!Array.isArray(uris) || (uris.includes('*') && uris.length > 1) || uris.some((uri) => uri !== '*' && !isValidRedirectURI(uri))) {
Logger.warn(`[MiscController] Invalid value for authOpenIDMobileRedirectURIs`)
continue
}
// Update the URIs
- if (Database.serverSettings[key].some(uri => !uris.includes(uri)) || uris.some(uri => !Database.serverSettings[key].includes(uri))) {
+ if (Database.serverSettings[key].some((uri) => !uris.includes(uri)) || uris.some((uri) => !Database.serverSettings[key].includes(uri))) {
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${Database.serverSettings[key]}" to "${uris}"`)
Database.serverSettings[key] = uris
hasUpdates = true
@@ -704,9 +715,9 @@ class MiscController {
/**
* GET: /api/stats/year/:year
- *
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getAdminStatsForYear(req, res) {
if (!req.user.isAdminOrUp) {
@@ -725,9 +736,9 @@ class MiscController {
/**
* GET: /api/logger-data
* admin or up
- *
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getLoggerData(req, res) {
if (!req.user.isAdminOrUp) {
diff --git a/server/controllers/NotificationController.js b/server/controllers/NotificationController.js
index 8b94a9bbc1..215afe0ab1 100644
--- a/server/controllers/NotificationController.js
+++ b/server/controllers/NotificationController.js
@@ -1,10 +1,26 @@
-const Logger = require('../Logger')
+const { Request, Response, NextFunction } = require('express')
const Database = require('../Database')
const { version } = require('../../package.json')
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
+
class NotificationController {
- constructor() { }
+ constructor() {}
+ /**
+ * GET: /api/notifications
+ * Get notifications, settings and data
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
get(req, res) {
res.json({
data: this.notificationManager.getData(),
@@ -12,6 +28,12 @@ class NotificationController {
})
}
+ /**
+ * PATCH: /api/notifications
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async update(req, res) {
const updated = Database.notificationSettings.update(req.body)
if (updated) {
@@ -20,15 +42,38 @@ class NotificationController {
res.sendStatus(200)
}
+ /**
+ * GET: /api/notificationdata
+ * @deprecated Use /api/notifications
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
getData(req, res) {
res.json(this.notificationManager.getData())
}
+ /**
+ * GET: /api/notifications/test
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async fireTestEvent(req, res) {
await this.notificationManager.triggerNotification('onTest', { version: `v${version}` }, req.query.fail === '1')
res.sendStatus(200)
}
+ /**
+ * POST: /api/notifications
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async createNotification(req, res) {
const success = Database.notificationSettings.createNotification(req.body)
@@ -38,6 +83,12 @@ class NotificationController {
res.json(Database.notificationSettings)
}
+ /**
+ * DELETE: /api/notifications/:id
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async deleteNotification(req, res) {
if (Database.notificationSettings.removeNotification(req.notification.id)) {
await Database.updateSetting(Database.notificationSettings)
@@ -45,6 +96,12 @@ class NotificationController {
res.json(Database.notificationSettings)
}
+ /**
+ * PATCH: /api/notifications/:id
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async updateNotification(req, res) {
const success = Database.notificationSettings.updateNotification(req.body)
if (success) {
@@ -53,17 +110,32 @@ class NotificationController {
res.json(Database.notificationSettings)
}
+ /**
+ * GET: /api/notifications/:id/test
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async sendNotificationTest(req, res) {
- if (!Database.notificationSettings.isUseable) return res.status(500).send('Apprise is not configured')
+ if (!Database.notificationSettings.isUseable) return res.status(400).send('Apprise is not configured')
const success = await this.notificationManager.sendTestNotification(req.notification)
if (success) res.sendStatus(200)
else res.sendStatus(500)
}
+ /**
+ * Requires admin or up
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ * @param {NextFunction} next
+ */
middleware(req, res, next) {
if (!req.user.isAdminOrUp) {
- return res.sendStatus(404)
+ return res.sendStatus(403)
}
if (req.params.id) {
@@ -77,4 +149,4 @@ class NotificationController {
next()
}
}
-module.exports = new NotificationController()
\ No newline at end of file
+module.exports = new NotificationController()
diff --git a/server/controllers/PlaylistController.js b/server/controllers/PlaylistController.js
index c501f287ac..5b84fe16f5 100644
--- a/server/controllers/PlaylistController.js
+++ b/server/controllers/PlaylistController.js
@@ -1,17 +1,26 @@
+const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const Playlist = require('../objects/Playlist')
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
+
class PlaylistController {
- constructor() { }
+ constructor() {}
/**
* POST: /api/playlists
* Create playlist
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async create(req, res) {
const oldPlaylist = new Playlist()
@@ -25,7 +34,7 @@ class PlaylistController {
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
// Lookup all library items in playlist
- const libraryItemIds = oldPlaylist.items.map(i => i.libraryItemId).filter(i => i)
+ const libraryItemIds = oldPlaylist.items.map((i) => i.libraryItemId).filter((i) => i)
const libraryItemsInPlaylist = await Database.libraryItemModel.findAll({
where: {
id: libraryItemIds
@@ -36,7 +45,7 @@ class PlaylistController {
const mediaItemsToAdd = []
let order = 1
for (const mediaItemObj of oldPlaylist.items) {
- const libraryItem = libraryItemsInPlaylist.find(li => li.id === mediaItemObj.libraryItemId)
+ const libraryItem = libraryItemsInPlaylist.find((li) => li.id === mediaItemObj.libraryItemId)
if (!libraryItem) continue
mediaItemsToAdd.push({
@@ -58,8 +67,9 @@ class PlaylistController {
/**
* GET: /api/playlists
* Get all playlists for user
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async findAllForUser(req, res) {
const playlistsForUser = await Database.playlistModel.findAll({
@@ -79,8 +89,9 @@ class PlaylistController {
/**
* GET: /api/playlists/:id
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async findOne(req, res) {
const jsonExpanded = await req.playlist.getOldJsonExpanded()
@@ -90,8 +101,9 @@ class PlaylistController {
/**
* PATCH: /api/playlists/:id
* Update playlist
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async update(req, res) {
const updatedPlaylist = req.playlist.set(req.body)
@@ -104,7 +116,7 @@ class PlaylistController {
}
// If array of items is passed in then update order of playlist media items
- const libraryItemIds = req.body.items?.map(i => i.libraryItemId).filter(i => i) || []
+ const libraryItemIds = req.body.items?.map((i) => i.libraryItemId).filter((i) => i) || []
if (libraryItemIds.length) {
const libraryItems = await Database.libraryItemModel.findAll({
where: {
@@ -118,7 +130,7 @@ class PlaylistController {
// Set an array of mediaItemId
const newMediaItemIdOrder = []
for (const item of req.body.items) {
- const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
+ const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
if (!libraryItem) {
continue
}
@@ -128,8 +140,8 @@ class PlaylistController {
// Sort existing playlist media items into new order
existingPlaylistMediaItems.sort((a, b) => {
- const aIndex = newMediaItemIdOrder.findIndex(i => i === a.mediaItemId)
- const bIndex = newMediaItemIdOrder.findIndex(i => i === b.mediaItemId)
+ const aIndex = newMediaItemIdOrder.findIndex((i) => i === a.mediaItemId)
+ const bIndex = newMediaItemIdOrder.findIndex((i) => i === b.mediaItemId)
return aIndex - bIndex
})
@@ -156,8 +168,9 @@ class PlaylistController {
/**
* DELETE: /api/playlists/:id
* Remove playlist
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async delete(req, res) {
const jsonExpanded = await req.playlist.getOldJsonExpanded()
@@ -169,8 +182,9 @@ class PlaylistController {
/**
* POST: /api/playlists/:id/item
* Add item to playlist
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async addItem(req, res) {
const oldPlaylist = await Database.playlistModel.getById(req.playlist.id)
@@ -213,8 +227,9 @@ class PlaylistController {
/**
* DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId?
* Remove item from playlist
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async removeItem(req, res) {
const oldLibraryItem = await Database.libraryItemModel.getOldById(req.params.libraryItemId)
@@ -229,7 +244,7 @@ class PlaylistController {
})
// Check if media item to delete is in playlist
- const mediaItemToRemove = playlistMediaItems.find(pmi => pmi.mediaItemId === mediaItemId)
+ const mediaItemToRemove = playlistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId)
if (!mediaItemToRemove) {
return res.status(404).send('Media item not found in playlist')
}
@@ -266,8 +281,9 @@ class PlaylistController {
/**
* POST: /api/playlists/:id/batch/add
* Batch add playlist items
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async addBatch(req, res) {
if (!req.body.items?.length) {
@@ -275,7 +291,7 @@ class PlaylistController {
}
const itemsToAdd = req.body.items
- const libraryItemIds = itemsToAdd.map(i => i.libraryItemId).filter(i => i)
+ const libraryItemIds = itemsToAdd.map((i) => i.libraryItemId).filter((i) => i)
if (!libraryItemIds.length) {
return res.status(400).send('Invalid request body')
}
@@ -297,12 +313,12 @@ class PlaylistController {
// Setup array of playlistMediaItem records to add
let order = existingPlaylistMediaItems.length + 1
for (const item of itemsToAdd) {
- const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
+ const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
if (!libraryItem) {
return res.status(404).send('Item not found with id ' + item.libraryItemId)
} else {
const mediaItemId = item.episodeId || libraryItem.mediaId
- if (existingPlaylistMediaItems.some(pmi => pmi.mediaItemId === mediaItemId)) {
+ if (existingPlaylistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) {
// Already exists in playlist
continue
} else {
@@ -330,8 +346,9 @@ class PlaylistController {
/**
* POST: /api/playlists/:id/batch/remove
* Batch remove playlist items
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async removeBatch(req, res) {
if (!req.body.items?.length) {
@@ -339,7 +356,7 @@ class PlaylistController {
}
const itemsToRemove = req.body.items
- const libraryItemIds = itemsToRemove.map(i => i.libraryItemId).filter(i => i)
+ const libraryItemIds = itemsToRemove.map((i) => i.libraryItemId).filter((i) => i)
if (!libraryItemIds.length) {
return res.status(400).send('Invalid request body')
}
@@ -360,10 +377,10 @@ class PlaylistController {
// Remove playlist media items
let hasUpdated = false
for (const item of itemsToRemove) {
- const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
+ const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
if (!libraryItem) continue
const mediaItemId = item.episodeId || libraryItem.mediaId
- const existingMediaItem = existingPlaylistMediaItems.find(pmi => pmi.mediaItemId === mediaItemId)
+ const existingMediaItem = existingPlaylistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId)
if (!existingMediaItem) continue
await existingMediaItem.destroy()
hasUpdated = true
@@ -387,8 +404,9 @@ class PlaylistController {
/**
* POST: /api/playlists/collection/:collectionId
* Create a playlist from a collection
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async createFromCollection(req, res) {
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
@@ -436,6 +454,12 @@ class PlaylistController {
res.json(jsonExpanded)
}
+ /**
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ * @param {NextFunction} next
+ */
async middleware(req, res, next) {
if (req.params.id) {
const playlist = await Database.playlistModel.findByPk(req.params.id)
@@ -452,4 +476,4 @@ class PlaylistController {
next()
}
}
-module.exports = new PlaylistController()
\ No newline at end of file
+module.exports = new PlaylistController()
diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js
index 1198548641..30688c7687 100644
--- a/server/controllers/PodcastController.js
+++ b/server/controllers/PodcastController.js
@@ -1,3 +1,4 @@
+const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
@@ -13,7 +14,23 @@ const CoverManager = require('../managers/CoverManager')
const LibraryItem = require('../objects/LibraryItem')
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
+
class PodcastController {
+ /**
+ * POST /api/podcasts
+ * Create podcast
+ *
+ * @this import('../routers/ApiRouter')
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async create(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to create podcast`)
@@ -112,8 +129,8 @@ class PodcastController {
* @typedef getPodcastFeedReqBody
* @property {string} rssFeed
*
- * @param {import('express').Request<{}, {}, getPodcastFeedReqBody, {}} req
- * @param {import('express').Response} res
+ * @param {Request<{}, {}, getPodcastFeedReqBody, {}> & RequestUserObject} req
+ * @param {Response} res
*/
async getPodcastFeed(req, res) {
if (!req.user.isAdminOrUp) {
@@ -133,6 +150,14 @@ class PodcastController {
res.json({ podcast })
}
+ /**
+ * POST: /api/podcasts/opml
+ *
+ * @this import('../routers/ApiRouter')
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async getFeedsFromOPMLText(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to get feeds from opml`)
@@ -143,13 +168,57 @@ class PodcastController {
return res.sendStatus(400)
}
- const rssFeedsData = await this.podcastManager.getOPMLFeeds(req.body.opmlText)
- res.json(rssFeedsData)
+ res.json({
+ feeds: this.podcastManager.getParsedOPMLFileFeeds(req.body.opmlText)
+ })
}
+ /**
+ * POST: /api/podcasts/opml/create
+ *
+ * @this import('../routers/ApiRouter')
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
+ async bulkCreatePodcastsFromOpmlFeedUrls(req, res) {
+ if (!req.user.isAdminOrUp) {
+ Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to bulk create podcasts`)
+ return res.sendStatus(403)
+ }
+
+ const rssFeeds = req.body.feeds
+ if (!Array.isArray(rssFeeds) || !rssFeeds.length || rssFeeds.some((feed) => !validateUrl(feed))) {
+ return res.status(400).send('Invalid request body. "feeds" must be an array of RSS feed URLs')
+ }
+
+ const libraryId = req.body.libraryId
+ const folderId = req.body.folderId
+ if (!libraryId || !folderId) {
+ return res.status(400).send('Invalid request body. "libraryId" and "folderId" are required')
+ }
+
+ const folder = await Database.libraryFolderModel.findByPk(folderId)
+ if (!folder || folder.libraryId !== libraryId) {
+ return res.status(404).send('Folder not found')
+ }
+ const autoDownloadEpisodes = !!req.body.autoDownloadEpisodes
+ this.podcastManager.createPodcastsFromFeedUrls(rssFeeds, folder, autoDownloadEpisodes, this.cronManager)
+
+ res.sendStatus(200)
+ }
+
+ /**
+ * GET: /api/podcasts/:id/checknew
+ *
+ * @this import('../routers/ApiRouter')
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async checkNewEpisodes(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[PodcastController] Non-admin user attempted to check/download episodes`, req.user)
+ Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to check/download episodes`)
return res.sendStatus(403)
}
@@ -167,15 +236,31 @@ class PodcastController {
})
}
+ /**
+ * GET: /api/podcasts/:id/clear-queue
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
clearEpisodeDownloadQueue(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[PodcastController] Non-admin user attempting to clear download queue "${req.user.username}"`)
+ Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempting to clear download queue`)
return res.sendStatus(403)
}
this.podcastManager.clearDownloadQueue(req.params.id)
res.sendStatus(200)
}
+ /**
+ * GET: /api/podcasts/:id/downloads
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
getEpisodeDownloads(req, res) {
var libraryItem = req.libraryItem
@@ -202,9 +287,17 @@ class PodcastController {
})
}
+ /**
+ * POST: /api/podcasts/:id/download-episodes
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async downloadEpisodes(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user)
+ Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to download episodes`)
return res.sendStatus(403)
}
const libraryItem = req.libraryItem
@@ -217,10 +310,17 @@ class PodcastController {
res.sendStatus(200)
}
- // POST: api/podcasts/:id/match-episodes
+ /**
+ * POST: /api/podcasts/:id/match-episodes
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async quickMatchEpisodes(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user)
+ Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to download episodes`)
return res.sendStatus(403)
}
@@ -236,6 +336,12 @@ class PodcastController {
})
}
+ /**
+ * PATCH: /api/podcasts/:id/episode/:episodeId
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async updateEpisode(req, res) {
const libraryItem = req.libraryItem
@@ -252,7 +358,12 @@ class PodcastController {
res.json(libraryItem.toJSONExpanded())
}
- // GET: api/podcasts/:id/episode/:episodeId
+ /**
+ * GET: /api/podcasts/:id/episode/:episodeId
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async getEpisode(req, res) {
const episodeId = req.params.episodeId
const libraryItem = req.libraryItem
@@ -266,7 +377,12 @@ class PodcastController {
res.json(episode)
}
- // DELETE: api/podcasts/:id/episode/:episodeId
+ /**
+ * DELETE: /api/podcasts/:id/episode/:episodeId
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async removeEpisode(req, res) {
const episodeId = req.params.episodeId
const libraryItem = req.libraryItem
@@ -337,6 +453,12 @@ class PodcastController {
res.json(libraryItem.toJSON())
}
+ /**
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ * @param {NextFunction} next
+ */
async middleware(req, res, next) {
const item = await Database.libraryItemModel.getOldById(req.params.id)
if (!item?.media) return res.sendStatus(404)
@@ -351,10 +473,10 @@ class PodcastController {
}
if (req.method == 'DELETE' && !req.user.canDelete) {
- Logger.warn(`[PodcastController] User attempted to delete without permission`, req.user.username)
+ Logger.warn(`[PodcastController] User "${req.user.username}" attempted to delete without permission`)
return res.sendStatus(403)
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
- Logger.warn('[PodcastController] User attempted to update without permission', req.user.username)
+ Logger.warn(`[PodcastController] User "${req.user.username}" attempted to update without permission`)
return res.sendStatus(403)
}
diff --git a/server/controllers/RSSFeedController.js b/server/controllers/RSSFeedController.js
index 9b7acf706a..5c7cc2a044 100644
--- a/server/controllers/RSSFeedController.js
+++ b/server/controllers/RSSFeedController.js
@@ -1,19 +1,42 @@
+const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const Database = require('../Database')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
-class RSSFeedController {
- constructor() { }
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
+class RSSFeedController {
+ constructor() {}
+
+ /**
+ * GET: /api/feeds
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async getAll(req, res) {
const feeds = await this.rssFeedManager.getFeeds()
res.json({
- feeds: feeds.map(f => f.toJSON()),
- minified: feeds.map(f => f.toJSONMinified())
+ feeds: feeds.map((f) => f.toJSON()),
+ minified: feeds.map((f) => f.toJSONMinified())
})
}
- // POST: api/feeds/item/:itemId/open
+ /**
+ * POST: /api/feeds/item/:itemId/open
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async openRSSFeedForItem(req, res) {
const options = req.body || {}
@@ -44,13 +67,20 @@ class RSSFeedController {
return res.status(400).send('Slug already in use')
}
- const feed = await this.rssFeedManager.openFeedForItem(req.user, item, req.body)
+ const feed = await this.rssFeedManager.openFeedForItem(req.user.id, item, req.body)
res.json({
feed: feed.toJSONMinified()
})
}
- // POST: api/feeds/collection/:collectionId/open
+ /**
+ * POST: /api/feeds/collection/:collectionId/open
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async openRSSFeedForCollection(req, res) {
const options = req.body || {}
@@ -70,7 +100,7 @@ class RSSFeedController {
}
const collectionExpanded = await collection.getOldJsonExpanded()
- const collectionItemsWithTracks = collectionExpanded.books.filter(li => li.media.tracks.length)
+ const collectionItemsWithTracks = collectionExpanded.books.filter((li) => li.media.tracks.length)
// Check collection has audio tracks
if (!collectionItemsWithTracks.length) {
@@ -78,13 +108,20 @@ class RSSFeedController {
return res.status(400).send('Collection has no audio tracks')
}
- const feed = await this.rssFeedManager.openFeedForCollection(req.user, collectionExpanded, req.body)
+ const feed = await this.rssFeedManager.openFeedForCollection(req.user.id, collectionExpanded, req.body)
res.json({
feed: feed.toJSONMinified()
})
}
- // POST: api/feeds/series/:seriesId/open
+ /**
+ * POST: /api/feeds/series/:seriesId/open
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async openRSSFeedForSeries(req, res) {
const options = req.body || {}
@@ -106,7 +143,7 @@ class RSSFeedController {
const seriesJson = series.toJSON()
// Get books in series that have audio tracks
- seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter(li => li.media.numTracks)
+ seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
// Check series has audio tracks
if (!seriesJson.books.length) {
@@ -114,20 +151,34 @@ class RSSFeedController {
return res.status(400).send('Series has no audio tracks')
}
- const feed = await this.rssFeedManager.openFeedForSeries(req.user, seriesJson, req.body)
+ const feed = await this.rssFeedManager.openFeedForSeries(req.user.id, seriesJson, req.body)
res.json({
feed: feed.toJSONMinified()
})
}
- // POST: api/feeds/:id/close
+ /**
+ * POST: /api/feeds/:id/close
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
closeRSSFeed(req, res) {
this.rssFeedManager.closeRssFeed(req, res)
}
+ /**
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ * @param {NextFunction} next
+ */
middleware(req, res, next) {
- if (!req.user.isAdminOrUp) { // Only admins can manage rss feeds
- Logger.error(`[RSSFeedController] Non-admin user attempted to make a request to an RSS feed route`, req.user.username)
+ if (!req.user.isAdminOrUp) {
+ // Only admins can manage rss feeds
+ Logger.error(`[RSSFeedController] Non-admin user "${req.user.username}" attempted to make a request to an RSS feed route`)
return res.sendStatus(403)
}
diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js
index b0aebb31de..cfe4e6d3ea 100644
--- a/server/controllers/SearchController.js
+++ b/server/controllers/SearchController.js
@@ -1,3 +1,4 @@
+const { Request, Response } = require('express')
const Logger = require('../Logger')
const BookFinder = require('../finders/BookFinder')
const PodcastFinder = require('../finders/PodcastFinder')
@@ -6,25 +7,50 @@ const MusicFinder = require('../finders/MusicFinder')
const Database = require('../Database')
const { isValidASIN } = require('../utils')
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
+
class SearchController {
constructor() {}
+ /**
+ * GET: /api/search/books
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async findBooks(req, res) {
const id = req.query.id
const libraryItem = await Database.libraryItemModel.getOldById(id)
const provider = req.query.provider || 'google'
const title = req.query.title || ''
const author = req.query.author || ''
+
+ if (typeof provider !== 'string' || typeof title !== 'string' || typeof author !== 'string') {
+ Logger.error(`[SearchController] findBooks: Invalid request query params`)
+ return res.status(400).send('Invalid request query params')
+ }
+
const results = await BookFinder.search(libraryItem, provider, title, author)
res.json(results)
}
+ /**
+ * GET: /api/search/covers
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async findCovers(req, res) {
const query = req.query
const podcast = query.podcast == 1
- if (!query.title) {
- Logger.error(`[SearchController] findCovers: No title sent in query`)
+ if (!query.title || typeof query.title !== 'string') {
+ Logger.error(`[SearchController] findCovers: Invalid title sent in query`)
return res.sendStatus(400)
}
@@ -37,10 +63,11 @@ class SearchController {
}
/**
+ * GET: /api/search/podcasts
* Find podcast RSS feeds given a term
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async findPodcasts(req, res) {
const term = req.query.term
@@ -56,12 +83,29 @@ class SearchController {
res.json(results)
}
+ /**
+ * GET: /api/search/authors
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async findAuthor(req, res) {
const query = req.query.q
+ if (!query || typeof query !== 'string') {
+ Logger.error(`[SearchController] findAuthor: Invalid query param`)
+ return res.status(400).send('Invalid query param')
+ }
+
const author = await AuthorFinder.findAuthorByName(query)
res.json(author)
}
+ /**
+ * GET: /api/search/chapters
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async findChapters(req, res) {
const asin = req.query.asin
if (!isValidASIN(asin.toUpperCase())) {
@@ -74,12 +118,5 @@ class SearchController {
}
res.json(chapterData)
}
-
- async findMusicTrack(req, res) {
- const tracks = await MusicFinder.searchTrack(req.query || {})
- res.json({
- tracks
- })
- }
}
module.exports = new SearchController()
diff --git a/server/controllers/SeriesController.js b/server/controllers/SeriesController.js
index 38ab3da9d5..54b0453855 100644
--- a/server/controllers/SeriesController.js
+++ b/server/controllers/SeriesController.js
@@ -1,36 +1,46 @@
+const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
+
class SeriesController {
- constructor() { }
+ constructor() {}
/**
* @deprecated
* /api/series/:id
- *
+ *
* TODO: Update mobile app to use /api/libraries/:id/series/:seriesId API route instead
* Series are not library specific so we need to know what the library id is
- *
- * @param {*} req
- * @param {*} res
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async findOne(req, res) {
- const include = (req.query.include || '').split(',').map(v => v.trim()).filter(v => !!v)
+ const include = (req.query.include || '')
+ .split(',')
+ .map((v) => v.trim())
+ .filter((v) => !!v)
const seriesJson = req.series.toJSON()
// Add progress map with isFinished flag
if (include.includes('progress')) {
const libraryItemsInSeries = req.libraryItemsInSeries
- const libraryItemsFinished = libraryItemsInSeries.filter(li => {
- const mediaProgress = req.user.getMediaProgress(li.id)
- return mediaProgress?.isFinished
+ const libraryItemsFinished = libraryItemsInSeries.filter((li) => {
+ return req.user.getMediaProgress(li.media.id)?.isFinished
})
seriesJson.progress = {
- libraryItemIds: libraryItemsInSeries.map(li => li.id),
- libraryItemIdsFinished: libraryItemsFinished.map(li => li.id),
+ libraryItemIds: libraryItemsInSeries.map((li) => li.id),
+ libraryItemIdsFinished: libraryItemsFinished.map((li) => li.id),
isFinished: libraryItemsFinished.length === libraryItemsInSeries.length
}
}
@@ -43,6 +53,11 @@ class SeriesController {
res.json(seriesJson)
}
+ /**
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async update(req, res) {
const hasUpdated = req.series.update(req.body)
if (hasUpdated) {
@@ -52,6 +67,12 @@ class SeriesController {
res.json(req.series.toJSON())
}
+ /**
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ * @param {NextFunction} next
+ */
async middleware(req, res, next) {
const series = await Database.seriesModel.getOldById(req.params.id)
if (!series) return res.sendStatus(404)
@@ -61,15 +82,15 @@ class SeriesController {
*/
const libraryItems = await libraryItemsBookFilters.getLibraryItemsForSeries(series, req.user)
if (!libraryItems.length) {
- Logger.warn(`[SeriesController] User attempted to access series "${series.id}" with no accessible books`, req.user)
+ Logger.warn(`[SeriesController] User "${req.user.username}" attempted to access series "${series.id}" with no accessible books`)
return res.sendStatus(404)
}
if (req.method == 'DELETE' && !req.user.canDelete) {
- Logger.warn(`[SeriesController] User attempted to delete without permission`, req.user)
+ Logger.warn(`[SeriesController] User "${req.user.username}" attempted to delete without permission`)
return res.sendStatus(403)
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
- Logger.warn('[SeriesController] User attempted to update without permission', req.user)
+ Logger.warn(`[SeriesController] User "${req.user.username}" attempted to update without permission`)
return res.sendStatus(403)
}
@@ -78,4 +99,4 @@ class SeriesController {
next()
}
}
-module.exports = new SeriesController()
\ No newline at end of file
+module.exports = new SeriesController()
diff --git a/server/controllers/SessionController.js b/server/controllers/SessionController.js
index 9dd3666d06..cc6c0fd729 100644
--- a/server/controllers/SessionController.js
+++ b/server/controllers/SessionController.js
@@ -1,26 +1,31 @@
+const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const Database = require('../Database')
const { toNumber, isUUID } = require('../utils/index')
const ShareManager = require('../managers/ShareManager')
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
+
class SessionController {
constructor() {}
- async findOne(req, res) {
- return res.json(req.playbackSession)
- }
-
/**
* GET: /api/sessions
+ *
* @this import('../routers/ApiRouter')
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async getAllWithUserData(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[SessionController] getAllWithUserData: Non-admin user requested all session data ${req.user.id}/"${req.user.username}"`)
+ Logger.error(`[SessionController] getAllWithUserData: Non-admin user "${req.user.username}" requested all session data`)
return res.sendStatus(404)
}
// Validate "user" query
@@ -105,9 +110,17 @@ class SessionController {
res.json(payload)
}
+ /**
+ * GET: /api/sessions/open
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async getOpenSessions(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[SessionController] getOpenSessions: Non-admin user requested open session data ${req.user.id}/"${req.user.username}"`)
+ Logger.error(`[SessionController] getOpenSessions: Non-admin user "${req.user.username}" requested open session data`)
return res.sendStatus(404)
}
@@ -127,25 +140,54 @@ class SessionController {
})
}
+ /**
+ * GET: /api/session/:id
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async getOpenSession(req, res) {
const libraryItem = await Database.libraryItemModel.getOldById(req.playbackSession.libraryItemId)
const sessionForClient = req.playbackSession.toJSONForClient(libraryItem)
res.json(sessionForClient)
}
- // POST: api/session/:id/sync
+ /**
+ * POST: /api/session/:id/sync
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
sync(req, res) {
this.playbackSessionManager.syncSessionRequest(req.user, req.playbackSession, req.body, res)
}
- // POST: api/session/:id/close
+ /**
+ * POST: /api/session/:id/close
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
close(req, res) {
let syncData = req.body
if (syncData && !Object.keys(syncData).length) syncData = null
this.playbackSessionManager.closeSessionRequest(req.user, req.playbackSession, syncData, res)
}
- // DELETE: api/session/:id
+ /**
+ * DELETE: /api/session/:id
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async delete(req, res) {
// if session is open then remove it
const openSession = this.playbackSessionManager.getSession(req.playbackSession.id)
@@ -164,12 +206,12 @@ class SessionController {
* @typedef batchDeleteReqBody
* @property {string[]} sessions
*
- * @param {import('express').Request<{}, {}, batchDeleteReqBody, {}>} req
- * @param {import('express').Response} res
+ * @param {Request<{}, {}, batchDeleteReqBody, {}> & RequestUserObject} req
+ * @param {Response} res
*/
async batchDelete(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[SessionController] Non-admin user attempted to batch delete sessions "${req.user.username}"`)
+ Logger.error(`[SessionController] Non-admin user "${req.user.username}" attempted to batch delete sessions`)
return res.sendStatus(403)
}
// Validate session ids
@@ -200,16 +242,36 @@ class SessionController {
}
}
- // POST: api/session/local
+ /**
+ * POST: /api/session/local
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
syncLocal(req, res) {
this.playbackSessionManager.syncLocalSessionRequest(req, res)
}
- // POST: api/session/local-all
+ /**
+ * POST: /api/session/local-all
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
syncLocalSessions(req, res) {
this.playbackSessionManager.syncLocalSessionsRequest(req, res)
}
+ /**
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ * @param {NextFunction} next
+ */
openSessionMiddleware(req, res, next) {
var playbackSession = this.playbackSessionManager.getSession(req.params.id)
if (!playbackSession) return res.sendStatus(404)
@@ -223,6 +285,12 @@ class SessionController {
next()
}
+ /**
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ * @param {NextFunction} next
+ */
async middleware(req, res, next) {
const playbackSession = await Database.getPlaybackSession(req.params.id)
if (!playbackSession) {
@@ -231,10 +299,10 @@ class SessionController {
}
if (req.method == 'DELETE' && !req.user.canDelete) {
- Logger.warn(`[SessionController] User attempted to delete without permission`, req.user)
+ Logger.warn(`[SessionController] User "${req.user.username}" attempted to delete without permission`)
return res.sendStatus(403)
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
- Logger.warn('[SessionController] User attempted to update without permission', req.user.username)
+ Logger.warn(`[SessionController] User "${req.user.username}" attempted to update without permission`)
return res.sendStatus(403)
}
diff --git a/server/controllers/ShareController.js b/server/controllers/ShareController.js
index 0dbec37461..e1568c0dbe 100644
--- a/server/controllers/ShareController.js
+++ b/server/controllers/ShareController.js
@@ -1,3 +1,4 @@
+const { Request, Response } = require('express')
const uuid = require('uuid')
const Path = require('path')
const { Op } = require('sequelize')
@@ -10,6 +11,13 @@ const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUti
const PlaybackSession = require('../objects/PlaybackSession')
const ShareManager = require('../managers/ShareManager')
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
+
class ShareController {
constructor() {}
@@ -20,8 +28,8 @@ class ShareController {
*
* @this {import('../routers/PublicRouter')}
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {Request} req
+ * @param {Response} res
*/
async getMediaItemShareBySlug(req, res) {
const { slug } = req.params
@@ -122,8 +130,8 @@ class ShareController {
* GET: /api/share/:slug/cover
* Get media item share cover image
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {Request} req
+ * @param {Response} res
*/
async getMediaItemShareCoverImage(req, res) {
if (!req.cookies.share_session_id) {
@@ -162,8 +170,8 @@ class ShareController {
* GET: /api/share/:slug/track/:index
* Get media item share audio track
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {Request} req
+ * @param {Response} res
*/
async getMediaItemShareAudioTrack(req, res) {
if (!req.cookies.share_session_id) {
@@ -208,8 +216,8 @@ class ShareController {
* PATCH: /api/share/:slug/progress
* Update media item share progress
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {Request} req
+ * @param {Response} res
*/
async updateMediaItemShareProgress(req, res) {
if (!req.cookies.share_session_id) {
@@ -242,8 +250,8 @@ class ShareController {
* POST: /api/share/mediaitem
* Create a new media item share
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async createMediaItemShare(req, res) {
if (!req.user.isAdminOrUp) {
@@ -306,8 +314,8 @@ class ShareController {
* DELETE: /api/share/mediaitem/:id
* Delete media item share
*
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ * @param {RequestWithUser} req
+ * @param {Response} res
*/
async deleteMediaItemShare(req, res) {
if (!req.user.isAdminOrUp) {
diff --git a/server/controllers/ToolsController.js b/server/controllers/ToolsController.js
index 3f81d116a7..32cd5a6c97 100644
--- a/server/controllers/ToolsController.js
+++ b/server/controllers/ToolsController.js
@@ -1,10 +1,26 @@
+const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const Database = require('../Database')
-class ToolsController {
- constructor() { }
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ */
- // POST: api/tools/item/:id/encode-m4b
+class ToolsController {
+ constructor() {}
+
+ /**
+ * POST: /api/tools/item/:id/encode-m4b
+ * Start an audiobook merge to m4b task
+ *
+ * @this import('../routers/ApiRouter')
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async encodeM4b(req, res) {
if (req.libraryItem.isMissing || req.libraryItem.isInvalid) {
Logger.error(`[MiscController] encodeM4b: library item not found or invalid ${req.params.id}`)
@@ -22,12 +38,20 @@ class ToolsController {
}
const options = req.query || {}
- this.abMergeManager.startAudiobookMerge(req.user, req.libraryItem, options)
+ this.abMergeManager.startAudiobookMerge(req.user.id, req.libraryItem, options)
res.sendStatus(200)
}
- // DELETE: api/tools/item/:id/encode-m4b
+ /**
+ * DELETE: /api/tools/item/:id/encode-m4b
+ * Cancel a running m4b merge task
+ *
+ * @this import('../routers/ApiRouter')
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async cancelM4bEncode(req, res) {
const workerTask = this.abMergeManager.getPendingTaskByLibraryItemId(req.params.id)
if (!workerTask) return res.sendStatus(404)
@@ -37,7 +61,15 @@ class ToolsController {
res.sendStatus(200)
}
- // POST: api/tools/item/:id/embed-metadata
+ /**
+ * POST: /api/tools/item/:id/embed-metadata
+ * Start audiobook embed task
+ *
+ * @this import('../routers/ApiRouter')
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async embedAudioFileMetadata(req, res) {
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
Logger.error(`[ToolsController] Invalid library item`)
@@ -53,11 +85,19 @@ class ToolsController {
forceEmbedChapters: req.query.forceEmbedChapters === '1',
backup: req.query.backup === '1'
}
- this.audioMetadataManager.updateMetadataForItem(req.user, req.libraryItem, options)
+ this.audioMetadataManager.updateMetadataForItem(req.user.id, req.libraryItem, options)
res.sendStatus(200)
}
- // POST: api/tools/batch/embed-metadata
+ /**
+ * POST: /api/tools/batch/embed-metadata
+ * Start batch audiobook embed task
+ *
+ * @this import('../routers/ApiRouter')
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async batchEmbedMetadata(req, res) {
const libraryItemIds = req.body.libraryItemIds || []
if (!libraryItemIds.length) {
@@ -74,7 +114,7 @@ class ToolsController {
// Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
- Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not accessible to user`, req.user)
+ Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not accessible to user "${req.user.username}"`)
return res.sendStatus(403)
}
@@ -95,13 +135,19 @@ class ToolsController {
forceEmbedChapters: req.query.forceEmbedChapters === '1',
backup: req.query.backup === '1'
}
- this.audioMetadataManager.handleBatchEmbed(req.user, libraryItems, options)
+ this.audioMetadataManager.handleBatchEmbed(req.user.id, libraryItems, options)
res.sendStatus(200)
}
+ /**
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ * @param {NextFunction} next
+ */
async middleware(req, res, next) {
if (!req.user.isAdminOrUp) {
- Logger.error(`[LibraryItemController] Non-root user attempted to access tools route`, req.user)
+ Logger.error(`[LibraryItemController] Non-root user "${req.user.username}" attempted to access tools route`)
return res.sendStatus(403)
}
@@ -120,4 +166,4 @@ class ToolsController {
next()
}
}
-module.exports = new ToolsController()
\ No newline at end of file
+module.exports = new ToolsController()
diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js
index 726777516d..37caa61cc1 100644
--- a/server/controllers/UserController.js
+++ b/server/controllers/UserController.js
@@ -1,4 +1,5 @@
-const uuidv4 = require("uuid").v4
+const { Request, Response, NextFunction } = require('express')
+const uuidv4 = require('uuid').v4
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
@@ -7,18 +8,35 @@ const User = require('../objects/user/User')
const { toNumber } = require('../utils/index')
+/**
+ * @typedef RequestUserObject
+ * @property {import('../models/User')} user
+ *
+ * @typedef {Request & RequestUserObject} RequestWithUser
+ *
+ * @typedef RequestEntityObject
+ * @property {import('../models/User')} reqUser
+ *
+ * @typedef {RequestWithUser & RequestEntityObject} UserControllerRequest
+ */
+
class UserController {
- constructor() { }
+ constructor() {}
+ /**
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async findAll(req, res) {
if (!req.user.isAdminOrUp) return res.sendStatus(403)
const hideRootToken = !req.user.isRoot
- const includes = (req.query.include || '').split(',').map(i => i.trim())
+ const includes = (req.query.include || '').split(',').map((i) => i.trim())
// Minimal toJSONForBrowser does not include mediaProgress and bookmarks
- const allUsers = await Database.userModel.getOldUsers()
- const users = allUsers.map(u => u.toJSONForBrowser(hideRootToken, true))
+ const allUsers = await Database.userModel.findAll()
+ const users = allUsers.map((u) => u.toOldJSONForBrowser(hideRootToken, true))
if (includes.includes('latestSession')) {
for (const user of users) {
@@ -36,13 +54,13 @@ class UserController {
* GET: /api/users/:id
* Get a single user toJSONForBrowser
* Media progress items include: `displayTitle`, `displaySubtitle` (for podcasts), `coverPath` and `mediaUpdatedAt`
- *
- * @param {import("express").Request} req
- * @param {import("express").Response} res
+ *
+ * @param {UserControllerRequest} req
+ * @param {Response} res
*/
async findOne(req, res) {
if (!req.user.isAdminOrUp) {
- Logger.error('User other than admin attempting to get user', req.user)
+ Logger.error(`Non-admin user "${req.user.username}" attempted to get user`)
return res.sendStatus(403)
}
@@ -67,7 +85,7 @@ class UserController {
]
})
- const oldMediaProgresses = mediaProgresses.map(mp => {
+ const oldMediaProgresses = mediaProgresses.map((mp) => {
const oldMediaProgress = mp.getOldMediaProgress()
oldMediaProgress.displayTitle = mp.mediaItem?.title
if (mp.mediaItem?.podcast) {
@@ -81,20 +99,29 @@ class UserController {
return oldMediaProgress
})
- const userJson = req.reqUser.toJSONForBrowser(!req.user.isRoot)
+ const userJson = req.reqUser.toOldJSONForBrowser(!req.user.isRoot)
userJson.mediaProgress = oldMediaProgresses
res.json(userJson)
}
+ /**
+ * POST: /api/users
+ * Create a new user
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async create(req, res) {
const account = req.body
const username = account.username
- const usernameExists = await Database.userModel.getUserByUsername(username)
+ const usernameExists = await Database.userModel.checkUserExistsWithUsername(username)
if (usernameExists) {
- return res.status(500).send('Username already taken')
+ return res.status(400).send('Username already taken')
}
account.id = uuidv4()
@@ -104,6 +131,7 @@ class UserController {
account.createdAt = Date.now()
const newUser = new User(account)
+ // TODO: Create with new User model
const success = await Database.createUser(newUser)
if (success) {
SocketAuthority.adminEmitter('user_added', newUser.toJSONForBrowser())
@@ -118,24 +146,26 @@ class UserController {
/**
* PATCH: /api/users/:id
* Update user
- *
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {UserControllerRequest} req
+ * @param {Response} res
*/
async update(req, res) {
const user = req.reqUser
if (user.type === 'root' && !req.user.isRoot) {
- Logger.error(`[UserController] Admin user attempted to update root user`, req.user.username)
+ Logger.error(`[UserController] Admin user "${req.user.username}" attempted to update root user`)
return res.sendStatus(403)
}
- var account = req.body
- var shouldUpdateToken = false
+ const updatePayload = req.body
+ let shouldUpdateToken = false
// When changing username create a new API token
- if (account.username !== undefined && account.username !== user.username) {
- const usernameExists = await Database.userModel.getUserByUsername(account.username)
+ if (updatePayload.username !== undefined && updatePayload.username !== user.username) {
+ const usernameExists = await Database.userModel.checkUserExistsWithUsername(updatePayload.username)
if (usernameExists) {
return res.status(500).send('Username already taken')
}
@@ -143,34 +173,43 @@ class UserController {
}
// Updating password
- if (account.password) {
- account.pash = await this.auth.hashPass(account.password)
- delete account.password
+ if (updatePayload.password) {
+ updatePayload.pash = await this.auth.hashPass(updatePayload.password)
+ delete updatePayload.password
}
- if (user.update(account)) {
+ // TODO: Update with new User model
+ const oldUser = Database.userModel.getOldUser(user)
+ if (oldUser.update(updatePayload)) {
if (shouldUpdateToken) {
- user.token = await this.auth.generateAccessToken(user)
- Logger.info(`[UserController] User ${user.username} was generated a new api token`)
+ oldUser.token = await this.auth.generateAccessToken(oldUser)
+ Logger.info(`[UserController] User ${oldUser.username} has generated a new api token`)
}
- await Database.updateUser(user)
- SocketAuthority.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
+ await Database.updateUser(oldUser)
+ SocketAuthority.clientEmitter(req.user.id, 'user_updated', oldUser.toJSONForBrowser())
}
res.json({
success: true,
- user: user.toJSONForBrowser()
+ user: oldUser.toJSONForBrowser()
})
}
+ /**
+ * DELETE: /api/users/:id
+ * Delete a user
+ *
+ * @param {UserControllerRequest} req
+ * @param {Response} res
+ */
async delete(req, res) {
if (req.params.id === 'root') {
Logger.error('[UserController] Attempt to delete root user. Root user cannot be deleted')
- return res.sendStatus(500)
+ return res.sendStatus(400)
}
if (req.user.id === req.params.id) {
- Logger.error(`[UserController] ${req.user.username} is attempting to delete themselves... why? WHY?`)
- return res.sendStatus(500)
+ Logger.error(`[UserController] User ${req.user.username} is attempting to delete self`)
+ return res.sendStatus(400)
}
const user = req.reqUser
@@ -186,8 +225,8 @@ class UserController {
await playlist.destroy()
}
- const userJson = user.toJSONForBrowser()
- await Database.removeUser(user.id)
+ const userJson = user.toOldJSONForBrowser()
+ await user.destroy()
SocketAuthority.adminEmitter('user_removed', userJson)
res.json({
success: true
@@ -196,22 +235,30 @@ class UserController {
/**
* PATCH: /api/users/:id/openid-unlink
- *
- * @param {import('express').Request} req
- * @param {import('express').Response} res
+ *
+ * @param {UserControllerRequest} req
+ * @param {Response} res
*/
async unlinkFromOpenID(req, res) {
Logger.debug(`[UserController] Unlinking user "${req.reqUser.username}" from OpenID with sub "${req.reqUser.authOpenIDSub}"`)
- req.reqUser.authOpenIDSub = null
- if (await Database.userModel.updateFromOld(req.reqUser)) {
- SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.reqUser.toJSONForBrowser())
- res.sendStatus(200)
- } else {
- res.sendStatus(500)
+
+ if (!req.reqUser.authOpenIDSub) {
+ return res.sendStatus(200)
}
+
+ req.reqUser.extraData.authOpenIDSub = null
+ req.reqUser.changed('extraData', true)
+ await req.reqUser.save()
+ SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.reqUser.toOldJSONForBrowser())
+ res.sendStatus(200)
}
- // GET: api/users/:id/listening-sessions
+ /**
+ * GET: /api/users/:id/listening-sessions
+ *
+ * @param {UserControllerRequest} req
+ * @param {Response} res
+ */
async getListeningSessions(req, res) {
var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id)
@@ -232,13 +279,27 @@ class UserController {
res.json(payload)
}
- // GET: api/users/:id/listening-stats
+ /**
+ * GET: /api/users/:id/listening-stats
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {UserControllerRequest} req
+ * @param {Response} res
+ */
async getListeningStats(req, res) {
var listeningStats = await this.getUserListeningStatsHelpers(req.params.id)
res.json(listeningStats)
}
- // POST: api/users/online (admin)
+ /**
+ * GET: /api/users/online
+ *
+ * @this {import('../routers/ApiRouter')}
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
async getOnlineUsers(req, res) {
if (!req.user.isAdminOrUp) {
return res.sendStatus(403)
@@ -250,6 +311,12 @@ class UserController {
})
}
+ /**
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ * @param {NextFunction} next
+ */
async middleware(req, res, next) {
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
return res.sendStatus(403)
@@ -267,4 +334,4 @@ class UserController {
next()
}
}
-module.exports = new UserController()
\ No newline at end of file
+module.exports = new UserController()
diff --git a/server/libs/ffbinaries/index.js b/server/libs/ffbinaries/index.js
deleted file mode 100644
index b0660f088b..0000000000
--- a/server/libs/ffbinaries/index.js
+++ /dev/null
@@ -1,315 +0,0 @@
-const os = require('os')
-const path = require('path')
-const axios = require('axios')
-const fse = require('../fsExtra')
-const async = require('../async')
-const StreamZip = require('../nodeStreamZip')
-const { finished } = require('stream/promises')
-
-var API_URL = 'https://ffbinaries.com/api/v1'
-
-var RUNTIME_CACHE = {}
-var errorMsgs = {
- connectionIssues: 'Couldn\'t connect to ffbinaries.com API. Check your Internet connection.',
- parsingVersionData: 'Couldn\'t parse retrieved version data.',
- parsingVersionList: 'Couldn\'t parse the list of available versions.',
- notFound: 'Requested data not found.',
- incorrectVersionParam: '"version" parameter must be a string.'
-}
-
-function ensureDirSync(dir) {
- try {
- fse.accessSync(dir)
- } catch (e) {
- fse.mkdirSync(dir)
- }
-}
-
-/**
- * Resolves the platform key based on input string
- */
-function resolvePlatform(input) {
- var rtn = null
-
- switch (input) {
- case 'mac':
- case 'osx':
- case 'mac-64':
- case 'osx-64':
- rtn = 'osx-64'
- break
-
- case 'linux':
- case 'linux-32':
- rtn = 'linux-32'
- break
-
- case 'linux-64':
- rtn = 'linux-64'
- break
-
- case 'linux-arm':
- case 'linux-armel':
- rtn = 'linux-armel'
- break
-
- case 'linux-armhf':
- rtn = 'linux-armhf'
- break
-
- case 'win':
- case 'win-32':
- case 'windows':
- case 'windows-32':
- rtn = 'windows-32'
- break
-
- case 'win-64':
- case 'windows-64':
- rtn = 'windows-64'
- break
-
- default:
- rtn = null
- }
-
- return rtn
-}
-/**
- * Detects the platform of the machine the script is executed on.
- * Object can be provided to detect platform from info derived elsewhere.
- *
- * @param {object} osinfo Contains "type" and "arch" properties
- */
-function detectPlatform(osinfo) {
- var inputIsValid = typeof osinfo === 'object' && typeof osinfo.type === 'string' && typeof osinfo.arch === 'string'
- var type = (inputIsValid ? osinfo.type : os.type()).toLowerCase()
- var arch = (inputIsValid ? osinfo.arch : os.arch()).toLowerCase()
-
- if (type === 'darwin') {
- return 'osx-64'
- }
-
- if (type === 'windows_nt') {
- return arch === 'x64' ? 'windows-64' : 'windows-32'
- }
-
- if (type === 'linux') {
- if (arch === 'arm' || arch === 'arm64') {
- return 'linux-armel'
- }
- return arch === 'x64' ? 'linux-64' : 'linux-32'
- }
-
- return null
-}
-/**
- * Gets the binary filename (appends exe in Windows)
- *
- * @param {string} component "ffmpeg", "ffplay", "ffprobe" or "ffserver"
- * @param {platform} platform "ffmpeg", "ffplay", "ffprobe" or "ffserver"
- */
-function getBinaryFilename(component, platform) {
- var platformCode = resolvePlatform(platform)
- if (platformCode === 'windows-32' || platformCode === 'windows-64') {
- return component + '.exe'
- }
- return component
-}
-
-function listPlatforms() {
- return ['osx-64', 'linux-32', 'linux-64', 'linux-armel', 'linux-armhf', 'windows-32', 'windows-64']
-}
-
-/**
- *
- * @returns {Promise} array of version strings
- */
-function listVersions() {
- if (RUNTIME_CACHE.versionsAll) {
- return RUNTIME_CACHE.versionsAll
- }
- return axios.get(API_URL).then((res) => {
- if (!res.data?.versions || !Object.keys(res.data.versions)?.length) {
- throw new Error(errorMsgs.parsingVersionList)
- }
- const versionKeys = Object.keys(res.data.versions)
- RUNTIME_CACHE.versionsAll = versionKeys
- return versionKeys
- })
-}
-/**
- * Gets full data set from ffbinaries.com
- */
-function getVersionData(version) {
- if (RUNTIME_CACHE[version]) {
- return RUNTIME_CACHE[version]
- }
-
- if (version && typeof version !== 'string') {
- throw new Error(errorMsgs.incorrectVersionParam)
- }
-
- var url = version ? '/version/' + version : '/latest'
-
- return axios.get(`${API_URL}${url}`).then((res) => {
- RUNTIME_CACHE[version] = res.data
- return res.data
- }).catch((error) => {
- if (error.response?.status == 404) {
- throw new Error(errorMsgs.notFound)
- } else {
- throw new Error(errorMsgs.connectionIssues)
- }
- })
-}
-
-/**
- * Download file(s) and save them in the specified directory
- */
-async function downloadUrls(components, urls, opts) {
- const destinationDir = opts.destination
- const results = []
- const remappedUrls = []
-
- if (components && !Array.isArray(components)) {
- components = [components]
- } else if (!components || !Array.isArray(components)) {
- components = []
- }
-
- // returns an array of objects like this: {component: 'ffmpeg', url: 'https://...'}
- if (typeof urls === 'object') {
- for (const key in urls) {
- if (components.includes(key) && urls[key]) {
- remappedUrls.push({
- component: key,
- url: urls[key]
- })
- }
- }
- }
-
-
- async function extractZipToDestination(zipFilename) {
- const oldpath = path.join(destinationDir, zipFilename)
- const zip = new StreamZip.async({ file: oldpath })
- const count = await zip.extract(null, destinationDir)
- await zip.close()
- }
-
-
- await async.each(remappedUrls, async function (urlObject) {
- try {
- const url = urlObject.url
-
- const zipFilename = url.split('/').pop()
- const binFilenameBase = urlObject.component
- const binFilename = getBinaryFilename(binFilenameBase, opts.platform || detectPlatform())
-
- let runningTotal = 0
- let totalFilesize
- let interval
-
-
- if (typeof opts.tickerFn === 'function') {
- opts.tickerInterval = parseInt(opts.tickerInterval, 10)
- const tickerInterval = (!Number.isNaN(opts.tickerInterval)) ? opts.tickerInterval : 1000
- const tickData = { filename: zipFilename, progress: 0 }
-
- // Schedule next ticks
- interval = setInterval(function () {
- if (totalFilesize && runningTotal == totalFilesize) {
- return clearInterval(interval)
- }
- tickData.progress = totalFilesize > -1 ? runningTotal / totalFilesize : 0
-
- opts.tickerFn(tickData)
- }, tickerInterval)
- }
-
-
- // Check if file already exists in target directory
- const binPath = path.join(destinationDir, binFilename)
- if (!opts.force && await fse.pathExists(binPath)) {
- // if the accessSync method doesn't throw we know the binary already exists
- results.push({
- filename: binFilename,
- path: destinationDir,
- status: 'File exists',
- code: 'FILE_EXISTS'
- })
- clearInterval(interval)
- return
- }
-
- if (opts.quiet) clearInterval(interval)
-
- const zipPath = path.join(destinationDir, zipFilename)
- const zipFileTempName = zipPath + '.part'
- const zipFileFinalName = zipPath
-
- const response = await axios({
- url,
- method: 'GET',
- responseType: 'stream'
- })
- totalFilesize = response.headers?.['content-length'] || []
-
- const writer = fse.createWriteStream(zipFileTempName)
- response.data.on('data', (chunk) => {
- runningTotal += chunk.length
- })
- response.data.pipe(writer)
- await finished(writer)
- await fse.rename(zipFileTempName, zipFileFinalName)
- await extractZipToDestination(zipFilename)
- await fse.remove(zipFileFinalName)
-
- results.push({
- filename: binFilename,
- path: destinationDir,
- size: Math.floor(totalFilesize / 1024 / 1024 * 1000) / 1000 + 'MB',
- status: 'File extracted to destination (downloaded from "' + url + '")',
- code: 'DONE_CLEAN'
- })
- } catch (err) {
- console.error(`Failed to download or extract file for component: ${urlObject.component}`, err)
- }
- })
-
- return results
-}
-
-/**
- * Gets binaries for the platform
- * It will get the data from ffbinaries, pick the correct files
- * and save it to the specified directory
- *
- * @param {Array} components
- * @param {Object} [opts]
- */
-async function downloadBinaries(components, opts = {}) {
- var platform = resolvePlatform(opts.platform) || detectPlatform()
-
- opts.destination = path.resolve(opts.destination || '.')
- ensureDirSync(opts.destination)
-
- const versionData = await getVersionData(opts.version)
- const urls = versionData?.bin?.[platform]
- if (!urls) {
- throw new Error('No URLs!')
- }
-
- return await downloadUrls(components, urls, opts)
-}
-
-module.exports = {
- downloadBinaries: downloadBinaries,
- getVersionData: getVersionData,
- listVersions: listVersions,
- listPlatforms: listPlatforms,
- detectPlatform: detectPlatform,
- resolvePlatform: resolvePlatform,
- getBinaryFilename: getBinaryFilename
-}
\ No newline at end of file
diff --git a/server/libs/fluentFfmpeg/index.d.ts b/server/libs/fluentFfmpeg/index.d.ts
new file mode 100644
index 0000000000..ee9f49232a
--- /dev/null
+++ b/server/libs/fluentFfmpeg/index.d.ts
@@ -0,0 +1,498 @@
+///
+
+import * as events from "events";
+import * as stream from "stream";
+
+declare namespace Ffmpeg {
+ interface FfmpegCommandLogger {
+ error(...data: any[]): void;
+ warn(...data: any[]): void;
+ info(...data: any[]): void;
+ debug(...data: any[]): void;
+ }
+
+ interface FfmpegCommandOptions {
+ logger?: FfmpegCommandLogger | undefined;
+ niceness?: number | undefined;
+ priority?: number | undefined;
+ presets?: string | undefined;
+ preset?: string | undefined;
+ stdoutLines?: number | undefined;
+ timeout?: number | undefined;
+ source?: string | stream.Readable | undefined;
+ cwd?: string | undefined;
+ }
+
+ interface FilterSpecification {
+ filter: string;
+ inputs?: string | string[] | undefined;
+ outputs?: string | string[] | undefined;
+ options?: any | string | any[] | undefined;
+ }
+
+ type PresetFunction = (command: FfmpegCommand) => void;
+
+ interface Filter {
+ description: string;
+ input: string;
+ multipleInputs: boolean;
+ output: string;
+ multipleOutputs: boolean;
+ }
+ interface Filters {
+ [key: string]: Filter;
+ }
+ type FiltersCallback = (err: Error, filters: Filters) => void;
+
+ interface Codec {
+ type: string;
+ description: string;
+ canDecode: boolean;
+ canEncode: boolean;
+ drawHorizBand?: boolean | undefined;
+ directRendering?: boolean | undefined;
+ weirdFrameTruncation?: boolean | undefined;
+ intraFrameOnly?: boolean | undefined;
+ isLossy?: boolean | undefined;
+ isLossless?: boolean | undefined;
+ }
+ interface Codecs {
+ [key: string]: Codec;
+ }
+ type CodecsCallback = (err: Error, codecs: Codecs) => void;
+
+ interface Encoder {
+ type: string;
+ description: string;
+ frameMT: boolean;
+ sliceMT: boolean;
+ experimental: boolean;
+ drawHorizBand: boolean;
+ directRendering: boolean;
+ }
+ interface Encoders {
+ [key: string]: Encoder;
+ }
+ type EncodersCallback = (err: Error, encoders: Encoders) => void;
+
+ interface Format {
+ description: string;
+ canDemux: boolean;
+ canMux: boolean;
+ }
+ interface Formats {
+ [key: string]: Format;
+ }
+ type FormatsCallback = (err: Error, formats: Formats) => void;
+
+ interface FfprobeData {
+ streams: FfprobeStream[];
+ format: FfprobeFormat;
+ chapters: any[];
+ }
+
+ interface FfprobeStream {
+ [key: string]: any;
+ index: number;
+ codec_name?: string | undefined;
+ codec_long_name?: string | undefined;
+ profile?: number | undefined;
+ codec_type?: string | undefined;
+ codec_time_base?: string | undefined;
+ codec_tag_string?: string | undefined;
+ codec_tag?: string | undefined;
+ width?: number | undefined;
+ height?: number | undefined;
+ coded_width?: number | undefined;
+ coded_height?: number | undefined;
+ has_b_frames?: number | undefined;
+ sample_aspect_ratio?: string | undefined;
+ display_aspect_ratio?: string | undefined;
+ pix_fmt?: string | undefined;
+ level?: string | undefined;
+ color_range?: string | undefined;
+ color_space?: string | undefined;
+ color_transfer?: string | undefined;
+ color_primaries?: string | undefined;
+ chroma_location?: string | undefined;
+ field_order?: string | undefined;
+ timecode?: string | undefined;
+ refs?: number | undefined;
+ id?: string | undefined;
+ r_frame_rate?: string | undefined;
+ avg_frame_rate?: string | undefined;
+ time_base?: string | undefined;
+ start_pts?: number | undefined;
+ start_time?: number | undefined;
+ duration_ts?: string | undefined;
+ duration?: string | undefined;
+ bit_rate?: string | undefined;
+ max_bit_rate?: string | undefined;
+ bits_per_raw_sample?: string | undefined;
+ nb_frames?: string | undefined;
+ nb_read_frames?: string | undefined;
+ nb_read_packets?: string | undefined;
+ sample_fmt?: string | undefined;
+ sample_rate?: number | undefined;
+ channels?: number | undefined;
+ channel_layout?: string | undefined;
+ bits_per_sample?: number | undefined;
+ disposition?: FfprobeStreamDisposition | undefined;
+ rotation?: string | number | undefined;
+ }
+
+ interface FfprobeStreamDisposition {
+ [key: string]: any;
+ default?: number | undefined;
+ dub?: number | undefined;
+ original?: number | undefined;
+ comment?: number | undefined;
+ lyrics?: number | undefined;
+ karaoke?: number | undefined;
+ forced?: number | undefined;
+ hearing_impaired?: number | undefined;
+ visual_impaired?: number | undefined;
+ clean_effects?: number | undefined;
+ attached_pic?: number | undefined;
+ timed_thumbnails?: number | undefined;
+ }
+
+ interface FfprobeFormat {
+ [key: string]: any;
+ filename?: string | undefined;
+ nb_streams?: number | undefined;
+ nb_programs?: number | undefined;
+ format_name?: string | undefined;
+ format_long_name?: string | undefined;
+ start_time?: number | undefined;
+ duration?: number | undefined;
+ size?: number | undefined;
+ bit_rate?: number | undefined;
+ probe_score?: number | undefined;
+ tags?: Record | undefined;
+ }
+
+ interface ScreenshotsConfig {
+ count?: number | undefined;
+ folder?: string | undefined;
+ filename?: string | undefined;
+ timemarks?: number[] | string[] | undefined;
+ timestamps?: number[] | string[] | undefined;
+ fastSeek?: boolean | undefined;
+ size?: string | undefined;
+ }
+
+ interface AudioVideoFilter {
+ filter: string;
+ options: string | string[] | {};
+ }
+
+ // static methods
+ function setFfmpegPath(path: string): FfmpegCommand;
+ function setFfprobePath(path: string): FfmpegCommand;
+ function setFlvtoolPath(path: string): FfmpegCommand;
+ function availableFilters(callback: FiltersCallback): void;
+ function getAvailableFilters(callback: FiltersCallback): void;
+ function availableCodecs(callback: CodecsCallback): void;
+ function getAvailableCodecs(callback: CodecsCallback): void;
+ function availableEncoders(callback: EncodersCallback): void;
+ function getAvailableEncoders(callback: EncodersCallback): void;
+ function availableFormats(callback: FormatsCallback): void;
+ function getAvailableFormats(callback: FormatsCallback): void;
+
+ class FfmpegCommand extends events.EventEmitter {
+ constructor(options?: FfmpegCommandOptions);
+ constructor(input?: string | stream.Readable, options?: FfmpegCommandOptions);
+
+ // options/inputs
+ mergeAdd(source: string | stream.Readable): FfmpegCommand;
+ addInput(source: string | stream.Readable): FfmpegCommand;
+ input(source: string | stream.Readable): FfmpegCommand;
+ withInputFormat(format: string): FfmpegCommand;
+ inputFormat(format: string): FfmpegCommand;
+ fromFormat(format: string): FfmpegCommand;
+ withInputFps(fps: number): FfmpegCommand;
+ withInputFPS(fps: number): FfmpegCommand;
+ withFpsInput(fps: number): FfmpegCommand;
+ withFPSInput(fps: number): FfmpegCommand;
+ inputFPS(fps: number): FfmpegCommand;
+ inputFps(fps: number): FfmpegCommand;
+ fpsInput(fps: number): FfmpegCommand;
+ FPSInput(fps: number): FfmpegCommand;
+ nativeFramerate(): FfmpegCommand;
+ withNativeFramerate(): FfmpegCommand;
+ native(): FfmpegCommand;
+ setStartTime(seek: string | number): FfmpegCommand;
+ seekInput(seek: string | number): FfmpegCommand;
+ loop(duration?: string | number): FfmpegCommand;
+
+ // options/audio
+ withNoAudio(): FfmpegCommand;
+ noAudio(): FfmpegCommand;
+ withAudioCodec(codec: string): FfmpegCommand;
+ audioCodec(codec: string): FfmpegCommand;
+ withAudioBitrate(bitrate: string | number): FfmpegCommand;
+ audioBitrate(bitrate: string | number): FfmpegCommand;
+ withAudioChannels(channels: number): FfmpegCommand;
+ audioChannels(channels: number): FfmpegCommand;
+ withAudioFrequency(freq: number): FfmpegCommand;
+ audioFrequency(freq: number): FfmpegCommand;
+ withAudioQuality(quality: number): FfmpegCommand;
+ audioQuality(quality: number): FfmpegCommand;
+ withAudioFilter(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand;
+ withAudioFilters(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand;
+ audioFilter(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand;
+ audioFilters(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand;
+
+ // options/video;
+ withNoVideo(): FfmpegCommand;
+ noVideo(): FfmpegCommand;
+ withVideoCodec(codec: string): FfmpegCommand;
+ videoCodec(codec: string): FfmpegCommand;
+ withVideoBitrate(bitrate: string | number, constant?: boolean): FfmpegCommand;
+ videoBitrate(bitrate: string | number, constant?: boolean): FfmpegCommand;
+ withVideoFilter(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand;
+ withVideoFilters(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand;
+ videoFilter(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand;
+ videoFilters(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand;
+ withOutputFps(fps: number): FfmpegCommand;
+ withOutputFPS(fps: number): FfmpegCommand;
+ withFpsOutput(fps: number): FfmpegCommand;
+ withFPSOutput(fps: number): FfmpegCommand;
+ withFps(fps: number): FfmpegCommand;
+ withFPS(fps: number): FfmpegCommand;
+ outputFPS(fps: number): FfmpegCommand;
+ outputFps(fps: number): FfmpegCommand;
+ fpsOutput(fps: number): FfmpegCommand;
+ FPSOutput(fps: number): FfmpegCommand;
+ fps(fps: number): FfmpegCommand;
+ FPS(fps: number): FfmpegCommand;
+ takeFrames(frames: number): FfmpegCommand;
+ withFrames(frames: number): FfmpegCommand;
+ frames(frames: number): FfmpegCommand;
+
+ // options/videosize
+ keepPixelAspect(): FfmpegCommand;
+ keepDisplayAspect(): FfmpegCommand;
+ keepDisplayAspectRatio(): FfmpegCommand;
+ keepDAR(): FfmpegCommand;
+ withSize(size: string): FfmpegCommand;
+ setSize(size: string): FfmpegCommand;
+ size(size: string): FfmpegCommand;
+ withAspect(aspect: string | number): FfmpegCommand;
+ withAspectRatio(aspect: string | number): FfmpegCommand;
+ setAspect(aspect: string | number): FfmpegCommand;
+ setAspectRatio(aspect: string | number): FfmpegCommand;
+ aspect(aspect: string | number): FfmpegCommand;
+ aspectRatio(aspect: string | number): FfmpegCommand;
+ applyAutopadding(pad?: boolean, color?: string): FfmpegCommand;
+ applyAutoPadding(pad?: boolean, color?: string): FfmpegCommand;
+ applyAutopad(pad?: boolean, color?: string): FfmpegCommand;
+ applyAutoPad(pad?: boolean, color?: string): FfmpegCommand;
+ withAutopadding(pad?: boolean, color?: string): FfmpegCommand;
+ withAutoPadding(pad?: boolean, color?: string): FfmpegCommand;
+ withAutopad(pad?: boolean, color?: string): FfmpegCommand;
+ withAutoPad(pad?: boolean, color?: string): FfmpegCommand;
+ autoPad(pad?: boolean, color?: string): FfmpegCommand;
+ autopad(pad?: boolean, color?: string): FfmpegCommand;
+
+ // options/output
+ addOutput(target: string | stream.Writable, pipeopts?: { end?: boolean | undefined }): FfmpegCommand;
+ output(target: string | stream.Writable, pipeopts?: { end?: boolean | undefined }): FfmpegCommand;
+ seekOutput(seek: string | number): FfmpegCommand;
+ seek(seek: string | number): FfmpegCommand;
+ withDuration(duration: string | number): FfmpegCommand;
+ setDuration(duration: string | number): FfmpegCommand;
+ duration(duration: string | number): FfmpegCommand;
+ toFormat(format: string): FfmpegCommand;
+ withOutputFormat(format: string): FfmpegCommand;
+ outputFormat(format: string): FfmpegCommand;
+ format(format: string): FfmpegCommand;
+ map(spec: string): FfmpegCommand;
+ updateFlvMetadata(): FfmpegCommand;
+ flvmeta(): FfmpegCommand;
+
+ // options/custom
+ addInputOption(options: string[]): FfmpegCommand;
+ addInputOption(...options: string[]): FfmpegCommand;
+ addInputOptions(options: string[]): FfmpegCommand;
+ addInputOptions(...options: string[]): FfmpegCommand;
+ withInputOption(options: string[]): FfmpegCommand;
+ withInputOption(...options: string[]): FfmpegCommand;
+ withInputOptions(options: string[]): FfmpegCommand;
+ withInputOptions(...options: string[]): FfmpegCommand;
+ inputOption(options: string[]): FfmpegCommand;
+ inputOption(...options: string[]): FfmpegCommand;
+ inputOptions(options: string[]): FfmpegCommand;
+ inputOptions(...options: string[]): FfmpegCommand;
+ addOutputOption(options: string[]): FfmpegCommand;
+ addOutputOption(...options: string[]): FfmpegCommand;
+ addOutputOptions(options: string[]): FfmpegCommand;
+ addOutputOptions(...options: string[]): FfmpegCommand;
+ addOption(options: string[]): FfmpegCommand;
+ addOption(...options: string[]): FfmpegCommand;
+ addOptions(options: string[]): FfmpegCommand;
+ addOptions(...options: string[]): FfmpegCommand;
+ withOutputOption(options: string[]): FfmpegCommand;
+ withOutputOption(...options: string[]): FfmpegCommand;
+ withOutputOptions(options: string[]): FfmpegCommand;
+ withOutputOptions(...options: string[]): FfmpegCommand;
+ withOption(options: string[]): FfmpegCommand;
+ withOption(...options: string[]): FfmpegCommand;
+ withOptions(options: string[]): FfmpegCommand;
+ withOptions(...options: string[]): FfmpegCommand;
+ outputOption(options: string[]): FfmpegCommand;
+ outputOption(...options: string[]): FfmpegCommand;
+ outputOptions(options: string[]): FfmpegCommand;
+ outputOptions(...options: string[]): FfmpegCommand;
+ filterGraph(
+ spec: string | FilterSpecification | Array,
+ map?: string[] | string,
+ ): FfmpegCommand;
+ complexFilter(
+ spec: string | FilterSpecification | Array,
+ map?: string[] | string,
+ ): FfmpegCommand;
+
+ // options/misc
+ usingPreset(preset: string | PresetFunction): FfmpegCommand;
+ preset(preset: string | PresetFunction): FfmpegCommand;
+
+ // processor
+ renice(niceness: number): FfmpegCommand;
+ kill(signal: string): FfmpegCommand;
+ _getArguments(): string[];
+
+ // capabilities
+ setFfmpegPath(path: string): FfmpegCommand;
+ setFfprobePath(path: string): FfmpegCommand;
+ setFlvtoolPath(path: string): FfmpegCommand;
+ availableFilters(callback: FiltersCallback): void;
+ getAvailableFilters(callback: FiltersCallback): void;
+ availableCodecs(callback: CodecsCallback): void;
+ getAvailableCodecs(callback: CodecsCallback): void;
+ availableEncoders(callback: EncodersCallback): void;
+ getAvailableEncoders(callback: EncodersCallback): void;
+ availableFormats(callback: FormatsCallback): void;
+ getAvailableFormats(callback: FormatsCallback): void;
+
+ // ffprobe
+ ffprobe(callback: (err: any, data: FfprobeData) => void): void;
+ ffprobe(index: number, callback: (err: any, data: FfprobeData) => void): void;
+ ffprobe(options: string[], callback: (err: any, data: FfprobeData) => void): void; // tslint:disable-line unified-signatures
+ ffprobe(index: number, options: string[], callback: (err: any, data: FfprobeData) => void): void;
+
+ // event listeners
+ /**
+ * Emitted just after ffmpeg has been spawned.
+ *
+ * @event FfmpegCommand#start
+ * @param {String} command ffmpeg command line
+ */
+ on(event: "start", listener: (command: string) => void): this;
+
+ /**
+ * Emitted when ffmpeg reports progress information
+ *
+ * @event FfmpegCommand#progress
+ * @param {Object} progress progress object
+ * @param {Number} progress.frames number of frames transcoded
+ * @param {Number} progress.currentFps current processing speed in frames per second
+ * @param {Number} progress.currentKbps current output generation speed in kilobytes per second
+ * @param {Number} progress.targetSize current output file size
+ * @param {String} progress.timemark current video timemark
+ * @param {Number} [progress.percent] processing progress (may not be available depending on input)
+ */
+ on(
+ event: "progress",
+ listener: (progress: {
+ frames: number;
+ currentFps: number;
+ currentKbps: number;
+ targetSize: number;
+ timemark: string;
+ percent?: number | undefined;
+ }) => void,
+ ): this;
+
+ /**
+ * Emitted when ffmpeg outputs to stderr
+ *
+ * @event FfmpegCommand#stderr
+ * @param {String} line stderr output line
+ */
+ on(event: "stderr", listener: (line: string) => void): this;
+
+ /**
+ * Emitted when ffmpeg reports input codec data
+ *
+ * @event FfmpegCommand#codecData
+ * @param {Object} codecData codec data object
+ * @param {String} codecData.format input format name
+ * @param {String} codecData.audio input audio codec name
+ * @param {String} codecData.audio_details input audio codec parameters
+ * @param {String} codecData.video input video codec name
+ * @param {String} codecData.video_details input video codec parameters
+ */
+ on(
+ event: "codecData",
+ listener: (codecData: {
+ format: string;
+ audio: string;
+ audio_details: string;
+ video: string;
+ video_details: string;
+ }) => void,
+ ): this;
+
+ /**
+ * Emitted when an error happens when preparing or running a command
+ *
+ * @event FfmpegCommand#error
+ * @param {Error} error error object, with optional properties 'inputStreamError' / 'outputStreamError' for errors on their respective streams
+ * @param {String|null} stdout ffmpeg stdout, unless outputting to a stream
+ * @param {String|null} stderr ffmpeg stderr
+ */
+ on(event: "error", listener: (error: Error, stdout: string | null, stderr: string | null) => void): this;
+
+ /**
+ * Emitted when a command finishes processing
+ *
+ * @event FfmpegCommand#end
+ * @param {Array|String|null} [filenames|stdout] generated filenames when taking screenshots, ffmpeg stdout when not outputting to a stream, null otherwise
+ * @param {String|null} stderr ffmpeg stderr
+ */
+ on(event: "end", listener: (filenames: string[] | string | null, stderr: string | null) => void): this;
+
+ // recipes
+ saveToFile(output: string): FfmpegCommand;
+ save(output: string): FfmpegCommand;
+ writeToStream(stream: stream.Writable, options?: { end?: boolean | undefined }): stream.Writable;
+ pipe(stream?: stream.Writable, options?: { end?: boolean | undefined }): stream.Writable | stream.PassThrough;
+ stream(stream: stream.Writable, options?: { end?: boolean | undefined }): stream.Writable;
+ takeScreenshots(config: number | ScreenshotsConfig, folder?: string): FfmpegCommand;
+ thumbnail(config: number | ScreenshotsConfig, folder?: string): FfmpegCommand;
+ thumbnails(config: number | ScreenshotsConfig, folder?: string): FfmpegCommand;
+ screenshot(config: number | ScreenshotsConfig, folder?: string): FfmpegCommand;
+ screenshots(config: number | ScreenshotsConfig, folder?: string): FfmpegCommand;
+ mergeToFile(target: string | stream.Writable, tmpFolder: string): FfmpegCommand;
+ concatenate(target: string | stream.Writable, options?: { end?: boolean | undefined }): FfmpegCommand;
+ concat(target: string | stream.Writable, options?: { end?: boolean | undefined }): FfmpegCommand;
+ clone(): FfmpegCommand;
+ run(): void;
+ }
+
+ function ffprobe(file: string, callback: (err: any, data: FfprobeData) => void): void;
+ function ffprobe(file: string, index: number, callback: (err: any, data: FfprobeData) => void): void;
+ function ffprobe(file: string, options: string[], callback: (err: any, data: FfprobeData) => void): void; // tslint:disable-line unified-signatures
+ function ffprobe(
+ file: string,
+ index: number,
+ options: string[],
+ callback: (err: any, data: FfprobeData) => void,
+ ): void;
+}
+declare function Ffmpeg(options?: Ffmpeg.FfmpegCommandOptions): Ffmpeg.FfmpegCommand;
+declare function Ffmpeg(input?: string | stream.Readable, options?: Ffmpeg.FfmpegCommandOptions): Ffmpeg.FfmpegCommand;
+
+export = Ffmpeg;
diff --git a/server/libs/jsonwebtoken/lib/timespan.js b/server/libs/jsonwebtoken/lib/timespan.js
index 4d4574c2ed..4ef5851389 100644
--- a/server/libs/jsonwebtoken/lib/timespan.js
+++ b/server/libs/jsonwebtoken/lib/timespan.js
@@ -1,18 +1,17 @@
-var ms = require('../../ms');
+const ms = require('ms')
module.exports = function (time, iat) {
- var timestamp = iat || Math.floor(Date.now() / 1000);
+ var timestamp = iat || Math.floor(Date.now() / 1000)
if (typeof time === 'string') {
- var milliseconds = ms(time);
+ var milliseconds = ms(time)
if (typeof milliseconds === 'undefined') {
- return;
+ return
}
- return Math.floor(timestamp + milliseconds / 1000);
+ return Math.floor(timestamp + milliseconds / 1000)
} else if (typeof time === 'number') {
- return timestamp + time;
+ return timestamp + time
} else {
- return;
+ return
}
-
-};
\ No newline at end of file
+}
diff --git a/server/libs/ms/LICENSE b/server/libs/memorystore/LICENSE
similarity index 95%
rename from server/libs/ms/LICENSE
rename to server/libs/memorystore/LICENSE
index 69b61253a3..f8855e7e79 100644
--- a/server/libs/ms/LICENSE
+++ b/server/libs/memorystore/LICENSE
@@ -1,6 +1,6 @@
-The MIT License (MIT)
+MIT License
-Copyright (c) 2016 Zeit, Inc.
+Copyright (c) 2017 Rocco Musolino
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/server/libs/memorystore/index.js b/server/libs/memorystore/index.js
new file mode 100644
index 0000000000..b17e881355
--- /dev/null
+++ b/server/libs/memorystore/index.js
@@ -0,0 +1,303 @@
+/*!
+ * memorystore
+ * Copyright(c) 2020 Rocco Musolino <@roccomuso>
+ * MIT Licensed
+ */
+//
+// modified for audiobookshelf (update to lru-cache 10)
+// SOURCE: https://github.com/roccomuso/memorystore
+//
+
+var debug = require('debug')('memorystore')
+const { LRUCache } = require('lru-cache')
+var util = require('util')
+
+/**
+ * One day in milliseconds.
+ */
+
+var oneDay = 86400000
+
+function getTTL(options, sess, sid) {
+ if (typeof options.ttl === 'number') return options.ttl
+ if (typeof options.ttl === 'function') return options.ttl(options, sess, sid)
+ if (options.ttl) throw new TypeError('`options.ttl` must be a number or function.')
+
+ var maxAge = sess?.cookie?.maxAge || null
+ return typeof maxAge === 'number' ? Math.floor(maxAge) : oneDay
+}
+
+function prune(store) {
+ debug('Pruning expired entries')
+ store.forEach(function (value, key) {
+ store.get(key)
+ })
+}
+
+var defer =
+ typeof setImmediate === 'function'
+ ? setImmediate
+ : function (fn) {
+ process.nextTick(fn.bind.apply(fn, arguments))
+ }
+
+/**
+ * Return the `MemoryStore` extending `express`'s session Store.
+ *
+ * @param {object} express session
+ * @return {Function}
+ * @api public
+ */
+
+module.exports = function (session) {
+ /**
+ * Express's session Store.
+ */
+
+ var Store = session.Store
+
+ /**
+ * Initialize MemoryStore with the given `options`.
+ *
+ * @param {Object} options
+ * @api public
+ */
+
+ function MemoryStore(options) {
+ if (!(this instanceof MemoryStore)) {
+ throw new TypeError('Cannot call MemoryStore constructor as a function')
+ }
+
+ options = options || {}
+ Store.call(this, options)
+
+ this.options = {}
+ this.options.checkPeriod = options.checkPeriod
+ this.options.max = options.max
+ this.options.ttl = options.ttl
+ this.options.dispose = options.dispose
+ this.options.stale = options.stale
+
+ this.serializer = options.serializer || JSON
+ this.store = new LRUCache(this.options)
+ debug('Init MemoryStore')
+
+ this.startInterval()
+ }
+
+ /**
+ * Inherit from `Store`.
+ */
+
+ util.inherits(MemoryStore, Store)
+
+ /**
+ * Attempt to fetch session by the given `sid`.
+ *
+ * @param {String} sid
+ * @param {Function} fn
+ * @api public
+ */
+
+ MemoryStore.prototype.get = function (sid, fn) {
+ var store = this.store
+
+ debug('GET "%s"', sid)
+
+ var data = store.get(sid)
+ if (!data) return fn()
+
+ debug('GOT %s', data)
+ var err = null
+ var result
+ try {
+ result = this.serializer.parse(data)
+ } catch (er) {
+ err = er
+ }
+
+ fn && defer(fn, err, result)
+ }
+
+ /**
+ * Commit the given `sess` object associated with the given `sid`.
+ *
+ * @param {String} sid
+ * @param {Session} sess
+ * @param {Function} fn
+ * @api public
+ */
+
+ MemoryStore.prototype.set = function (sid, sess, fn) {
+ var store = this.store
+
+ var ttl = getTTL(this.options, sess, sid)
+ try {
+ var jsess = this.serializer.stringify(sess)
+ } catch (err) {
+ fn && defer(fn, err)
+ }
+
+ store.set(sid, jsess, {
+ ttl
+ })
+ debug('SET "%s" %s ttl:%s', sid, jsess, ttl)
+ fn && defer(fn, null)
+ }
+
+ /**
+ * Destroy the session associated with the given `sid`.
+ *
+ * @param {String} sid
+ * @api public
+ */
+
+ MemoryStore.prototype.destroy = function (sid, fn) {
+ var store = this.store
+
+ if (Array.isArray(sid)) {
+ sid.forEach(function (s) {
+ debug('DEL "%s"', s)
+ store.delete(s)
+ })
+ } else {
+ debug('DEL "%s"', sid)
+ store.delete(sid)
+ }
+ fn && defer(fn, null)
+ }
+
+ /**
+ * Refresh the time-to-live for the session with the given `sid`.
+ *
+ * @param {String} sid
+ * @param {Session} sess
+ * @param {Function} fn
+ * @api public
+ */
+
+ MemoryStore.prototype.touch = function (sid, sess, fn) {
+ var store = this.store
+
+ var ttl = getTTL(this.options, sess, sid)
+
+ debug('EXPIRE "%s" ttl:%s', sid, ttl)
+ var err = null
+ if (store.get(sid) !== undefined) {
+ try {
+ var s = this.serializer.parse(store.get(sid))
+ s.cookie = sess.cookie
+ store.set(sid, this.serializer.stringify(s), {
+ ttl
+ })
+ } catch (e) {
+ err = e
+ }
+ }
+ fn && defer(fn, err)
+ }
+
+ /**
+ * Fetch all sessions' ids
+ *
+ * @param {Function} fn
+ * @api public
+ */
+
+ MemoryStore.prototype.ids = function (fn) {
+ var store = this.store
+
+ var Ids = store.keys()
+ debug('Getting IDs: %s', Ids)
+ fn && defer(fn, null, Ids)
+ }
+
+ /**
+ * Fetch all sessions
+ *
+ * @param {Function} fn
+ * @api public
+ */
+
+ MemoryStore.prototype.all = function (fn) {
+ var store = this.store
+ var self = this
+
+ debug('Fetching all sessions')
+ var err = null
+ var result = {}
+ try {
+ store.forEach(function (val, key) {
+ result[key] = self.serializer.parse(val)
+ })
+ } catch (e) {
+ err = e
+ }
+ fn && defer(fn, err, result)
+ }
+
+ /**
+ * Delete all sessions from the store
+ *
+ * @param {Function} fn
+ * @api public
+ */
+
+ MemoryStore.prototype.clear = function (fn) {
+ var store = this.store
+ debug('delete all sessions from the store')
+ store.clear()
+ fn && defer(fn, null)
+ }
+
+ /**
+ * Get the count of all sessions in the store
+ *
+ * @param {Function} fn
+ * @api public
+ */
+
+ MemoryStore.prototype.length = function (fn) {
+ var store = this.store
+ debug('getting length', store.size)
+ fn && defer(fn, null, store.size)
+ }
+
+ /**
+ * Start the check interval
+ * @api public
+ */
+
+ MemoryStore.prototype.startInterval = function () {
+ var self = this
+ var ms = this.options.checkPeriod
+ if (ms && typeof ms === 'number') {
+ clearInterval(this._checkInterval)
+ debug('starting periodic check for expired sessions')
+ this._checkInterval = setInterval(function () {
+ prune(self.store) // iterates over the entire cache proactively pruning old entries
+ }, Math.floor(ms)).unref()
+ }
+ }
+
+ /**
+ * Stop the check interval
+ * @api public
+ */
+
+ MemoryStore.prototype.stopInterval = function () {
+ debug('stopping periodic check for expired sessions')
+ clearInterval(this._checkInterval)
+ }
+
+ /**
+ * Remove only expired entries from the store
+ * @api public
+ */
+
+ MemoryStore.prototype.prune = function () {
+ prune(this.store)
+ }
+
+ return MemoryStore
+}
diff --git a/server/libs/ms/index.js b/server/libs/ms/index.js
deleted file mode 100644
index 31a3ec72d5..0000000000
--- a/server/libs/ms/index.js
+++ /dev/null
@@ -1,153 +0,0 @@
-//
-// used by jsonwebtoken
-// Source: https://github.com/vercel/ms
-//
-
-var s = 1000;
-var m = s * 60;
-var h = m * 60;
-var d = h * 24;
-var y = d * 365.25;
-
-/**
- * Parse or format the given `val`.
- *
- * Options:
- *
- * - `long` verbose formatting [false]
- *
- * @param {String|Number} val
- * @param {Object} [options]
- * @throws {Error} throw an error if val is not a non-empty string or a number
- * @return {String|Number}
- * @api public
- */
-
-module.exports = function (val, options) {
- options = options || {};
- var type = typeof val;
- if (type === 'string' && val.length > 0) {
- return parse(val);
- } else if (type === 'number' && isNaN(val) === false) {
- return options.long ? fmtLong(val) : fmtShort(val);
- }
- throw new Error(
- 'val is not a non-empty string or a valid number. val=' +
- JSON.stringify(val)
- );
-};
-
-/**
- * Parse the given `str` and return milliseconds.
- *
- * @param {String} str
- * @return {Number}
- * @api private
- */
-
-function parse(str) {
- str = String(str);
- if (str.length > 100) {
- return;
- }
- var match = /^((?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|years?|yrs?|y)?$/i.exec(
- str
- );
- if (!match) {
- return;
- }
- var n = parseFloat(match[1]);
- var type = (match[2] || 'ms').toLowerCase();
- switch (type) {
- case 'years':
- case 'year':
- case 'yrs':
- case 'yr':
- case 'y':
- return n * y;
- case 'days':
- case 'day':
- case 'd':
- return n * d;
- case 'hours':
- case 'hour':
- case 'hrs':
- case 'hr':
- case 'h':
- return n * h;
- case 'minutes':
- case 'minute':
- case 'mins':
- case 'min':
- case 'm':
- return n * m;
- case 'seconds':
- case 'second':
- case 'secs':
- case 'sec':
- case 's':
- return n * s;
- case 'milliseconds':
- case 'millisecond':
- case 'msecs':
- case 'msec':
- case 'ms':
- return n;
- default:
- return undefined;
- }
-}
-
-/**
- * Short format for `ms`.
- *
- * @param {Number} ms
- * @return {String}
- * @api private
- */
-
-function fmtShort(ms) {
- if (ms >= d) {
- return Math.round(ms / d) + 'd';
- }
- if (ms >= h) {
- return Math.round(ms / h) + 'h';
- }
- if (ms >= m) {
- return Math.round(ms / m) + 'm';
- }
- if (ms >= s) {
- return Math.round(ms / s) + 's';
- }
- return ms + 'ms';
-}
-
-/**
- * Long format for `ms`.
- *
- * @param {Number} ms
- * @return {String}
- * @api private
- */
-
-function fmtLong(ms) {
- return plural(ms, d, 'day') ||
- plural(ms, h, 'hour') ||
- plural(ms, m, 'minute') ||
- plural(ms, s, 'second') ||
- ms + ' ms';
-}
-
-/**
- * Pluralization helper.
- */
-
-function plural(ms, n, name) {
- if (ms < n) {
- return;
- }
- if (ms < n * 1.5) {
- return Math.floor(ms / n) + ' ' + name;
- }
- return Math.ceil(ms / n) + ' ' + name + 's';
-}
diff --git a/server/managers/AbMergeManager.js b/server/managers/AbMergeManager.js
index 711e8892df..77702d790a 100644
--- a/server/managers/AbMergeManager.js
+++ b/server/managers/AbMergeManager.js
@@ -1,29 +1,56 @@
const Path = require('path')
const fs = require('../libs/fsExtra')
-
-const workerThreads = require('worker_threads')
const Logger = require('../Logger')
const TaskManager = require('./TaskManager')
const Task = require('../objects/Task')
-const { writeConcatFile } = require('../utils/ffmpegHelpers')
const ffmpegHelpers = require('../utils/ffmpegHelpers')
+const Ffmpeg = require('../libs/fluentFfmpeg')
+const SocketAuthority = require('../SocketAuthority')
+const { isWritable, copyToExisting } = require('../utils/fileUtils')
+const TrackProgressMonitor = require('../objects/TrackProgressMonitor')
+
+/**
+ * @typedef AbMergeEncodeOptions
+ * @property {string} codec
+ * @property {string} channels
+ * @property {string} bitrate
+ */
class AbMergeManager {
constructor() {
this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items')
+ /** @type {Task[]} */
this.pendingTasks = []
}
+ /**
+ *
+ * @param {string} libraryItemId
+ * @returns {Task|null}
+ */
getPendingTaskByLibraryItemId(libraryItemId) {
return this.pendingTasks.find((t) => t.task.data.libraryItemId === libraryItemId)
}
+ /**
+ * Cancel and fail running task
+ *
+ * @param {Task} task
+ * @returns {Promise}
+ */
cancelEncode(task) {
+ task.setFailed('Task canceled by user')
return this.removeTask(task, true)
}
- async startAudiobookMerge(user, libraryItem, options = {}) {
+ /**
+ *
+ * @param {string} userId
+ * @param {import('../objects/LibraryItem')} libraryItem
+ * @param {AbMergeEncodeOptions} [options={}]
+ */
+ async startAudiobookMerge(userId, libraryItem, options = {}) {
const task = new Task()
const audiobookDirname = Path.basename(libraryItem.path)
@@ -34,8 +61,9 @@ class AbMergeManager {
const taskData = {
libraryItemId: libraryItem.id,
libraryItemPath: libraryItem.path,
- userId: user.id,
+ userId,
originalTrackPaths: libraryItem.media.tracks.map((t) => t.metadata.path),
+ inos: libraryItem.media.includedAudioFiles.map((f) => f.ino),
tempFilepath,
targetFilename,
targetFilepath: Path.join(libraryItem.path, targetFilename),
@@ -43,7 +71,9 @@ class AbMergeManager {
ffmetadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, 1),
chapters: libraryItem.media.chapters?.map((c) => ({ ...c })),
coverPath: libraryItem.media.coverPath,
- ffmetadataPath
+ ffmetadataPath,
+ duration: libraryItem.media.duration,
+ encodeOptions: options
}
const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`
task.setData('encode-m4b', 'Encoding M4b', taskDescription, false, taskData)
@@ -57,136 +87,118 @@ class AbMergeManager {
this.runAudiobookMerge(libraryItem, task, options || {})
}
+ /**
+ *
+ * @param {import('../objects/LibraryItem')} libraryItem
+ * @param {Task} task
+ * @param {AbMergeEncodeOptions} encodingOptions
+ */
async runAudiobookMerge(libraryItem, task, encodingOptions) {
- // Create ffmetadata file
- const success = await ffmpegHelpers.writeFFMetadataFile(task.data.metadataObject, task.data.chapters, task.data.ffmetadataPath)
- if (!success) {
- Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook "${task.data.libraryItemId}"`)
- task.setFailed('Failed to write metadata file.')
+ // Make sure the target directory is writable
+ if (!(await isWritable(libraryItem.path))) {
+ Logger.error(`[AbMergeManager] Target directory is not writable: ${libraryItem.path}`)
+ task.setFailed('Target directory is not writable')
this.removeTask(task, true)
return
}
- const audioBitrate = encodingOptions.bitrate || '128k'
- const audioCodec = encodingOptions.codec || 'aac'
- const audioChannels = encodingOptions.channels || 2
-
- // If changing audio file type then encoding is needed
- const audioTracks = libraryItem.media.tracks
-
- // TODO: Updated in 2.2.11 to always encode even if merging multiple m4b. This is because just using the file extension as was being done before is not enough. This can be an option or do more to check if a concat is possible.
- // const audioRequiresEncode = audioTracks[0].metadata.ext !== '.m4b'
- const audioRequiresEncode = true
-
- const firstTrackIsM4b = audioTracks[0].metadata.ext.toLowerCase() === '.m4b'
- const isOneTrack = audioTracks.length === 1
-
- const ffmpegInputs = []
-
- if (!isOneTrack) {
- const concatFilePath = Path.join(task.data.itemCachePath, 'files.txt')
- await writeConcatFile(audioTracks, concatFilePath)
- ffmpegInputs.push({
- input: concatFilePath,
- options: ['-safe 0', '-f concat']
- })
- } else {
- ffmpegInputs.push({
- input: audioTracks[0].metadata.path,
- options: firstTrackIsM4b ? ['-f mp4'] : []
- })
- }
-
- const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
- let ffmpegOptions = [`-loglevel ${logLevel}`]
- const ffmpegOutputOptions = ['-f mp4']
-
- if (audioRequiresEncode) {
- ffmpegOptions = ffmpegOptions.concat(['-map 0:a', `-acodec ${audioCodec}`, `-ac ${audioChannels}`, `-b:a ${audioBitrate}`])
- } else {
- ffmpegOptions.push('-max_muxing_queue_size 1000')
-
- if (isOneTrack && firstTrackIsM4b) {
- ffmpegOptions.push('-c copy')
- } else {
- ffmpegOptions.push('-c:a copy')
- }
- }
-
- const workerData = {
- inputs: ffmpegInputs,
- options: ffmpegOptions,
- outputOptions: ffmpegOutputOptions,
- output: task.data.tempFilepath
- }
-
- let worker = null
- try {
- const workerPath = Path.join(global.appRoot, 'server/utils/downloadWorker.js')
- worker = new workerThreads.Worker(workerPath, { workerData })
- } catch (error) {
- Logger.error(`[AbMergeManager] Start worker thread failed`, error)
- task.setFailed('Failed to start worker thread')
+ // Create ffmetadata file
+ if (!(await ffmpegHelpers.writeFFMetadataFile(task.data.ffmetadataObject, task.data.chapters, task.data.ffmetadataPath))) {
+ Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook "${task.data.libraryItemId}"`)
+ task.setFailed('Failed to write metadata file.')
this.removeTask(task, true)
return
}
- worker.on('message', (message) => {
- if (message != null && typeof message === 'object') {
- if (message.type === 'RESULT') {
- this.sendResult(task, message)
- } else if (message.type === 'FFMPEG') {
- if (Logger[message.level]) {
- Logger[message.level](message.log)
- }
- }
- }
- })
this.pendingTasks.push({
id: task.id,
- task,
- worker
+ task
})
- }
- async sendResult(task, result) {
- // Remove pending task
- this.pendingTasks = this.pendingTasks.filter((d) => d.id !== task.id)
-
- if (result.isKilled) {
- task.setFailed('Ffmpeg task killed')
- this.removeTask(task, true)
- return
- }
-
- if (!result.success) {
- task.setFailed('Encoding failed')
- this.removeTask(task, true)
+ const encodeFraction = 0.95
+ const embedFraction = 1 - encodeFraction
+ try {
+ const trackProgressMonitor = new TrackProgressMonitor(
+ libraryItem.media.tracks.map((t) => t.duration),
+ (trackIndex) => SocketAuthority.adminEmitter('track_started', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] }),
+ (trackIndex, progressInTrack, taskProgress) => {
+ SocketAuthority.adminEmitter('track_progress', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex], progress: progressInTrack })
+ SocketAuthority.adminEmitter('task_progress', { libraryItemId: libraryItem.id, progress: taskProgress * encodeFraction })
+ },
+ (trackIndex) => SocketAuthority.adminEmitter('track_finished', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] })
+ )
+ task.data.ffmpeg = new Ffmpeg()
+ await ffmpegHelpers.mergeAudioFiles(libraryItem.media.tracks, task.data.duration, task.data.itemCachePath, task.data.tempFilepath, encodingOptions, (progress) => trackProgressMonitor.update(progress), task.data.ffmpeg)
+ delete task.data.ffmpeg
+ trackProgressMonitor.finish()
+ } catch (error) {
+ if (error.message === 'FFMPEG_CANCELED') {
+ Logger.info(`[AbMergeManager] Task cancelled ${task.id}`)
+ } else {
+ Logger.error(`[AbMergeManager] mergeAudioFiles failed`, error)
+ task.setFailed('Failed to merge audio files')
+ this.removeTask(task, true)
+ }
return
}
// Write metadata to merged file
- const success = await ffmpegHelpers.addCoverAndMetadataToFile(task.data.tempFilepath, task.data.coverPath, task.data.ffmetadataPath, 1, 'audio/mp4')
- if (!success) {
- Logger.error(`[AbMergeManager] Failed to write metadata to file "${task.data.tempFilepath}"`)
- task.setFailed('Failed to write metadata to m4b file')
- this.removeTask(task, true)
+ try {
+ task.data.ffmpeg = new Ffmpeg()
+ await ffmpegHelpers.addCoverAndMetadataToFile(
+ task.data.tempFilepath,
+ task.data.coverPath,
+ task.data.ffmetadataPath,
+ 1,
+ 'audio/mp4',
+ (progress) => {
+ Logger.debug(`[AbMergeManager] Embedding metadata progress: ${100 * encodeFraction + progress * embedFraction}`)
+ SocketAuthority.adminEmitter('task_progress', { libraryItemId: libraryItem.id, progress: 100 * encodeFraction + progress * embedFraction })
+ },
+ task.data.ffmpeg
+ )
+ delete task.data.ffmpeg
+ } catch (error) {
+ if (error.message === 'FFMPEG_CANCELED') {
+ Logger.info(`[AbMergeManager] Task cancelled ${task.id}`)
+ } else {
+ Logger.error(`[AbMergeManager] Failed to write metadata to file "${task.data.tempFilepath}"`)
+ task.setFailed('Failed to write metadata to m4b file')
+ this.removeTask(task, true)
+ }
return
}
// Move library item tracks to cache
- for (const trackPath of task.data.originalTrackPaths) {
+ for (const [index, trackPath] of task.data.originalTrackPaths.entries()) {
const trackFilename = Path.basename(trackPath)
const moveToPath = Path.join(task.data.itemCachePath, trackFilename)
Logger.debug(`[AbMergeManager] Backing up original track "${trackPath}" to ${moveToPath}`)
- await fs.move(trackPath, moveToPath, { overwrite: true }).catch((err) => {
- Logger.error(`[AbMergeManager] Failed to move track "${trackPath}" to "${moveToPath}"`, err)
- })
+ if (index === 0) {
+ // copy the first track to the cache directory
+ await fs.copy(trackPath, moveToPath).catch((err) => {
+ Logger.error(`[AbMergeManager] Failed to copy track "${trackPath}" to "${moveToPath}"`, err)
+ })
+ } else {
+ // move the rest of the tracks to the cache directory
+ await fs.move(trackPath, moveToPath, { overwrite: true }).catch((err) => {
+ Logger.error(`[AbMergeManager] Failed to move track "${trackPath}" to "${moveToPath}"`, err)
+ })
+ }
}
- // Move m4b to target
+ // Move m4b to target, preserving the original track's permissions
Logger.debug(`[AbMergeManager] Moving m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`)
- await fs.move(task.data.tempFilepath, task.data.targetFilepath)
+ try {
+ await copyToExisting(task.data.tempFilepath, task.data.originalTrackPaths[0])
+ await fs.rename(task.data.originalTrackPaths[0], task.data.targetFilepath)
+ await fs.remove(task.data.tempFilepath)
+ } catch (err) {
+ Logger.error(`[AbMergeManager] Failed to move m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`, err)
+ task.setFailed('Failed to move m4b file')
+ this.removeTask(task, true)
+ return
+ }
// Remove ffmetadata file
await fs.remove(task.data.ffmetadataPath)
@@ -196,22 +208,23 @@ class AbMergeManager {
Logger.info(`[AbMergeManager] Ab task finished ${task.id}`)
}
+ /**
+ * Remove ab merge task
+ *
+ * @param {Task} task
+ * @param {boolean} [removeTempFilepath=false]
+ */
async removeTask(task, removeTempFilepath = false) {
Logger.info('[AbMergeManager] Removing task ' + task.id)
- const pendingDl = this.pendingTasks.find((d) => d.id === task.id)
- if (pendingDl) {
+ const pendingTask = this.pendingTasks.find((d) => d.id === task.id)
+ if (pendingTask) {
this.pendingTasks = this.pendingTasks.filter((d) => d.id !== task.id)
- if (pendingDl.worker) {
- Logger.warn(`[AbMergeManager] Removing download in progress - stopping worker`)
- try {
- pendingDl.worker.postMessage('STOP')
- return
- } catch (error) {
- Logger.error('[AbMergeManager] Error posting stop message to worker', error)
- }
- } else {
- Logger.debug(`[AbMergeManager] Removing download in progress - no worker`)
+ if (task.data.ffmpeg) {
+ Logger.warn(`[AbMergeManager] Killing ffmpeg process for task ${task.id}`)
+ task.data.ffmpeg.kill()
+ // wait for ffmpeg to exit, so that the output file is unlocked
+ await new Promise((resolve) => setTimeout(resolve, 500))
}
}
diff --git a/server/managers/ApiCacheManager.js b/server/managers/ApiCacheManager.js
index bb99b8cbb2..35009447da 100644
--- a/server/managers/ApiCacheManager.js
+++ b/server/managers/ApiCacheManager.js
@@ -3,8 +3,7 @@ const Logger = require('../Logger')
const Database = require('../Database')
class ApiCacheManager {
-
- defaultCacheOptions = { max: 1000, maxSize: 10 * 1000 * 1000, sizeCalculation: item => (item.body.length + JSON.stringify(item.headers).length) }
+ defaultCacheOptions = { max: 1000, maxSize: 10 * 1000 * 1000, sizeCalculation: (item) => item.body.length + JSON.stringify(item.headers).length }
defaultTtlOptions = { ttl: 30 * 60 * 1000 }
constructor(cache = new LRUCache(this.defaultCacheOptions), ttlOptions = this.defaultTtlOptions) {
@@ -14,7 +13,7 @@ class ApiCacheManager {
init(database = Database) {
let hooks = ['afterCreate', 'afterUpdate', 'afterDestroy', 'afterBulkCreate', 'afterBulkUpdate', 'afterBulkDestroy', 'afterUpsert']
- hooks.forEach(hook => database.sequelize.addHook(hook, (model) => this.clear(model, hook)))
+ hooks.forEach((hook) => database.sequelize.addHook(hook, (model) => this.clear(model, hook)))
}
clear(model, hook) {
@@ -33,7 +32,16 @@ class ApiCacheManager {
}
get middleware() {
+ /**
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res
+ * @param {import('express').NextFunction} next
+ */
return (req, res, next) => {
+ if (req.query.sort === 'random') {
+ Logger.debug(`[ApiCacheManager] Skipping cache for random sort`)
+ return next()
+ }
const key = { user: req.user.username, url: req.url }
const stringifiedKey = JSON.stringify(key)
Logger.debug(`[ApiCacheManager] count: ${this.cache.size} size: ${this.cache.calculatedSize}`)
@@ -61,4 +69,4 @@ class ApiCacheManager {
}
}
}
-module.exports = ApiCacheManager
\ No newline at end of file
+module.exports = ApiCacheManager
diff --git a/server/managers/AudioMetadataManager.js b/server/managers/AudioMetadataManager.js
index 09b773f6f9..f970d5a8af 100644
--- a/server/managers/AudioMetadataManager.js
+++ b/server/managers/AudioMetadataManager.js
@@ -1,15 +1,11 @@
const Path = require('path')
-
const SocketAuthority = require('../SocketAuthority')
const Logger = require('../Logger')
-
const fs = require('../libs/fsExtra')
-
const ffmpegHelpers = require('../utils/ffmpegHelpers')
-
const TaskManager = require('./TaskManager')
-
const Task = require('../objects/Task')
+const fileUtils = require('../utils/fileUtils')
class AudioMetadataMangaer {
constructor() {
@@ -36,13 +32,25 @@ class AudioMetadataMangaer {
return ffmpegHelpers.getFFMetadataObject(libraryItem, libraryItem.media.includedAudioFiles.length)
}
- handleBatchEmbed(user, libraryItems, options = {}) {
+ /**
+ *
+ * @param {string} userId
+ * @param {*} libraryItems
+ * @param {*} options
+ */
+ handleBatchEmbed(userId, libraryItems, options = {}) {
libraryItems.forEach((li) => {
- this.updateMetadataForItem(user, li, options)
+ this.updateMetadataForItem(userId, li, options)
})
}
- async updateMetadataForItem(user, libraryItem, options = {}) {
+ /**
+ *
+ * @param {string} userId
+ * @param {*} libraryItem
+ * @param {*} options
+ */
+ async updateMetadataForItem(userId, libraryItem, options = {}) {
const forceEmbedChapters = !!options.forceEmbedChapters
const backupFiles = !!options.backup
@@ -62,13 +70,14 @@ class AudioMetadataMangaer {
const taskData = {
libraryItemId: libraryItem.id,
libraryItemPath: libraryItem.path,
- userId: user.id,
+ userId,
audioFiles: audioFiles.map((af) => ({
index: af.index,
ino: af.ino,
filename: af.metadata.filename,
path: af.metadata.path,
- cachePath: Path.join(itemCachePath, af.metadata.filename)
+ cachePath: Path.join(itemCachePath, af.metadata.filename),
+ duration: af.duration
})),
coverPath: libraryItem.media.coverPath,
metadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, audioFiles.length),
@@ -78,7 +87,8 @@ class AudioMetadataMangaer {
options: {
forceEmbedChapters,
backupFiles
- }
+ },
+ duration: libraryItem.media.duration
}
const taskDescription = `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".`
task.setData('embed-metadata', 'Embedding Metadata', taskDescription, false, taskData)
@@ -101,11 +111,40 @@ class AudioMetadataMangaer {
Logger.info(`[AudioMetadataManager] Starting metadata embed task`, task.description)
+ // Ensure target directory is writable
+ const targetDirWritable = await fileUtils.isWritable(task.data.libraryItemPath)
+ Logger.debug(`[AudioMetadataManager] Target directory ${task.data.libraryItemPath} writable: ${targetDirWritable}`)
+ if (!targetDirWritable) {
+ Logger.error(`[AudioMetadataManager] Target directory is not writable: ${task.data.libraryItemPath}`)
+ task.setFailed('Target directory is not writable')
+ this.handleTaskFinished(task)
+ return
+ }
+
+ // Ensure target audio files are writable
+ for (const af of task.data.audioFiles) {
+ try {
+ await fs.access(af.path, fs.constants.W_OK)
+ } catch (err) {
+ Logger.error(`[AudioMetadataManager] Audio file is not writable: ${af.path}`)
+ task.setFailed(`Audio file "${Path.basename(af.path)}" is not writable`)
+ this.handleTaskFinished(task)
+ return
+ }
+ }
+
// Ensure item cache dir exists
let cacheDirCreated = false
if (!(await fs.pathExists(task.data.itemCachePath))) {
- await fs.mkdir(task.data.itemCachePath)
- cacheDirCreated = true
+ try {
+ await fs.mkdir(task.data.itemCachePath)
+ cacheDirCreated = true
+ } catch (err) {
+ Logger.error(`[AudioMetadataManager] Failed to create cache directory ${task.data.itemCachePath}`, err)
+ task.setFailed('Failed to create cache directory')
+ this.handleTaskFinished(task)
+ return
+ }
}
// Create ffmetadata file
@@ -119,8 +158,10 @@ class AudioMetadataMangaer {
}
// Tag audio files
+ let cummulativeProgress = 0
for (const af of task.data.audioFiles) {
- SocketAuthority.adminEmitter('audiofile_metadata_started', {
+ const audioFileRelativeDuration = af.duration / task.data.duration
+ SocketAuthority.adminEmitter('track_started', {
libraryItemId: task.data.libraryItemId,
ino: af.ino
})
@@ -133,18 +174,31 @@ class AudioMetadataMangaer {
Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`)
} catch (err) {
Logger.error(`[AudioMetadataManager] Failed to backup audio file "${af.path}"`, err)
+ task.setFailed(`Failed to backup audio file "${Path.basename(af.path)}"`)
+ this.handleTaskFinished(task)
+ return
}
}
- const success = await ffmpegHelpers.addCoverAndMetadataToFile(af.path, task.data.coverPath, ffmetadataPath, af.index, task.data.mimeType)
- if (success) {
+ try {
+ await ffmpegHelpers.addCoverAndMetadataToFile(af.path, task.data.coverPath, ffmetadataPath, af.index, task.data.mimeType, (progress) => {
+ SocketAuthority.adminEmitter('task_progress', { libraryItemId: task.data.libraryItemId, progress: cummulativeProgress + progress * audioFileRelativeDuration })
+ SocketAuthority.adminEmitter('track_progress', { libraryItemId: task.data.libraryItemId, ino: af.ino, progress })
+ })
Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${af.path}"`)
+ } catch (err) {
+ Logger.error(`[AudioMetadataManager] Failed to tag audio file "${af.path}"`, err)
+ task.setFailed(`Failed to tag audio file "${Path.basename(af.path)}"`)
+ this.handleTaskFinished(task)
+ return
}
- SocketAuthority.adminEmitter('audiofile_metadata_finished', {
+ SocketAuthority.adminEmitter('track_finished', {
libraryItemId: task.data.libraryItemId,
ino: af.ino
})
+
+ cummulativeProgress += audioFileRelativeDuration * 100
}
// Remove temp cache file/folder if not backing up
diff --git a/server/managers/BackupManager.js b/server/managers/BackupManager.js
index 88772c586b..b8b1beea81 100644
--- a/server/managers/BackupManager.js
+++ b/server/managers/BackupManager.js
@@ -42,7 +42,7 @@ class BackupManager {
}
get maxBackupSize() {
- return global.ServerSettings.maxBackupSize || 1
+ return global.ServerSettings.maxBackupSize || Infinity
}
async init() {
@@ -216,7 +216,9 @@ class BackupManager {
Logger.info(`[BackupManager] Saved backup sqlite file at "${dbPath}"`)
// Extract /metadata/items and /metadata/authors folders
+ await fs.ensureDir(this.ItemsMetadataPath)
await zip.extract('metadata-items/', this.ItemsMetadataPath)
+ await fs.ensureDir(this.AuthorsMetadataPath)
await zip.extract('metadata-authors/', this.AuthorsMetadataPath)
await zip.close()
@@ -419,14 +421,16 @@ class BackupManager {
reject(err)
})
archive.on('progress', ({ fs: fsobj }) => {
- const maxBackupSizeInBytes = this.maxBackupSize * 1000 * 1000 * 1000
- if (fsobj.processedBytes > maxBackupSizeInBytes) {
- Logger.error(`[BackupManager] Archiver is too large - aborting to prevent endless loop, Bytes Processed: ${fsobj.processedBytes}`)
- archive.abort()
- setTimeout(() => {
- this.removeBackup(backup)
- output.destroy('Backup too large') // Promise is reject in write stream error evt
- }, 500)
+ if (this.maxBackupSize !== Infinity) {
+ const maxBackupSizeInBytes = this.maxBackupSize * 1000 * 1000 * 1000
+ if (fsobj.processedBytes > maxBackupSizeInBytes) {
+ Logger.error(`[BackupManager] Archiver is too large - aborting to prevent endless loop, Bytes Processed: ${fsobj.processedBytes}`)
+ archive.abort()
+ setTimeout(() => {
+ this.removeBackup(backup)
+ output.destroy('Backup too large') // Promise is reject in write stream error evt
+ }, 500)
+ }
}
})
diff --git a/server/managers/BinaryManager.js b/server/managers/BinaryManager.js
index 25eb1ebc24..0e9353cf41 100644
--- a/server/managers/BinaryManager.js
+++ b/server/managers/BinaryManager.js
@@ -2,30 +2,288 @@ const child_process = require('child_process')
const { promisify } = require('util')
const exec = promisify(child_process.exec)
const path = require('path')
+const axios = require('axios')
const which = require('../libs/which')
const fs = require('../libs/fsExtra')
-const ffbinaries = require('../libs/ffbinaries')
const Logger = require('../Logger')
const fileUtils = require('../utils/fileUtils')
+const StreamZip = require('../libs/nodeStreamZip')
-class BinaryManager {
+class GithubAssetDownloader {
+ constructor(owner, repo) {
+ this.owner = owner
+ this.repo = repo
+ this.assetCache = {}
+ }
+
+ async getAssetUrl(releaseTag, assetName) {
+ // Check if the assets information is already cached for the release tag
+ if (this.assetCache[releaseTag]) {
+ Logger.debug(`[GithubAssetDownloader] Repo ${this.repo} release ${releaseTag}: assets found in cache.`)
+ } else {
+ // Get the release information
+ const releaseUrl = `https://api.github.com/repos/${this.owner}/${this.repo}/releases/tags/${releaseTag}`
+ const releaseResponse = await axios.get(releaseUrl, {
+ headers: {
+ Accept: 'application/vnd.github.v3+json',
+ 'User-Agent': 'axios'
+ }
+ })
+
+ // Cache the assets information for the release tag
+ this.assetCache[releaseTag] = releaseResponse.data.assets
+ Logger.debug(`[GithubAssetDownloader] Repo ${this.repo} release ${releaseTag}: assets fetched from API.`)
+ }
+
+ // Find the asset URL
+ const assets = this.assetCache[releaseTag]
+ const asset = assets.find((asset) => asset.name === assetName)
+ if (!asset) {
+ throw new Error(`[GithubAssetDownloader] Repo ${this.repo} release ${releaseTag}: asset ${assetName} not found`)
+ }
+
+ return asset.browser_download_url
+ }
+
+ async downloadAsset(assetUrl, destDir) {
+ const zipPath = path.join(destDir, 'temp.zip')
+ const writer = fs.createWriteStream(zipPath)
+
+ const assetResponse = await axios({
+ url: assetUrl,
+ method: 'GET',
+ responseType: 'stream'
+ })
+
+ assetResponse.data.pipe(writer)
+
+ await new Promise((resolve, reject) => {
+ writer.on('finish', () => {
+ Logger.debug(`[GithubAssetDownloader] Downloaded asset ${assetUrl} to ${zipPath}`)
+ resolve()
+ })
+ writer.on('error', (err) => {
+ Logger.error(`[GithubAssetDownloader] Error downloading asset ${assetUrl}: ${err.message}`)
+ reject(err)
+ })
+ })
+
+ return zipPath
+ }
+
+ async extractFiles(zipPath, filesToExtract, destDir) {
+ const zip = new StreamZip.async({ file: zipPath })
+
+ for (const file of filesToExtract) {
+ const outputPath = path.join(destDir, file.outputFileName)
+ await zip.extract(file.pathInsideZip, outputPath)
+ Logger.debug(`[GithubAssetDownloader] Extracted file ${file.pathInsideZip} to ${outputPath}`)
+
+ // Set executable permission for Linux
+ if (process.platform !== 'win32') {
+ await fs.chmod(outputPath, 0o755)
+ }
+ }
+
+ await zip.close()
+ }
+
+ async downloadAndExtractFiles(releaseTag, assetName, filesToExtract, destDir) {
+ let zipPath
+ try {
+ await fs.ensureDir(destDir)
+ const assetUrl = await this.getAssetUrl(releaseTag, assetName)
+ zipPath = await this.downloadAsset(assetUrl, destDir)
+ await this.extractFiles(zipPath, filesToExtract, destDir)
+ } catch (error) {
+ Logger.error(`[GithubAssetDownloader] Error downloading or extracting files: ${error.message}`)
+ throw error
+ } finally {
+ if (zipPath) await fs.remove(zipPath)
+ }
+ }
+}
+
+class FFBinariesDownloader extends GithubAssetDownloader {
+ constructor() {
+ super('ffbinaries', 'ffbinaries-prebuilt')
+ }
+ getPlatformSuffix() {
+ const platform = process.platform
+ const arch = process.arch
+
+ switch (platform) {
+ case 'win32':
+ return 'win-64'
+ case 'darwin':
+ return 'macos-64'
+ case 'linux':
+ switch (arch) {
+ case 'x64':
+ return 'linux-64'
+ case 'x32':
+ case 'ia32':
+ return 'linux-32'
+ case 'arm64':
+ return 'linux-arm-64'
+ case 'arm':
+ return 'linux-armhf-32'
+ default:
+ throw new Error(`Unsupported architecture: ${arch}`)
+ }
+ default:
+ throw new Error(`Unsupported platform: ${platform}`)
+ }
+ }
+
+ async downloadBinary(binaryName, releaseTag, destDir) {
+ const platformSuffix = this.getPlatformSuffix()
+ const assetName = `${binaryName}-${releaseTag}-${platformSuffix}.zip`
+ const fileName = process.platform === 'win32' ? `${binaryName}.exe` : binaryName
+ const filesToExtract = [{ pathInsideZip: fileName, outputFileName: fileName }]
+ releaseTag = `v${releaseTag}`
+
+ await this.downloadAndExtractFiles(releaseTag, assetName, filesToExtract, destDir)
+ }
+}
+
+class SQLeanDownloader extends GithubAssetDownloader {
+ constructor() {
+ super('nalgeon', 'sqlean')
+ }
+
+ getPlatformSuffix() {
+ const platform = process.platform
+ const arch = process.arch
+
+ switch (platform) {
+ case 'win32':
+ return arch === 'x64' ? 'win-x64' : 'win-x86'
+ case 'darwin':
+ return arch === 'arm64' ? 'macos-arm64' : 'macos-x86'
+ case 'linux':
+ return arch === 'arm64' ? 'linux-arm64' : 'linux-x86'
+ default:
+ throw new Error(`Unsupported platform or architecture: ${platform}, ${arch}`)
+ }
+ }
+
+ getLibraryName(binaryName) {
+ const platform = process.platform
+
+ switch (platform) {
+ case 'win32':
+ return `${binaryName}.dll`
+ case 'darwin':
+ return `${binaryName}.dylib`
+ case 'linux':
+ return `${binaryName}.so`
+ default:
+ throw new Error(`Unsupported platform: ${platform}`)
+ }
+ }
+
+ async downloadBinary(binaryName, releaseTag, destDir) {
+ const platformSuffix = this.getPlatformSuffix()
+ const assetName = `sqlean-${platformSuffix}.zip`
+ const fileName = this.getLibraryName(binaryName)
+ const filesToExtract = [{ pathInsideZip: fileName, outputFileName: fileName }]
+
+ await this.downloadAndExtractFiles(releaseTag, assetName, filesToExtract, destDir)
+ }
+}
+
+class Binary {
+ constructor(name, type, envVariable, validVersions, source) {
+ this.name = name
+ this.type = type
+ this.envVariable = envVariable
+ this.validVersions = validVersions
+ this.source = source
+ this.fileName = this.getFileName()
+ this.exec = exec
+ }
+
+ async find(mainInstallDir, altInstallDir) {
+ // 1. check path specified in environment variable
+ const defaultPath = process.env[this.envVariable]
+ if (await this.isGood(defaultPath)) return defaultPath
+ // 2. find the first instance of the binary in the PATH environment variable
+ if (this.type === 'executable') {
+ const whichPath = which.sync(this.fileName, { nothrow: true })
+ if (await this.isGood(whichPath)) return whichPath
+ }
+ // 3. check main install path (binary root dir)
+ const mainInstallPath = path.join(mainInstallDir, this.fileName)
+ if (await this.isGood(mainInstallPath)) return mainInstallPath
+ // 4. check alt install path (/config)
+ const altInstallPath = path.join(altInstallDir, this.fileName)
+ if (await this.isGood(altInstallPath)) return altInstallPath
+ return null
+ }
+
+ getFileName() {
+ const platform = process.platform
+
+ if (this.type === 'executable') {
+ return this.name + (platform == 'win32' ? '.exe' : '')
+ } else if (this.type === 'library') {
+ return this.name + (platform == 'win32' ? '.dll' : platform == 'darwin' ? '.dylib' : '.so')
+ } else {
+ return this.name
+ }
+ }
+
+ async isGood(binaryPath) {
+ if (!binaryPath || !(await fs.pathExists(binaryPath))) return false
+ if (!this.validVersions.length) return true
+ if (this.type === 'library') return true
+ try {
+ const { stdout } = await this.exec('"' + binaryPath + '"' + ' -version')
+ const version = stdout.match(/version\s([\d\.]+)/)?.[1]
+ if (!version) return false
+ return this.validVersions.some((validVersion) => version.startsWith(validVersion))
+ } catch (err) {
+ Logger.error(`[Binary] Failed to check version of ${binaryPath}`)
+ return false
+ }
+ }
+
+ async download(destination) {
+ await this.source.downloadBinary(this.name, this.validVersions[0], destination)
+ }
+}
+
+const ffbinaries = new FFBinariesDownloader()
+module.exports.ffbinaries = ffbinaries // for testing
+const sqlean = new SQLeanDownloader()
+module.exports.sqlean = sqlean // for testing
+
+class BinaryManager {
defaultRequiredBinaries = [
- { name: 'ffmpeg', envVariable: 'FFMPEG_PATH', validVersions: ['5.1'] },
- { name: 'ffprobe', envVariable: 'FFPROBE_PATH', validVersions: ['5.1'] }
+ new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries), // ffmpeg executable
+ new Binary('ffprobe', 'executable', 'FFPROBE_PATH', ['5.1'], ffbinaries) // ffprobe executable
+ // TODO: Temporarily disabled due to db corruption issues
+ // new Binary('unicode', 'library', 'SQLEAN_UNICODE_PATH', ['0.24.2'], sqlean) // sqlean unicode extension
]
constructor(requiredBinaries = this.defaultRequiredBinaries) {
this.requiredBinaries = requiredBinaries
- this.mainInstallPath = process.pkg ? path.dirname(process.execPath) : global.appRoot
- this.altInstallPath = global.ConfigPath
+ this.mainInstallDir = process.pkg ? path.dirname(process.execPath) : global.appRoot
+ this.altInstallDir = global.ConfigPath
this.initialized = false
- this.exec = exec
}
async init() {
// Optional skip binaries check
if (process.env.SKIP_BINARIES_CHECK === '1') {
+ for (const binary of this.requiredBinaries) {
+ if (!process.env[binary.envVariable]) {
+ await Logger.fatal(`[BinaryManager] Environment variable ${binary.envVariable} must be set`)
+ process.exit(1)
+ }
+ }
Logger.info('[BinaryManager] Skipping check for binaries')
return
}
@@ -44,36 +302,30 @@ class BinaryManager {
this.initialized = true
}
- /**
- * Remove old/invalid binaries in main or alt install path
- *
- * @param {string[]} binaryNames
- */
- async removeOldBinaries(binaryNames) {
- for (const binaryName of binaryNames) {
- const executable = this.getExecutableFileName(binaryName)
- const mainInstallPath = path.join(this.mainInstallPath, executable)
- if (await fs.pathExists(mainInstallPath)) {
- Logger.debug(`[BinaryManager] Removing old binary: ${mainInstallPath}`)
- await fs.remove(mainInstallPath)
- }
- const altInstallPath = path.join(this.altInstallPath, executable)
- if (await fs.pathExists(altInstallPath)) {
- Logger.debug(`[BinaryManager] Removing old binary: ${altInstallPath}`)
- await fs.remove(altInstallPath)
- }
+ async removeBinary(destination, binary) {
+ const binaryPath = path.join(destination, binary.fileName)
+ if (await fs.pathExists(binaryPath)) {
+ Logger.debug(`[BinaryManager] Removing binary: ${binaryPath}`)
+ await fs.remove(binaryPath)
+ }
+ }
+
+ async removeOldBinaries(binaries) {
+ for (const binary of binaries) {
+ await this.removeBinary(this.mainInstallDir, binary)
+ await this.removeBinary(this.altInstallDir, binary)
}
}
/**
* Find required binaries and return array of binary names that are missing
- *
+ *
* @returns {Promise}
*/
async findRequiredBinaries() {
const missingBinaries = []
for (const binary of this.requiredBinaries) {
- const binaryPath = await this.findBinary(binary.name, binary.envVariable, binary.validVersions)
+ const binaryPath = await binary.find(this.mainInstallDir, this.altInstallDir)
if (binaryPath) {
Logger.info(`[BinaryManager] Found valid binary ${binary.name} at ${binaryPath}`)
if (process.env[binary.envVariable] !== binaryPath) {
@@ -82,79 +334,22 @@ class BinaryManager {
}
} else {
Logger.info(`[BinaryManager] ${binary.name} not found or version too old`)
- missingBinaries.push(binary.name)
+ missingBinaries.push(binary)
}
}
return missingBinaries
}
- /**
- * Find absolute path for binary
- *
- * @param {string} name
- * @param {string} envVariable
- * @param {string[]} [validVersions]
- * @returns {Promise} Path to binary
- */
- async findBinary(name, envVariable, validVersions = []) {
- const executable = this.getExecutableFileName(name)
- // 1. check path specified in environment variable
- const defaultPath = process.env[envVariable]
- if (await this.isBinaryGood(defaultPath, validVersions)) return defaultPath
- // 2. find the first instance of the binary in the PATH environment variable
- const whichPath = which.sync(executable, { nothrow: true })
- if (await this.isBinaryGood(whichPath, validVersions)) return whichPath
- // 3. check main install path (binary root dir)
- const mainInstallPath = path.join(this.mainInstallPath, executable)
- if (await this.isBinaryGood(mainInstallPath, validVersions)) return mainInstallPath
- // 4. check alt install path (/config)
- const altInstallPath = path.join(this.altInstallPath, executable)
- if (await this.isBinaryGood(altInstallPath, validVersions)) return altInstallPath
- return null
- }
-
- /**
- * Check binary path exists and optionally check version is valid
- *
- * @param {string} binaryPath
- * @param {string[]} [validVersions]
- * @returns {Promise}
- */
- async isBinaryGood(binaryPath, validVersions = []) {
- if (!binaryPath || !await fs.pathExists(binaryPath)) return false
- if (!validVersions.length) return true
- try {
- const { stdout } = await this.exec('"' + binaryPath + '"' + ' -version')
- const version = stdout.match(/version\s([\d\.]+)/)?.[1]
- if (!version) return false
- return validVersions.some(validVersion => version.startsWith(validVersion))
- } catch (err) {
- Logger.error(`[BinaryManager] Failed to check version of ${binaryPath}`)
- return false
- }
- }
-
- /**
- *
- * @param {string[]} binaries
- */
async install(binaries) {
if (!binaries.length) return
- Logger.info(`[BinaryManager] Installing binaries: ${binaries.join(', ')}`)
- let destination = await fileUtils.isWritable(this.mainInstallPath) ? this.mainInstallPath : this.altInstallPath
- await ffbinaries.downloadBinaries(binaries, { destination, version: '5.1', force: true })
+ Logger.info(`[BinaryManager] Installing binaries: ${binaries.map((binary) => binary.name).join(', ')}`)
+ let destination = (await fileUtils.isWritable(this.mainInstallDir)) ? this.mainInstallDir : this.altInstallDir
+ for (const binary of binaries) {
+ await binary.download(destination)
+ }
Logger.info(`[BinaryManager] Binaries installed to ${destination}`)
}
-
- /**
- * Append .exe to binary name for Windows
- *
- * @param {string} name
- * @returns {string}
- */
- getExecutableFileName(name) {
- return name + (process.platform == 'win32' ? '.exe' : '')
- }
}
-module.exports = BinaryManager
\ No newline at end of file
+module.exports = BinaryManager
+module.exports.Binary = Binary // for testing
diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js
index 73a0432448..cafd6ff451 100644
--- a/server/managers/PlaybackSessionManager.js
+++ b/server/managers/PlaybackSessionManager.js
@@ -39,7 +39,7 @@ class PlaybackSessionManager {
/**
*
- * @param {import('express').Request} req
+ * @param {import('../controllers/SessionController').RequestWithUser} req
* @param {Object} [clientDeviceInfo]
* @returns {Promise}
*/
@@ -67,18 +67,25 @@ class PlaybackSessionManager {
/**
*
- * @param {import('express').Request} req
+ * @param {import('../controllers/SessionController').RequestWithUser} req
* @param {import('express').Response} res
* @param {string} [episodeId]
*/
async startSessionRequest(req, res, episodeId) {
const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo)
Logger.debug(`[PlaybackSessionManager] startSessionRequest for device ${deviceInfo.deviceDescription}`)
- const { user, libraryItem, body: options } = req
- const session = await this.startSession(user, deviceInfo, libraryItem, episodeId, options)
+ const { libraryItem, body: options } = req
+ const session = await this.startSession(req.user, deviceInfo, libraryItem, episodeId, options)
res.json(session.toJSONForClient(libraryItem))
}
+ /**
+ *
+ * @param {import('../models/User')} user
+ * @param {*} session
+ * @param {*} payload
+ * @param {import('express').Response} res
+ */
async syncSessionRequest(user, session, payload, res) {
if (await this.syncSession(user, session, payload)) {
res.sendStatus(200)
@@ -104,6 +111,13 @@ class PlaybackSessionManager {
})
}
+ /**
+ *
+ * @param {import('../models/User')} user
+ * @param {*} sessionJson
+ * @param {*} deviceInfo
+ * @returns
+ */
async syncLocalSession(user, sessionJson, deviceInfo) {
const libraryItem = await Database.libraryItemModel.getOldById(sessionJson.libraryItemId)
const episode = sessionJson.episodeId && libraryItem && libraryItem.isPodcast ? libraryItem.media.getEpisode(sessionJson.episodeId) : null
@@ -174,41 +188,58 @@ class PlaybackSessionManager {
progressSynced: false
}
- const userProgressForItem = user.getMediaProgress(session.libraryItemId, session.episodeId)
+ const mediaItemId = session.episodeId || libraryItem.media.id
+ let userProgressForItem = user.getMediaProgress(mediaItemId)
if (userProgressForItem) {
- if (userProgressForItem.lastUpdate > session.updatedAt) {
+ if (userProgressForItem.updatedAt.valueOf() > session.updatedAt) {
Logger.debug(`[PlaybackSessionManager] Not updating progress for "${session.displayTitle}" because it has been updated more recently`)
} else {
Logger.debug(`[PlaybackSessionManager] Updating progress for "${session.displayTitle}" with current time ${session.currentTime} (previously ${userProgressForItem.currentTime})`)
- result.progressSynced = user.createUpdateMediaProgress(libraryItem, session.mediaProgressObject, session.episodeId)
+ const updateResponse = await user.createUpdateMediaProgressFromPayload({
+ libraryItemId: libraryItem.id,
+ episodeId: session.episodeId,
+ ...session.mediaProgressObject
+ })
+ result.progressSynced = !!updateResponse.mediaProgress
+ if (result.progressSynced) {
+ userProgressForItem = updateResponse.mediaProgress
+ }
}
} else {
Logger.debug(`[PlaybackSessionManager] Creating new media progress for media item "${session.displayTitle}"`)
- result.progressSynced = user.createUpdateMediaProgress(libraryItem, session.mediaProgressObject, session.episodeId)
+ const updateResponse = await user.createUpdateMediaProgressFromPayload({
+ libraryItemId: libraryItem.id,
+ episodeId: session.episodeId,
+ ...session.mediaProgressObject
+ })
+ result.progressSynced = !!updateResponse.mediaProgress
+ if (result.progressSynced) {
+ userProgressForItem = updateResponse.mediaProgress
+ }
}
// Update user and emit socket event
if (result.progressSynced) {
- const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId)
- if (itemProgress) {
- await Database.upsertMediaProgress(itemProgress)
- SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {
- id: itemProgress.id,
- sessionId: session.id,
- deviceDescription: session.deviceDescription,
- data: itemProgress.toJSON()
- })
- }
+ SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {
+ id: userProgressForItem.id,
+ sessionId: session.id,
+ deviceDescription: session.deviceDescription,
+ data: userProgressForItem.getOldMediaProgress()
+ })
}
return result
}
+ /**
+ *
+ * @param {import('../controllers/SessionController').RequestWithUser} req
+ * @param {*} res
+ */
async syncLocalSessionRequest(req, res) {
const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo)
- const user = req.user
const sessionJson = req.body
- const result = await this.syncLocalSession(user, sessionJson, deviceInfo)
+ const result = await this.syncLocalSession(req.user, sessionJson, deviceInfo)
if (result.error) {
res.status(500).send(result.error)
} else {
@@ -216,6 +247,13 @@ class PlaybackSessionManager {
}
}
+ /**
+ *
+ * @param {import('../models/User')} user
+ * @param {*} session
+ * @param {*} syncData
+ * @param {import('express').Response} res
+ */
async closeSessionRequest(user, session, syncData, res) {
await this.closeSession(user, session, syncData)
res.sendStatus(200)
@@ -223,7 +261,7 @@ class PlaybackSessionManager {
/**
*
- * @param {import('../objects/user/User')} user
+ * @param {import('../models/User')} user
* @param {DeviceInfo} deviceInfo
* @param {import('../objects/LibraryItem')} libraryItem
* @param {string|null} episodeId
@@ -241,7 +279,8 @@ class PlaybackSessionManager {
const shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options, episodeId))
const mediaPlayer = options.mediaPlayer || 'unknown'
- const userProgress = libraryItem.isMusic ? null : user.getMediaProgress(libraryItem.id, episodeId)
+ const mediaItemId = episodeId || libraryItem.media.id
+ const userProgress = user.getMediaProgress(mediaItemId)
let userStartTime = 0
if (userProgress) {
if (userProgress.isFinished) {
@@ -292,6 +331,13 @@ class PlaybackSessionManager {
return newPlaybackSession
}
+ /**
+ *
+ * @param {import('../models/User')} user
+ * @param {*} session
+ * @param {*} syncData
+ * @returns
+ */
async syncSession(user, session, syncData) {
const libraryItem = await Database.libraryItemModel.getOldById(session.libraryItemId)
if (!libraryItem) {
@@ -303,20 +349,19 @@ class PlaybackSessionManager {
session.addListeningTime(syncData.timeListened)
Logger.debug(`[PlaybackSessionManager] syncSession "${session.id}" (Device: ${session.deviceDescription}) | Total Time Listened: ${session.timeListening}`)
- const itemProgressUpdate = {
+ const updateResponse = await user.createUpdateMediaProgressFromPayload({
+ libraryItemId: libraryItem.id,
+ episodeId: session.episodeId,
duration: syncData.duration,
currentTime: syncData.currentTime,
progress: session.progress
- }
- const wasUpdated = user.createUpdateMediaProgress(libraryItem, itemProgressUpdate, session.episodeId)
- if (wasUpdated) {
- const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId)
- if (itemProgress) await Database.upsertMediaProgress(itemProgress)
+ })
+ if (updateResponse.mediaProgress) {
SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {
- id: itemProgress.id,
+ id: updateResponse.mediaProgress.id,
sessionId: session.id,
deviceDescription: session.deviceDescription,
- data: itemProgress.toJSON()
+ data: updateResponse.mediaProgress.getOldMediaProgress()
})
}
this.saveSession(session)
@@ -325,6 +370,13 @@ class PlaybackSessionManager {
}
}
+ /**
+ *
+ * @param {import('../models/User')} user
+ * @param {*} session
+ * @param {*} syncData
+ * @returns
+ */
async closeSession(user, session, syncData = null) {
if (syncData) {
await this.syncSession(user, session, syncData)
diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js
index d8db6492ed..adec59871c 100644
--- a/server/managers/PodcastManager.js
+++ b/server/managers/PodcastManager.js
@@ -5,7 +5,7 @@ const Database = require('../Database')
const fs = require('../libs/fsExtra')
const { getPodcastFeed } = require('../utils/podcastUtils')
-const { removeFile, downloadFile } = require('../utils/fileUtils')
+const { removeFile, downloadFile, sanitizeFilename, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
const { levenshteinDistance } = require('../utils/index')
const opmlParser = require('../utils/parsers/parseOPML')
const opmlGenerator = require('../utils/generators/opmlGenerator')
@@ -13,11 +13,13 @@ const prober = require('../utils/prober')
const ffmpegHelpers = require('../utils/ffmpegHelpers')
const TaskManager = require('./TaskManager')
+const CoverManager = require('../managers/CoverManager')
const LibraryFile = require('../objects/files/LibraryFile')
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
const PodcastEpisode = require('../objects/entities/PodcastEpisode')
const AudioFile = require('../objects/files/AudioFile')
+const LibraryItem = require('../objects/LibraryItem')
class PodcastManager {
constructor(watcher, notificationManager) {
@@ -350,19 +352,23 @@ class PodcastManager {
return matches.sort((a, b) => a.levenshtein - b.levenshtein)
}
+ getParsedOPMLFileFeeds(opmlText) {
+ return opmlParser.parse(opmlText)
+ }
+
async getOPMLFeeds(opmlText) {
- var extractedFeeds = opmlParser.parse(opmlText)
- if (!extractedFeeds || !extractedFeeds.length) {
+ const extractedFeeds = opmlParser.parse(opmlText)
+ if (!extractedFeeds?.length) {
Logger.error('[PodcastManager] getOPMLFeeds: No RSS feeds found in OPML')
return {
error: 'No RSS feeds found in OPML'
}
}
- var rssFeedData = []
+ const rssFeedData = []
for (let feed of extractedFeeds) {
- var feedData = await getPodcastFeed(feed.feedUrl, true)
+ const feedData = await getPodcastFeed(feed.feedUrl, true)
if (feedData) {
feedData.metadata.feedUrl = feed.feedUrl
rssFeedData.push(feedData)
@@ -392,5 +398,115 @@ class PodcastManager {
queue: this.downloadQueue.filter((item) => !libraryId || item.libraryId === libraryId).map((item) => item.toJSONForClient())
}
}
+
+ /**
+ *
+ * @param {string[]} rssFeedUrls
+ * @param {import('../models/LibraryFolder')} folder
+ * @param {boolean} autoDownloadEpisodes
+ * @param {import('../managers/CronManager')} cronManager
+ */
+ async createPodcastsFromFeedUrls(rssFeedUrls, folder, autoDownloadEpisodes, cronManager) {
+ const task = TaskManager.createAndAddTask('opml-import', 'OPML import', `Creating podcasts from ${rssFeedUrls.length} RSS feeds`, true, null)
+ let numPodcastsAdded = 0
+ Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Importing ${rssFeedUrls.length} RSS feeds to folder "${folder.path}"`)
+ for (const feedUrl of rssFeedUrls) {
+ const feed = await getPodcastFeed(feedUrl).catch(() => null)
+ if (!feed?.episodes) {
+ TaskManager.createAndEmitFailedTask('opml-import-feed', 'OPML import feed', `Importing RSS feed "${feedUrl}"`, 'Failed to get podcast feed')
+ Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to get podcast feed for "${feedUrl}"`)
+ continue
+ }
+
+ const podcastFilename = sanitizeFilename(feed.metadata.title)
+ const podcastPath = filePathToPOSIX(`${folder.path}/${podcastFilename}`)
+ // Check if a library item with this podcast folder exists already
+ const existingLibraryItem =
+ (await Database.libraryItemModel.count({
+ where: {
+ path: podcastPath
+ }
+ })) > 0
+ if (existingLibraryItem) {
+ Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Podcast already exists at path "${podcastPath}"`)
+ TaskManager.createAndEmitFailedTask('opml-import-feed', 'OPML import feed', `Creating podcast "${feed.metadata.title}"`, 'Podcast already exists at path')
+ continue
+ }
+
+ const successCreatingPath = await fs
+ .ensureDir(podcastPath)
+ .then(() => true)
+ .catch((error) => {
+ Logger.error(`[PodcastManager] Failed to ensure podcast dir "${podcastPath}"`, error)
+ return false
+ })
+ if (!successCreatingPath) {
+ Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to create podcast folder at "${podcastPath}"`)
+ TaskManager.createAndEmitFailedTask('opml-import-feed', 'OPML import feed', `Creating podcast "${feed.metadata.title}"`, 'Failed to create podcast folder')
+ continue
+ }
+
+ const newPodcastMetadata = {
+ title: feed.metadata.title,
+ author: feed.metadata.author,
+ description: feed.metadata.description,
+ releaseDate: '',
+ genres: [...feed.metadata.categories],
+ feedUrl: feed.metadata.feedUrl,
+ imageUrl: feed.metadata.image,
+ itunesPageUrl: '',
+ itunesId: '',
+ itunesArtistId: '',
+ language: '',
+ numEpisodes: feed.numEpisodes
+ }
+
+ const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
+ const libraryItemPayload = {
+ path: podcastPath,
+ relPath: podcastFilename,
+ folderId: folder.id,
+ libraryId: folder.libraryId,
+ ino: libraryItemFolderStats.ino,
+ mtimeMs: libraryItemFolderStats.mtimeMs || 0,
+ ctimeMs: libraryItemFolderStats.ctimeMs || 0,
+ birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
+ media: {
+ metadata: newPodcastMetadata,
+ autoDownloadEpisodes
+ }
+ }
+
+ const libraryItem = new LibraryItem()
+ libraryItem.setData('podcast', libraryItemPayload)
+
+ // Download and save cover image
+ if (newPodcastMetadata.imageUrl) {
+ // TODO: Scan cover image to library files
+ // Podcast cover will always go into library item folder
+ const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, newPodcastMetadata.imageUrl, true)
+ if (coverResponse) {
+ if (coverResponse.error) {
+ Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Download cover error from "${newPodcastMetadata.imageUrl}": ${coverResponse.error}`)
+ } else if (coverResponse.cover) {
+ libraryItem.media.coverPath = coverResponse.cover
+ }
+ }
+ }
+
+ await Database.createLibraryItem(libraryItem)
+ SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
+
+ // Turn on podcast auto download cron if not already on
+ if (libraryItem.media.autoDownloadEpisodes) {
+ cronManager.checkUpdatePodcastCron(libraryItem)
+ }
+
+ numPodcastsAdded++
+ }
+ task.setFinished(`Added ${numPodcastsAdded} podcasts`)
+ TaskManager.taskFinished(task)
+ Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Finished OPML import. Created ${numPodcastsAdded} podcasts out of ${rssFeedUrls.length} RSS feed URLs`)
+ }
}
module.exports = PodcastManager
diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js
index 3149689d91..35ce4e1f83 100644
--- a/server/managers/RssFeedManager.js
+++ b/server/managers/RssFeedManager.js
@@ -9,7 +9,7 @@ const Feed = require('../objects/Feed')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
class RssFeedManager {
- constructor() { }
+ constructor() {}
async validateFeedEntity(feedObj) {
if (feedObj.entityType === 'collection') {
@@ -44,7 +44,7 @@ class RssFeedManager {
const feeds = await Database.feedModel.getOldFeeds()
for (const feed of feeds) {
// Remove invalid feeds
- if (!await this.validateFeedEntity(feed)) {
+ if (!(await this.validateFeedEntity(feed))) {
await Database.removeFeed(feed.id)
}
}
@@ -138,7 +138,7 @@ class RssFeedManager {
const seriesJson = series.toJSON()
// Get books in series that have audio tracks
- seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter(li => li.media.numTracks)
+ seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
// Find most recently updated item in series
let mostRecentlyUpdatedAt = seriesJson.updatedAt
@@ -202,7 +202,14 @@ class RssFeedManager {
readStream.pipe(res)
}
- async openFeedForItem(user, libraryItem, options) {
+ /**
+ *
+ * @param {string} userId
+ * @param {*} libraryItem
+ * @param {*} options
+ * @returns
+ */
+ async openFeedForItem(userId, libraryItem, options) {
const serverAddress = options.serverAddress
const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
@@ -210,7 +217,7 @@ class RssFeedManager {
const ownerEmail = options.metadataDetails?.ownerEmail
const feed = new Feed()
- feed.setFromItem(user.id, slug, libraryItem, serverAddress, preventIndexing, ownerName, ownerEmail)
+ feed.setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing, ownerName, ownerEmail)
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
await Database.createFeed(feed)
@@ -218,7 +225,14 @@ class RssFeedManager {
return feed
}
- async openFeedForCollection(user, collectionExpanded, options) {
+ /**
+ *
+ * @param {string} userId
+ * @param {*} collectionExpanded
+ * @param {*} options
+ * @returns
+ */
+ async openFeedForCollection(userId, collectionExpanded, options) {
const serverAddress = options.serverAddress
const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
@@ -226,7 +240,7 @@ class RssFeedManager {
const ownerEmail = options.metadataDetails?.ownerEmail
const feed = new Feed()
- feed.setFromCollection(user.id, slug, collectionExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
+ feed.setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
await Database.createFeed(feed)
@@ -234,7 +248,14 @@ class RssFeedManager {
return feed
}
- async openFeedForSeries(user, seriesExpanded, options) {
+ /**
+ *
+ * @param {string} userId
+ * @param {*} seriesExpanded
+ * @param {*} options
+ * @returns
+ */
+ async openFeedForSeries(userId, seriesExpanded, options) {
const serverAddress = options.serverAddress
const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
@@ -242,7 +263,7 @@ class RssFeedManager {
const ownerEmail = options.metadataDetails?.ownerEmail
const feed = new Feed()
- feed.setFromSeries(user.id, slug, seriesExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
+ feed.setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
await Database.createFeed(feed)
diff --git a/server/managers/ShareManager.js b/server/managers/ShareManager.js
index 4e5a96cbaf..07c57a45a2 100644
--- a/server/managers/ShareManager.js
+++ b/server/managers/ShareManager.js
@@ -1,12 +1,14 @@
const Database = require('../Database')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
+const LongTimeout = require('../utils/longTimeout')
+const { elapsedPretty } = require('../utils/index')
/**
* @typedef OpenMediaItemShareObject
* @property {string} id
* @property {import('../models/MediaItemShare').MediaItemShareObject} mediaItemShare
- * @property {NodeJS.Timeout} timeout
+ * @property {LongTimeout} timeout
*/
class ShareManager {
@@ -118,13 +120,13 @@ class ShareManager {
this.destroyMediaItemShare(mediaItemShare.id)
return
}
-
- const timeout = setTimeout(() => {
+ const timeout = new LongTimeout()
+ timeout.set(() => {
Logger.info(`[ShareManager] Removing expired media item share "${mediaItemShare.id}"`)
this.removeMediaItemShare(mediaItemShare.id)
}, expiresAtDuration)
this.openMediaItemShares.push({ id: mediaItemShare.id, mediaItemShare: mediaItemShare.toJSON(), timeout })
- Logger.info(`[ShareManager] Scheduled media item share "${mediaItemShare.id}" to expire in ${expiresAtDuration}ms`)
+ Logger.info(`[ShareManager] Scheduled media item share "${mediaItemShare.id}" to expire in ${elapsedPretty(expiresAtDuration / 1000)}`)
}
/**
@@ -149,7 +151,7 @@ class ShareManager {
if (!mediaItemShare) return
if (mediaItemShare.timeout) {
- clearTimeout(mediaItemShare.timeout)
+ mediaItemShare.timeout.clear()
}
this.openMediaItemShares = this.openMediaItemShares.filter((s) => s.id !== mediaItemShareId)
diff --git a/server/managers/TaskManager.js b/server/managers/TaskManager.js
index 31cf06a13f..1a8b6c85b0 100644
--- a/server/managers/TaskManager.js
+++ b/server/managers/TaskManager.js
@@ -9,8 +9,8 @@ class TaskManager {
/**
* Add task and emit socket task_started event
- *
- * @param {Task} task
+ *
+ * @param {Task} task
*/
addTask(task) {
this.tasks.push(task)
@@ -19,24 +19,24 @@ class TaskManager {
/**
* Remove task and emit task_finished event
- *
- * @param {Task} task
+ *
+ * @param {Task} task
*/
taskFinished(task) {
- if (this.tasks.some(t => t.id === task.id)) {
- this.tasks = this.tasks.filter(t => t.id !== task.id)
+ if (this.tasks.some((t) => t.id === task.id)) {
+ this.tasks = this.tasks.filter((t) => t.id !== task.id)
SocketAuthority.emitter('task_finished', task.toJSON())
}
}
/**
* Create new task and add
- *
- * @param {string} action
- * @param {string} title
- * @param {string} description
- * @param {boolean} showSuccess
- * @param {Object} [data]
+ *
+ * @param {string} action
+ * @param {string} title
+ * @param {string} description
+ * @param {boolean} showSuccess
+ * @param {Object} [data]
*/
createAndAddTask(action, title, description, showSuccess, data = {}) {
const task = new Task()
@@ -44,5 +44,21 @@ class TaskManager {
this.addTask(task)
return task
}
+
+ /**
+ * Create new failed task and add
+ *
+ * @param {string} action
+ * @param {string} title
+ * @param {string} description
+ * @param {string} errorMessage
+ */
+ createAndEmitFailedTask(action, title, description, errorMessage) {
+ const task = new Task()
+ task.setData(action, title, description, false)
+ task.setFailed(errorMessage)
+ SocketAuthority.emitter('task_started', task.toJSON())
+ return task
+ }
}
-module.exports = new TaskManager()
\ No newline at end of file
+module.exports = new TaskManager()
diff --git a/server/models/Collection.js b/server/models/Collection.js
index 5fa0310d93..dcc86e5a56 100644
--- a/server/models/Collection.js
+++ b/server/models/Collection.js
@@ -22,7 +22,8 @@ class Collection extends Model {
/**
* Get all old collections toJSONExpanded, items filtered for user permissions
- * @param {oldUser} [user]
+ *
+ * @param {import('./User')} user
* @param {string} [libraryId]
* @param {string[]} [include]
* @returns {Promise} oldCollection.toJSONExpanded
@@ -116,7 +117,8 @@ class Collection extends Model {
/**
* Get old collection toJSONExpanded, items filtered for user permissions
- * @param {oldUser} [user]
+ *
+ * @param {import('./User')|null} user
* @param {string[]} [include]
* @returns {Promise} oldCollection.toJSONExpanded
*/
diff --git a/server/models/Library.js b/server/models/Library.js
index 103d14b684..617063505b 100644
--- a/server/models/Library.js
+++ b/server/models/Library.js
@@ -60,7 +60,7 @@ class Library extends Model {
/**
* Convert expanded Library to oldLibrary
* @param {Library} libraryExpanded
- * @returns {Promise}
+ * @returns {oldLibrary}
*/
static getOldLibrary(libraryExpanded) {
const folders = libraryExpanded.libraryFolders.map((folder) => {
diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js
index 1b56a23f8e..847ff65073 100644
--- a/server/models/LibraryItem.js
+++ b/server/models/LibraryItem.js
@@ -427,6 +427,12 @@ class LibraryItem extends Model {
}
}
+ /**
+ * Remove library item by id
+ *
+ * @param {string} libraryItemId
+ * @returns {Promise} The number of destroyed rows
+ */
static removeById(libraryItemId) {
return this.destroy({
where: {
@@ -537,7 +543,7 @@ class LibraryItem extends Model {
/**
* Get library items using filter and sort
* @param {oldLibrary} library
- * @param {oldUser} user
+ * @param {import('./User')} user
* @param {object} options
* @returns {{ libraryItems:oldLibraryItem[], count:number }}
*/
@@ -580,7 +586,7 @@ class LibraryItem extends Model {
/**
* Get home page data personalized shelves
* @param {oldLibrary} library
- * @param {oldUser} user
+ * @param {import('./User')} user
* @param {string[]} include
* @param {number} limit
* @returns {object[]} array of shelf objects
@@ -753,7 +759,7 @@ class LibraryItem extends Model {
/**
* Get book library items for author, optional use user permissions
* @param {oldAuthor} author
- * @param {[oldUser]} user
+ * @param {import('./User')} user
* @returns {Promise}
*/
static async getForAuthor(author, user = null) {
diff --git a/server/models/MediaProgress.js b/server/models/MediaProgress.js
index 5c571c7392..196353d8eb 100644
--- a/server/models/MediaProgress.js
+++ b/server/models/MediaProgress.js
@@ -34,29 +34,6 @@ class MediaProgress extends Model {
this.createdAt
}
- getOldMediaProgress() {
- const isPodcastEpisode = this.mediaItemType === 'podcastEpisode'
-
- return {
- id: this.id,
- userId: this.userId,
- libraryItemId: this.extraData?.libraryItemId || null,
- episodeId: isPodcastEpisode ? this.mediaItemId : null,
- mediaItemId: this.mediaItemId,
- mediaItemType: this.mediaItemType,
- duration: this.duration,
- progress: this.extraData?.progress || 0,
- currentTime: this.currentTime,
- isFinished: !!this.isFinished,
- hideFromContinueListening: !!this.hideFromContinueListening,
- ebookLocation: this.ebookLocation,
- ebookProgress: this.ebookProgress,
- lastUpdate: this.updatedAt.valueOf(),
- startedAt: this.createdAt.valueOf(),
- finishedAt: this.finishedAt?.valueOf() || null
- }
- }
-
static upsertFromOld(oldMediaProgress) {
const mediaProgress = this.getFromOld(oldMediaProgress)
return this.upsert(mediaProgress)
@@ -182,6 +159,84 @@ class MediaProgress extends Model {
})
MediaProgress.belongsTo(user)
}
+
+ getOldMediaProgress() {
+ const isPodcastEpisode = this.mediaItemType === 'podcastEpisode'
+
+ return {
+ id: this.id,
+ userId: this.userId,
+ libraryItemId: this.extraData?.libraryItemId || null,
+ episodeId: isPodcastEpisode ? this.mediaItemId : null,
+ mediaItemId: this.mediaItemId,
+ mediaItemType: this.mediaItemType,
+ duration: this.duration,
+ progress: this.extraData?.progress || 0,
+ currentTime: this.currentTime,
+ isFinished: !!this.isFinished,
+ hideFromContinueListening: !!this.hideFromContinueListening,
+ ebookLocation: this.ebookLocation,
+ ebookProgress: this.ebookProgress,
+ lastUpdate: this.updatedAt.valueOf(),
+ startedAt: this.createdAt.valueOf(),
+ finishedAt: this.finishedAt?.valueOf() || null
+ }
+ }
+
+ /**
+ * Apply update to media progress
+ *
+ * @param {Object} progress
+ * @returns {Promise}
+ */
+ applyProgressUpdate(progressPayload) {
+ if (!this.extraData) this.extraData = {}
+ if (progressPayload.isFinished !== undefined) {
+ if (progressPayload.isFinished && !this.isFinished) {
+ this.finishedAt = Date.now()
+ this.extraData.progress = 1
+ this.changed('extraData', true)
+ delete progressPayload.finishedAt
+ } else if (!progressPayload.isFinished && this.isFinished) {
+ this.finishedAt = null
+ this.extraData.progress = 0
+ this.currentTime = 0
+ this.changed('extraData', true)
+ delete progressPayload.finishedAt
+ delete progressPayload.currentTime
+ }
+ } else if (!isNaN(progressPayload.progress) && progressPayload.progress !== this.progress) {
+ // Old model stored progress on object
+ this.extraData.progress = Math.min(1, Math.max(0, progressPayload.progress))
+ this.changed('extraData', true)
+ }
+
+ this.set(progressPayload)
+
+ // Reset hideFromContinueListening if the progress has changed
+ if (this.changed('currentTime') && !progressPayload.hideFromContinueListening) {
+ this.hideFromContinueListening = false
+ }
+
+ const timeRemaining = this.duration - this.currentTime
+ // Set to finished if time remaining is less than 5 seconds
+ if (!this.isFinished && this.duration && timeRemaining < 5) {
+ this.isFinished = true
+ this.finishedAt = this.finishedAt || Date.now()
+ this.extraData.progress = 1
+ this.changed('extraData', true)
+ } else if (this.isFinished && this.changed('currentTime') && this.currentTime < this.duration) {
+ this.isFinished = false
+ this.finishedAt = null
+ }
+
+ // For local sync
+ if (progressPayload.lastUpdate) {
+ this.updatedAt = progressPayload.lastUpdate
+ }
+
+ return this.save()
+ }
}
module.exports = MediaProgress
diff --git a/server/models/User.js b/server/models/User.js
index a714ca0f87..04f04e2b82 100644
--- a/server/models/User.js
+++ b/server/models/User.js
@@ -3,8 +3,18 @@ const sequelize = require('sequelize')
const Logger = require('../Logger')
const oldUser = require('../objects/user/User')
const SocketAuthority = require('../SocketAuthority')
+const { isNullOrNaN } = require('../utils')
+
const { DataTypes, Model } = sequelize
+/**
+ * @typedef AudioBookmarkObject
+ * @property {string} libraryItemId
+ * @property {string} title
+ * @property {number} time
+ * @property {number} createdAt
+ */
+
class User extends Model {
constructor(values, options) {
super(values, options)
@@ -19,6 +29,8 @@ class User extends Model {
this.pash
/** @type {string} */
this.type
+ /** @type {string} */
+ this.token
/** @type {boolean} */
this.isActive
/** @type {boolean} */
@@ -27,7 +39,7 @@ class User extends Model {
this.lastSeen
/** @type {Object} */
this.permissions
- /** @type {Object} */
+ /** @type {AudioBookmarkObject[]} */
this.bookmarks
/** @type {Object} */
this.extraData
@@ -35,34 +47,87 @@ class User extends Model {
this.createdAt
/** @type {Date} */
this.updatedAt
+ /** @type {import('./MediaProgress')[]?} - Only included when extended */
+ this.mediaProgresses
}
/**
- * Get all oldUsers
- * @returns {Promise}
+ * List of expected permission properties from the client
+ * Only used for OpenID
*/
- static async getOldUsers() {
- const users = await this.findAll({
- include: this.sequelize.models.mediaProgress
- })
- return users.map((u) => this.getOldUser(u))
+ static permissionMapping = {
+ canDownload: 'download',
+ canUpload: 'upload',
+ canDelete: 'delete',
+ canUpdate: 'update',
+ canAccessExplicitContent: 'accessExplicitContent',
+ canAccessAllLibraries: 'accessAllLibraries',
+ canAccessAllTags: 'accessAllTags',
+ tagsAreDenylist: 'selectedTagsNotAccessible',
+ // Direct mapping for array-based permissions
+ allowedLibraries: 'librariesAccessible',
+ allowedTags: 'itemTagsSelected'
+ }
+
+ /**
+ * Get a sample to show how a JSON for updatePermissionsFromExternalJSON should look like
+ * Only used for OpenID
+ *
+ * @returns {string} JSON string
+ */
+ static getSampleAbsPermissions() {
+ // Start with a template object where all permissions are false for simplicity
+ const samplePermissions = Object.keys(User.permissionMapping).reduce((acc, key) => {
+ // For array-based permissions, provide a sample array
+ if (key === 'allowedLibraries') {
+ acc[key] = [`5406ba8a-16e1-451d-96d7-4931b0a0d966`, `918fd848-7c1d-4a02-818a-847435a879ca`]
+ } else if (key === 'allowedTags') {
+ acc[key] = [`ExampleTag`, `AnotherTag`, `ThirdTag`]
+ } else {
+ acc[key] = false
+ }
+ return acc
+ }, {})
+
+ return JSON.stringify(samplePermissions, null, 2) // Pretty print the JSON
+ }
+
+ /**
+ *
+ * @param {string} type
+ * @returns
+ */
+ static getDefaultPermissionsForUserType(type) {
+ return {
+ download: true,
+ update: type === 'root' || type === 'admin',
+ delete: type === 'root',
+ upload: type === 'root' || type === 'admin',
+ accessAllLibraries: true,
+ accessAllTags: true,
+ accessExplicitContent: true,
+ librariesAccessible: [],
+ itemTagsSelected: []
+ }
}
/**
* Get old user model from new
*
- * @param {Object} userExpanded
+ * @param {User} userExpanded
* @returns {oldUser}
*/
static getOldUser(userExpanded) {
const mediaProgress = userExpanded.mediaProgresses.map((mp) => mp.getOldMediaProgress())
- const librariesAccessible = userExpanded.permissions?.librariesAccessible || []
- const itemTagsSelected = userExpanded.permissions?.itemTagsSelected || []
- const permissions = userExpanded.permissions || {}
+ const librariesAccessible = [...(userExpanded.permissions?.librariesAccessible || [])]
+ const itemTagsSelected = [...(userExpanded.permissions?.itemTagsSelected || [])]
+ const permissions = { ...(userExpanded.permissions || {}) }
delete permissions.librariesAccessible
delete permissions.itemTagsSelected
+ const seriesHideFromContinueListening = userExpanded.extraData?.seriesHideFromContinueListening || []
+
return new oldUser({
id: userExpanded.id,
oldUserId: userExpanded.extraData?.oldUserId || null,
@@ -72,7 +137,7 @@ class User extends Model {
type: userExpanded.type,
token: userExpanded.token,
mediaProgress,
- seriesHideFromContinueListening: userExpanded.extraData?.seriesHideFromContinueListening || [],
+ seriesHideFromContinueListening: [...seriesHideFromContinueListening],
bookmarks: userExpanded.bookmarks,
isActive: userExpanded.isActive,
isLocked: userExpanded.isLocked,
@@ -152,44 +217,39 @@ class User extends Model {
}
}
- static removeById(userId) {
- return this.destroy({
- where: {
- id: userId
- }
- })
- }
-
/**
* Create root user
* @param {string} username
* @param {string} pash
- * @param {Auth} auth
- * @returns {Promise}
+ * @param {import('../Auth')} auth
+ * @returns {Promise}
*/
static async createRootUser(username, pash, auth) {
const userId = uuidv4()
const token = await auth.generateAccessToken({ id: userId, username })
- const newRoot = new oldUser({
+ const newUser = {
id: userId,
type: 'root',
username,
pash,
token,
isActive: true,
- createdAt: Date.now()
- })
- await this.createFromOld(newRoot)
- return newRoot
+ permissions: this.getDefaultPermissionsForUserType('root'),
+ bookmarks: [],
+ extraData: {
+ seriesHideFromContinueListening: []
+ }
+ }
+ return this.create(newUser)
}
/**
* Create user from openid userinfo
* @param {Object} userinfo
- * @param {Auth} auth
- * @returns {Promise}
+ * @param {import('../Auth')} auth
+ * @returns {Promise}
*/
static async createUserFromOpenIdUserInfo(userinfo, auth) {
const userId = uuidv4()
@@ -199,7 +259,7 @@ class User extends Model {
const token = await auth.generateAccessToken({ id: userId, username })
- const newUser = new oldUser({
+ const newUser = {
id: userId,
type: 'user',
username,
@@ -207,51 +267,30 @@ class User extends Model {
pash: null,
token,
isActive: true,
- authOpenIDSub: userinfo.sub,
- createdAt: Date.now()
- })
- if (await this.createFromOld(newUser)) {
- SocketAuthority.adminEmitter('user_added', newUser.toJSONForBrowser())
- return newUser
+ permissions: this.getDefaultPermissionsForUserType('user'),
+ bookmarks: [],
+ extraData: {
+ authOpenIDSub: userinfo.sub,
+ seriesHideFromContinueListening: []
+ }
}
- return null
- }
+ const user = await this.create(newUser)
- /**
- * Get a user by id or by the old database id
- * @temp User ids were updated in v2.3.0 migration and old API tokens may still use that id
- * @param {string} userId
- * @returns {Promise} null if not found
- */
- static async getUserByIdOrOldId(userId) {
- if (!userId) return null
- const user = await this.findOne({
- where: {
- [sequelize.Op.or]: [
- {
- id: userId
- },
- {
- extraData: {
- [sequelize.Op.substring]: userId
- }
- }
- ]
- },
- include: this.sequelize.models.mediaProgress
- })
- if (!user) return null
- return this.getOldUser(user)
+ if (user) {
+ SocketAuthority.adminEmitter('user_added', user.toOldJSONForBrowser())
+ return user
+ }
+ return null
}
/**
* Get user by username case insensitive
* @param {string} username
- * @returns {Promise} returns null if not found
+ * @returns {Promise}
*/
static async getUserByUsername(username) {
if (!username) return null
- const user = await this.findOne({
+ return this.findOne({
where: {
username: {
[sequelize.Op.like]: username
@@ -259,18 +298,16 @@ class User extends Model {
},
include: this.sequelize.models.mediaProgress
})
- if (!user) return null
- return this.getOldUser(user)
}
/**
* Get user by email case insensitive
- * @param {string} username
- * @returns {Promise} returns null if not found
+ * @param {string} email
+ * @returns {Promise}
*/
static async getUserByEmail(email) {
if (!email) return null
- const user = await this.findOne({
+ return this.findOne({
where: {
email: {
[sequelize.Op.like]: email
@@ -278,20 +315,45 @@ class User extends Model {
},
include: this.sequelize.models.mediaProgress
})
- if (!user) return null
- return this.getOldUser(user)
}
/**
* Get user by id
* @param {string} userId
- * @returns {Promise} returns null if not found
+ * @returns {Promise}
*/
static async getUserById(userId) {
if (!userId) return null
- const user = await this.findByPk(userId, {
+ return this.findByPk(userId, {
+ include: this.sequelize.models.mediaProgress
+ })
+ }
+
+ /**
+ * Get user by id or old id
+ * JWT tokens generated before 2.3.0 used old user ids
+ *
+ * @param {string} userId
+ * @returns {Promise}
+ */
+ static async getUserByIdOrOldId(userId) {
+ if (!userId) return null
+ return this.findOne({
+ where: {
+ [sequelize.Op.or]: [{ id: userId }, { 'extraData.oldUserId': userId }]
+ },
include: this.sequelize.models.mediaProgress
})
+ }
+
+ /**
+ * @deprecated
+ * Get old user by id
+ * @param {string} userId
+ * @returns {Promise} returns null if not found
+ */
+ static async getOldUserById(userId) {
+ const user = await this.getUserById(userId)
if (!user) return null
return this.getOldUser(user)
}
@@ -299,16 +361,14 @@ class User extends Model {
/**
* Get user by openid sub
* @param {string} sub
- * @returns {Promise} returns null if not found
+ * @returns {Promise}
*/
static async getUserByOpenIDSub(sub) {
if (!sub) return null
- const user = await this.findOne({
+ return this.findOne({
where: sequelize.where(sequelize.literal(`extraData->>"authOpenIDSub"`), sub),
include: this.sequelize.models.mediaProgress
})
- if (!user) return null
- return this.getOldUser(user)
}
/**
@@ -340,6 +400,20 @@ class User extends Model {
return count > 0
}
+ /**
+ * Check if user exists with username
+ * @param {string} username
+ * @returns {boolean}
+ */
+ static async checkUserExistsWithUsername(username) {
+ const count = await this.count({
+ where: {
+ username
+ }
+ })
+ return count > 0
+ }
+
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
@@ -376,6 +450,469 @@ class User extends Model {
}
)
}
+
+ get isRoot() {
+ return this.type === 'root'
+ }
+ get isAdminOrUp() {
+ return this.isRoot || this.type === 'admin'
+ }
+ get isUser() {
+ return this.type === 'user'
+ }
+ get isGuest() {
+ return this.type === 'guest'
+ }
+ get canAccessExplicitContent() {
+ return !!this.permissions?.accessExplicitContent && this.isActive
+ }
+ get canDelete() {
+ return !!this.permissions?.delete && this.isActive
+ }
+ get canUpdate() {
+ return !!this.permissions?.update && this.isActive
+ }
+ get canDownload() {
+ return !!this.permissions?.download && this.isActive
+ }
+ get canUpload() {
+ return !!this.permissions?.upload && this.isActive
+ }
+ /** @type {string|null} */
+ get authOpenIDSub() {
+ return this.extraData?.authOpenIDSub || null
+ }
+
+ /**
+ * User data for clients
+ * Emitted on socket events user_online, user_offline and user_stream_update
+ *
+ * @param {import('../objects/PlaybackSession')[]} sessions
+ * @returns
+ */
+ toJSONForPublic(sessions) {
+ const session = sessions?.find((s) => s.userId === this.id)?.toJSONForClient() || null
+ return {
+ id: this.id,
+ username: this.username,
+ type: this.type,
+ session,
+ lastSeen: this.lastSeen?.valueOf() || null,
+ createdAt: this.createdAt.valueOf()
+ }
+ }
+
+ /**
+ * User data for browser using old model
+ *
+ * @param {boolean} [hideRootToken=false]
+ * @param {boolean} [minimal=false]
+ * @returns
+ */
+ toOldJSONForBrowser(hideRootToken = false, minimal = false) {
+ const seriesHideFromContinueListening = this.extraData?.seriesHideFromContinueListening || []
+ const librariesAccessible = this.permissions?.librariesAccessible || []
+ const itemTagsSelected = this.permissions?.itemTagsSelected || []
+ const permissions = { ...this.permissions }
+ delete permissions.librariesAccessible
+ delete permissions.itemTagsSelected
+
+ const json = {
+ id: this.id,
+ username: this.username,
+ email: this.email,
+ type: this.type,
+ token: this.type === 'root' && hideRootToken ? '' : this.token,
+ mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [],
+ seriesHideFromContinueListening: [...seriesHideFromContinueListening],
+ bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [],
+ isActive: this.isActive,
+ isLocked: this.isLocked,
+ lastSeen: this.lastSeen?.valueOf() || null,
+ createdAt: this.createdAt.valueOf(),
+ permissions: permissions,
+ librariesAccessible: [...librariesAccessible],
+ itemTagsSelected: [...itemTagsSelected],
+ hasOpenIDLink: !!this.authOpenIDSub
+ }
+ if (minimal) {
+ delete json.mediaProgress
+ delete json.bookmarks
+ }
+ return json
+ }
+
+ /**
+ * Check user has access to library
+ *
+ * @param {string} libraryId
+ * @returns {boolean}
+ */
+ checkCanAccessLibrary(libraryId) {
+ if (this.permissions?.accessAllLibraries) return true
+ if (!this.permissions?.librariesAccessible) return false
+ return this.permissions.librariesAccessible.includes(libraryId)
+ }
+
+ /**
+ * Check user has access to library item with tags
+ *
+ * @param {string[]} tags
+ * @returns {boolean}
+ */
+ checkCanAccessLibraryItemWithTags(tags) {
+ if (this.permissions.accessAllTags) return true
+ const itemTagsSelected = this.permissions?.itemTagsSelected || []
+ if (this.permissions.selectedTagsNotAccessible) {
+ if (!tags?.length) return true
+ return tags.every((tag) => !itemTagsSelected?.includes(tag))
+ }
+ if (!tags?.length) return false
+ return itemTagsSelected.some((tag) => tags.includes(tag))
+ }
+
+ /**
+ * Check user can access library item
+ * TODO: Currently supports both old and new library item models
+ *
+ * @param {import('../objects/LibraryItem')|import('./LibraryItem')} libraryItem
+ * @returns {boolean}
+ */
+ checkCanAccessLibraryItem(libraryItem) {
+ if (!this.checkCanAccessLibrary(libraryItem.libraryId)) return false
+
+ const libraryItemExplicit = !!libraryItem.media.explicit || !!libraryItem.media.metadata?.explicit
+
+ if (libraryItemExplicit && !this.canAccessExplicitContent) return false
+
+ return this.checkCanAccessLibraryItemWithTags(libraryItem.media.tags)
+ }
+
+ /**
+ * Get first available library id for user
+ *
+ * @param {string[]} libraryIds
+ * @returns {string|null}
+ */
+ getDefaultLibraryId(libraryIds) {
+ // Libraries should already be in ascending display order, find first accessible
+ return libraryIds.find((lid) => this.checkCanAccessLibrary(lid)) || null
+ }
+
+ /**
+ * Get media progress by media item id
+ *
+ * @param {string} libraryItemId
+ * @param {string|null} [episodeId]
+ * @returns {import('./MediaProgress')|null}
+ */
+ getMediaProgress(mediaItemId) {
+ if (!this.mediaProgresses?.length) return null
+ return this.mediaProgresses.find((mp) => mp.mediaItemId === mediaItemId)
+ }
+
+ /**
+ * Get old media progress
+ * TODO: Update to new model
+ *
+ * @param {string} libraryItemId
+ * @param {string} [episodeId]
+ * @returns
+ */
+ getOldMediaProgress(libraryItemId, episodeId = null) {
+ const mediaProgress = this.mediaProgresses?.find((mp) => {
+ if (episodeId && mp.mediaItemId === episodeId) return true
+ return mp.extraData?.libraryItemId === libraryItemId
+ })
+ return mediaProgress?.getOldMediaProgress() || null
+ }
+
+ /**
+ * TODO: Uses old model and should account for the different between ebook/audiobook progress
+ *
+ * @typedef ProgressUpdatePayload
+ * @property {string} libraryItemId
+ * @property {string} [episodeId]
+ * @property {number} [duration]
+ * @property {number} [progress]
+ * @property {number} [currentTime]
+ * @property {boolean} [isFinished]
+ * @property {boolean} [hideFromContinueListening]
+ * @property {string} [ebookLocation]
+ * @property {number} [ebookProgress]
+ * @property {string} [finishedAt]
+ * @property {number} [lastUpdate]
+ *
+ * @param {ProgressUpdatePayload} progressPayload
+ * @returns {Promise<{ mediaProgress: import('./MediaProgress'), error: [string], statusCode: [number] }>}
+ */
+ async createUpdateMediaProgressFromPayload(progressPayload) {
+ /** @type {import('./MediaProgress')|null} */
+ let mediaProgress = null
+ let mediaItemId = null
+ if (progressPayload.episodeId) {
+ const podcastEpisode = await this.sequelize.models.podcastEpisode.findByPk(progressPayload.episodeId, {
+ attributes: ['id', 'podcastId'],
+ include: [
+ {
+ model: this.sequelize.models.mediaProgress,
+ where: { userId: this.id },
+ required: false
+ },
+ {
+ model: this.sequelize.models.podcast,
+ attributes: ['id', 'title'],
+ include: {
+ model: this.sequelize.models.libraryItem,
+ attributes: ['id']
+ }
+ }
+ ]
+ })
+ if (!podcastEpisode) {
+ Logger.error(`[User] createUpdateMediaProgress: episode ${progressPayload.episodeId} not found`)
+ return {
+ error: 'Episode not found',
+ statusCode: 404
+ }
+ }
+ mediaItemId = podcastEpisode.id
+ mediaProgress = podcastEpisode.mediaProgresses?.[0]
+ } else {
+ const libraryItem = await this.sequelize.models.libraryItem.findByPk(progressPayload.libraryItemId, {
+ attributes: ['id', 'mediaId', 'mediaType'],
+ include: {
+ model: this.sequelize.models.book,
+ attributes: ['id', 'title'],
+ required: false,
+ include: {
+ model: this.sequelize.models.mediaProgress,
+ where: { userId: this.id },
+ required: false
+ }
+ }
+ })
+ if (!libraryItem) {
+ Logger.error(`[User] createUpdateMediaProgress: library item ${progressPayload.libraryItemId} not found`)
+ return {
+ error: 'Library item not found',
+ statusCode: 404
+ }
+ }
+ mediaItemId = libraryItem.media.id
+ mediaProgress = libraryItem.media.mediaProgresses?.[0]
+ }
+
+ if (mediaProgress) {
+ mediaProgress = await mediaProgress.applyProgressUpdate(progressPayload)
+ this.mediaProgresses = this.mediaProgresses.map((mp) => (mp.id === mediaProgress.id ? mediaProgress : mp))
+ } else {
+ const newMediaProgressPayload = {
+ userId: this.id,
+ mediaItemId,
+ mediaItemType: progressPayload.episodeId ? 'podcastEpisode' : 'book',
+ duration: isNullOrNaN(progressPayload.duration) ? 0 : Number(progressPayload.duration),
+ currentTime: isNullOrNaN(progressPayload.currentTime) ? 0 : Number(progressPayload.currentTime),
+ isFinished: !!progressPayload.isFinished,
+ hideFromContinueListening: !!progressPayload.hideFromContinueListening,
+ ebookLocation: progressPayload.ebookLocation || null,
+ ebookProgress: isNullOrNaN(progressPayload.ebookProgress) ? 0 : Number(progressPayload.ebookProgress),
+ finishedAt: progressPayload.finishedAt || null,
+ extraData: {
+ libraryItemId: progressPayload.libraryItemId,
+ progress: isNullOrNaN(progressPayload.progress) ? 0 : Number(progressPayload.progress)
+ }
+ }
+ if (newMediaProgressPayload.isFinished) {
+ newMediaProgressPayload.finishedAt = new Date()
+ newMediaProgressPayload.extraData.progress = 1
+ } else {
+ newMediaProgressPayload.finishedAt = null
+ }
+ mediaProgress = await this.sequelize.models.mediaProgress.create(newMediaProgressPayload)
+ this.mediaProgresses.push(mediaProgress)
+ }
+ return {
+ mediaProgress
+ }
+ }
+
+ /**
+ * Find bookmark
+ * TODO: Bookmarks should use mediaItemId instead of libraryItemId to support podcast episodes
+ *
+ * @param {string} libraryItemId
+ * @param {number} time
+ * @returns {AudioBookmarkObject|null}
+ */
+ findBookmark(libraryItemId, time) {
+ return this.bookmarks.find((bm) => bm.libraryItemId === libraryItemId && bm.time == time)
+ }
+
+ /**
+ * Create bookmark
+ *
+ * @param {string} libraryItemId
+ * @param {number} time
+ * @param {string} title
+ * @returns {Promise}
+ */
+ async createBookmark(libraryItemId, time, title) {
+ const existingBookmark = this.findBookmark(libraryItemId, time)
+ if (existingBookmark) {
+ Logger.warn('[User] Create Bookmark already exists for this time')
+ if (existingBookmark.title !== title) {
+ existingBookmark.title = title
+ this.changed('bookmarks', true)
+ await this.save()
+ }
+ return existingBookmark
+ }
+
+ const newBookmark = {
+ libraryItemId,
+ time,
+ title,
+ createdAt: Date.now()
+ }
+ this.bookmarks.push(newBookmark)
+ this.changed('bookmarks', true)
+ await this.save()
+ return newBookmark
+ }
+
+ /**
+ * Update bookmark
+ *
+ * @param {string} libraryItemId
+ * @param {number} time
+ * @param {string} title
+ * @returns {Promise}
+ */
+ async updateBookmark(libraryItemId, time, title) {
+ const bookmark = this.findBookmark(libraryItemId, time)
+ if (!bookmark) {
+ Logger.error(`[User] updateBookmark not found`)
+ return null
+ }
+ bookmark.title = title
+ this.changed('bookmarks', true)
+ await this.save()
+ return bookmark
+ }
+
+ /**
+ * Remove bookmark
+ *
+ * @param {string} libraryItemId
+ * @param {number} time
+ * @returns {Promise} - true if bookmark was removed
+ */
+ async removeBookmark(libraryItemId, time) {
+ if (!this.findBookmark(libraryItemId, time)) {
+ Logger.error(`[User] removeBookmark not found`)
+ return false
+ }
+ this.bookmarks = this.bookmarks.filter((bm) => bm.libraryItemId !== libraryItemId || bm.time !== time)
+ this.changed('bookmarks', true)
+ await this.save()
+ return true
+ }
+
+ /**
+ *
+ * @param {string} seriesId
+ * @returns {Promise}
+ */
+ async addSeriesToHideFromContinueListening(seriesId) {
+ if (!this.extraData) this.extraData = {}
+ const seriesHideFromContinueListening = this.extraData.seriesHideFromContinueListening || []
+ if (seriesHideFromContinueListening.includes(seriesId)) return false
+ seriesHideFromContinueListening.push(seriesId)
+ this.extraData.seriesHideFromContinueListening = seriesHideFromContinueListening
+ this.changed('extraData', true)
+ await this.save()
+ return true
+ }
+
+ /**
+ *
+ * @param {string} seriesId
+ * @returns {Promise}
+ */
+ async removeSeriesFromHideFromContinueListening(seriesId) {
+ if (!this.extraData) this.extraData = {}
+ let seriesHideFromContinueListening = this.extraData.seriesHideFromContinueListening || []
+ if (!seriesHideFromContinueListening.includes(seriesId)) return false
+ seriesHideFromContinueListening = seriesHideFromContinueListening.filter((sid) => sid !== seriesId)
+ this.extraData.seriesHideFromContinueListening = seriesHideFromContinueListening
+ this.changed('extraData', true)
+ await this.save()
+ return true
+ }
+
+ /**
+ * Update user permissions from external JSON
+ *
+ * @param {Object} absPermissions JSON containing user permissions
+ * @returns {Promise} true if updates were made
+ */
+ async updatePermissionsFromExternalJSON(absPermissions) {
+ if (!this.permissions) this.permissions = {}
+ let hasUpdates = false
+
+ // Map the boolean permissions from absPermissions
+ Object.keys(absPermissions).forEach((absKey) => {
+ const userPermKey = User.permissionMapping[absKey]
+ if (!userPermKey) {
+ throw new Error(`Unexpected permission property: ${absKey}`)
+ }
+
+ if (!['librariesAccessible', 'itemTagsSelected'].includes(userPermKey)) {
+ if (this.permissions[userPermKey] !== !!absPermissions[absKey]) {
+ this.permissions[userPermKey] = !!absPermissions[absKey]
+ hasUpdates = true
+ }
+ }
+ })
+
+ // Handle allowedLibraries
+ const librariesAccessible = this.permissions.librariesAccessible || []
+ if (this.permissions.accessAllLibraries) {
+ if (librariesAccessible.length) {
+ this.permissions.librariesAccessible = []
+ hasUpdates = true
+ }
+ } else if (absPermissions.allowedLibraries?.length && absPermissions.allowedLibraries.join(',') !== librariesAccessible.join(',')) {
+ if (absPermissions.allowedLibraries.some((lid) => typeof lid !== 'string')) {
+ throw new Error('Invalid permission property "allowedLibraries", expecting array of strings')
+ }
+ this.permissions.librariesAccessible = absPermissions.allowedLibraries
+ hasUpdates = true
+ }
+
+ // Handle allowedTags
+ const itemTagsSelected = this.permissions.itemTagsSelected || []
+ if (this.permissions.accessAllTags) {
+ if (itemTagsSelected.length) {
+ this.permissions.itemTagsSelected = []
+ hasUpdates = true
+ }
+ } else if (absPermissions.allowedTags?.length && absPermissions.allowedTags.join(',') !== itemTagsSelected.join(',')) {
+ if (absPermissions.allowedTags.some((tag) => typeof tag !== 'string')) {
+ throw new Error('Invalid permission property "allowedTags", expecting array of strings')
+ }
+ this.permissions.itemTagsSelected = absPermissions.allowedTags
+ hasUpdates = true
+ }
+
+ if (hasUpdates) {
+ this.changed('permissions', true)
+ await this.save()
+ }
+
+ return hasUpdates
+ }
}
module.exports = User
diff --git a/server/objects/Feed.js b/server/objects/Feed.js
index 2fac5d910b..74a220e35c 100644
--- a/server/objects/Feed.js
+++ b/server/objects/Feed.js
@@ -1,7 +1,9 @@
const Path = require('path')
-const uuidv4 = require("uuid").v4
+const uuidv4 = require('uuid').v4
const FeedMeta = require('./FeedMeta')
const FeedEpisode = require('./FeedEpisode')
+
+const date = require('../libs/dateAndTime')
const RSS = require('../libs/rss')
const { createNewSortInstance } = require('../libs/fastSort')
const naturalSort = createNewSortInstance({
@@ -46,7 +48,7 @@ class Feed {
this.serverAddress = feed.serverAddress
this.feedUrl = feed.feedUrl
this.meta = new FeedMeta(feed.meta)
- this.episodes = feed.episodes.map(ep => new FeedEpisode(ep))
+ this.episodes = feed.episodes.map((ep) => new FeedEpisode(ep))
this.createdAt = feed.createdAt
this.updatedAt = feed.updatedAt
}
@@ -62,7 +64,7 @@ class Feed {
serverAddress: this.serverAddress,
feedUrl: this.feedUrl,
meta: this.meta.toJSON(),
- episodes: this.episodes.map(ep => ep.toJSON()),
+ episodes: this.episodes.map((ep) => ep.toJSON()),
createdAt: this.createdAt,
updatedAt: this.updatedAt
}
@@ -74,20 +76,20 @@ class Feed {
entityType: this.entityType,
entityId: this.entityId,
feedUrl: this.feedUrl,
- meta: this.meta.toJSONMinified(),
+ meta: this.meta.toJSONMinified()
}
}
getEpisodePath(id) {
- var episode = this.episodes.find(ep => ep.id === id)
+ var episode = this.episodes.find((ep) => ep.id === id)
if (!episode) return null
return episode.fullPath
}
/**
* If chapters for an audiobook match the audio tracks then use chapter titles instead of audio file names
- *
- * @param {import('../objects/LibraryItem')} libraryItem
+ *
+ * @param {import('../objects/LibraryItem')} libraryItem
* @returns {boolean}
*/
checkUseChapterTitlesForEpisodes(libraryItem) {
@@ -137,7 +139,8 @@ class Feed {
this.meta.ownerEmail = ownerEmail
this.episodes = []
- if (isPodcast) { // PODCAST EPISODES
+ if (isPodcast) {
+ // PODCAST EPISODES
media.episodes.forEach((episode) => {
if (episode.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = episode.updatedAt
@@ -145,7 +148,8 @@ class Feed {
feedEpisode.setFromPodcastEpisode(libraryItem, serverAddress, slug, episode, this.meta)
this.episodes.push(feedEpisode)
})
- } else { // AUDIOBOOK EPISODES
+ } else {
+ // AUDIOBOOK EPISODES
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItem)
media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
@@ -178,7 +182,8 @@ class Feed {
this.meta.language = mediaMetadata.language
this.episodes = []
- if (isPodcast) { // PODCAST EPISODES
+ if (isPodcast) {
+ // PODCAST EPISODES
media.episodes.forEach((episode) => {
if (episode.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = episode.updatedAt
@@ -186,7 +191,8 @@ class Feed {
feedEpisode.setFromPodcastEpisode(libraryItem, this.serverAddress, this.slug, episode, this.meta)
this.episodes.push(feedEpisode)
})
- } else { // AUDIOBOOK EPISODES
+ } else {
+ // AUDIOBOOK EPISODES
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItem)
media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
@@ -202,8 +208,8 @@ class Feed {
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)
- const firstItemWithCover = itemsWithTracks.find(item => item.media.coverPath)
+ const itemsWithTracks = collectionExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
+ const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
this.id = uuidv4()
this.slug = slug
@@ -211,11 +217,11 @@ class Feed {
this.entityType = 'collection'
this.entityId = collectionExpanded.id
this.entityUpdatedAt = collectionExpanded.lastUpdate // This will be set to the most recently updated library item
- this.coverPath = firstItemWithCover?.coverPath || null
+ this.coverPath = firstItemWithCover?.media.coverPath || null
this.serverAddress = serverAddress
this.feedUrl = feedUrl
- const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
+ const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
this.meta = new FeedMeta()
this.meta.title = collectionExpanded.name
@@ -224,20 +230,28 @@ class Feed {
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.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 = []
+ // Used for calculating pubdate
+ const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
+
itemsWithTracks.forEach((item, index) => {
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
item.media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
- feedEpisode.setFromAudiobookTrack(item, serverAddress, slug, audioTrack, this.meta, useChapterTitles, index)
+
+ // Offset pubdate to ensure correct order
+ let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
+ trackTimeOffset += index * 1000 // Offset item
+ const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
+ feedEpisode.setFromAudiobookTrack(item, serverAddress, slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
this.episodes.push(feedEpisode)
})
})
@@ -247,29 +261,37 @@ class Feed {
}
updateFromCollection(collectionExpanded) {
- const itemsWithTracks = collectionExpanded.books.filter(libraryItem => libraryItem.media.tracks.length)
- const firstItemWithCover = itemsWithTracks.find(item => item.media.coverPath)
+ const itemsWithTracks = collectionExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
+ const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
this.entityUpdatedAt = collectionExpanded.lastUpdate
- this.coverPath = firstItemWithCover?.coverPath || null
+ this.coverPath = firstItemWithCover?.media.coverPath || null
- const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
+ const coverFileExtension = this.coverPath ? Path.extname(this.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${coverFileExtension}` : `${this.serverAddress}/Logo.png`
- this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit
+ this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
this.episodes = []
+ // Used for calculating pubdate
+ const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
+
itemsWithTracks.forEach((item, index) => {
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
item.media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
- feedEpisode.setFromAudiobookTrack(item, this.serverAddress, this.slug, audioTrack, this.meta, useChapterTitles, index)
+
+ // Offset pubdate to ensure correct order
+ let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
+ trackTimeOffset += index * 1000 // Offset item
+ const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
+ feedEpisode.setFromAudiobookTrack(item, this.serverAddress, this.slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
this.episodes.push(feedEpisode)
})
})
@@ -281,12 +303,12 @@ class Feed {
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)
+ let itemsWithTracks = seriesExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
// Sort series items by series sequence
- itemsWithTracks = naturalSort(itemsWithTracks).asc(li => li.media.metadata.getSeriesSequence(seriesExpanded.id))
+ itemsWithTracks = naturalSort(itemsWithTracks).asc((li) => li.media.metadata.getSeriesSequence(seriesExpanded.id))
const libraryId = itemsWithTracks[0].libraryId
- const firstItemWithCover = itemsWithTracks.find(li => li.media.coverPath)
+ const firstItemWithCover = itemsWithTracks.find((li) => li.media.coverPath)
this.id = uuidv4()
this.slug = slug
@@ -294,11 +316,11 @@ class Feed {
this.entityType = 'series'
this.entityId = seriesExpanded.id
this.entityUpdatedAt = seriesExpanded.updatedAt // This will be set to the most recently updated library item
- this.coverPath = firstItemWithCover?.coverPath || null
+ this.coverPath = firstItemWithCover?.media.coverPath || null
this.serverAddress = serverAddress
this.feedUrl = feedUrl
- const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
+ const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
this.meta = new FeedMeta()
this.meta.title = seriesExpanded.name
@@ -307,20 +329,28 @@ class Feed {
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.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 = []
+ // Used for calculating pubdate
+ const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
+
itemsWithTracks.forEach((item, index) => {
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
item.media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
- feedEpisode.setFromAudiobookTrack(item, serverAddress, slug, audioTrack, this.meta, useChapterTitles, index)
+
+ // Offset pubdate to ensure correct order
+ let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
+ trackTimeOffset += index * 1000 // Offset item
+ const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
+ feedEpisode.setFromAudiobookTrack(item, serverAddress, slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
this.episodes.push(feedEpisode)
})
})
@@ -330,32 +360,40 @@ class Feed {
}
updateFromSeries(seriesExpanded) {
- let itemsWithTracks = seriesExpanded.books.filter(libraryItem => libraryItem.media.tracks.length)
+ let itemsWithTracks = seriesExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
// Sort series items by series sequence
- itemsWithTracks = naturalSort(itemsWithTracks).asc(li => li.media.metadata.getSeriesSequence(seriesExpanded.id))
+ itemsWithTracks = naturalSort(itemsWithTracks).asc((li) => li.media.metadata.getSeriesSequence(seriesExpanded.id))
- const firstItemWithCover = itemsWithTracks.find(item => item.media.coverPath)
+ const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
this.entityUpdatedAt = seriesExpanded.updatedAt
- this.coverPath = firstItemWithCover?.coverPath || null
+ this.coverPath = firstItemWithCover?.media.coverPath || null
- const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
+ const coverFileExtension = this.coverPath ? Path.extname(this.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${coverFileExtension}` : `${this.serverAddress}/Logo.png`
- this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit
+ this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
this.episodes = []
+ // Used for calculating pubdate
+ const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
+
itemsWithTracks.forEach((item, index) => {
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
item.media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
- feedEpisode.setFromAudiobookTrack(item, this.serverAddress, this.slug, audioTrack, this.meta, useChapterTitles, index)
+
+ // Offset pubdate to ensure correct order
+ let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
+ trackTimeOffset += index * 1000 // Offset item
+ const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
+ feedEpisode.setFromAudiobookTrack(item, this.serverAddress, this.slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
this.episodes.push(feedEpisode)
})
})
@@ -377,7 +415,7 @@ class Feed {
getAuthorsStringFromLibraryItems(libraryItems) {
let itemAuthors = []
- libraryItems.forEach((item) => itemAuthors.push(...item.media.metadata.authors.map(au => au.name)))
+ libraryItems.forEach((item) => itemAuthors.push(...item.media.metadata.authors.map((au) => au.name)))
itemAuthors = [...new Set(itemAuthors)] // Filter out dupes
let author = itemAuthors.slice(0, 3).join(', ')
if (itemAuthors.length > 3) {
diff --git a/server/objects/FeedEpisode.js b/server/objects/FeedEpisode.js
index 50e27cf619..6d9f36a087 100644
--- a/server/objects/FeedEpisode.js
+++ b/server/objects/FeedEpisode.js
@@ -1,5 +1,5 @@
const Path = require('path')
-const uuidv4 = require("uuid").v4
+const uuidv4 = require('uuid').v4
const date = require('../libs/dateAndTime')
const { secondsToTimestamp } = require('../utils/index')
@@ -98,27 +98,22 @@ class FeedEpisode {
}
/**
- *
- * @param {import('../objects/LibraryItem')} libraryItem
- * @param {string} serverAddress
- * @param {string} slug
- * @param {import('../objects/files/AudioTrack')} audioTrack
- * @param {Object} meta
- * @param {boolean} useChapterTitles
- * @param {number} [additionalOffset]
+ *
+ * @param {import('../objects/LibraryItem')} libraryItem
+ * @param {string} serverAddress
+ * @param {string} slug
+ * @param {import('../objects/files/AudioTrack')} audioTrack
+ * @param {Object} meta
+ * @param {boolean} useChapterTitles
+ * @param {string} [pubDateOverride] Used for series & collections to ensure correct episode order
*/
- setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta, useChapterTitles, additionalOffset = null) {
+ setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta, useChapterTitles, pubDateOverride = null) {
// Example: Fri, 04 Feb 2015 00:00:00 GMT
- let timeOffset = isNaN(audioTrack.index) ? 0 : (Number(audioTrack.index) * 1000) // Offset pubdate to ensure correct order
+ let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset pubdate to ensure correct order
let episodeId = uuidv4()
- // Additional offset can be used for collections/series
- if (additionalOffset !== null && !isNaN(additionalOffset)) {
- timeOffset += Number(additionalOffset) * 1000
- }
-
// 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 audiobookPubDate = pubDateOverride || date.format(new Date(libraryItem.addedAt + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
const contentFileExtension = Path.extname(audioTrack.metadata.filename)
const contentUrl = `/feed/${slug}/item/${episodeId}/media${contentFileExtension}`
@@ -126,12 +121,13 @@ class FeedEpisode {
const mediaMetadata = media.metadata
let title = audioTrack.title
- if (libraryItem.media.tracks.length == 1) { // If audiobook is a single file, use book title instead of chapter/file title
+ if (libraryItem.media.tracks.length == 1) {
+ // If audiobook is a single file, use book title instead of chapter/file title
title = libraryItem.media.metadata.title
} else {
if (useChapterTitles) {
// If audio track start and chapter start are within 1 seconds of eachother then use the chapter title
- const matchingChapter = libraryItem.media.chapters.find(ch => Math.abs(ch.start - audioTrack.startOffset) < 1)
+ const matchingChapter = libraryItem.media.chapters.find((ch) => Math.abs(ch.start - audioTrack.startOffset) < 1)
if (matchingChapter?.title) title = matchingChapter.title
}
}
@@ -169,11 +165,11 @@ class FeedEpisode {
{ 'itunes:duration': secondsToTimestamp(this.duration) },
{ 'itunes:summary': this.description || '' },
{
- "itunes:explicit": !!this.explicit
+ 'itunes:explicit': !!this.explicit
},
- { "itunes:episodeType": this.episodeType },
- { "itunes:season": this.season },
- { "itunes:episode": this.episode }
+ { 'itunes:episodeType': this.episodeType },
+ { 'itunes:season': this.season },
+ { 'itunes:episode': this.episode }
]
}
}
diff --git a/server/objects/Stream.js b/server/objects/Stream.js
index c49372b0c7..2ab6f50362 100644
--- a/server/objects/Stream.js
+++ b/server/objects/Stream.js
@@ -1,4 +1,3 @@
-
const EventEmitter = require('events')
const Path = require('path')
const Logger = require('../Logger')
@@ -46,7 +45,7 @@ class Stream extends EventEmitter {
}
get episode() {
if (!this.isPodcast) return null
- return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId)
+ return this.libraryItem.media.episodes.find((ep) => ep.id === this.episodeId)
}
get libraryItemId() {
return this.libraryItem.id
@@ -76,21 +75,10 @@ class Stream extends EventEmitter {
return this.tracks[0].codec
}
get mimeTypesToForceAAC() {
- return [
- AudioMimeType.FLAC,
- AudioMimeType.OPUS,
- AudioMimeType.WMA,
- AudioMimeType.AIFF,
- AudioMimeType.WEBM,
- AudioMimeType.WEBMA,
- AudioMimeType.AWB,
- AudioMimeType.CAF
- ]
+ return [AudioMimeType.FLAC, AudioMimeType.OPUS, AudioMimeType.WMA, AudioMimeType.AIFF, AudioMimeType.WEBM, AudioMimeType.WEBMA, AudioMimeType.AWB, AudioMimeType.CAF]
}
get codecsToForceAAC() {
- return [
- 'alac'
- ]
+ return ['alac']
}
get userToken() {
return this.user.token
@@ -109,7 +97,7 @@ class Stream extends EventEmitter {
}
get numSegments() {
var numSegs = Math.floor(this.totalDuration / this.segmentLength)
- if (this.totalDuration - (numSegs * this.segmentLength) > 0) {
+ if (this.totalDuration - numSegs * this.segmentLength > 0) {
numSegs++
}
return numSegs
@@ -135,7 +123,7 @@ class Stream extends EventEmitter {
clientPlaylistUri: this.clientPlaylistUri,
startTime: this.startTime,
segmentStartNumber: this.segmentStartNumber,
- isTranscodeComplete: this.isTranscodeComplete,
+ isTranscodeComplete: this.isTranscodeComplete
}
}
@@ -143,7 +131,7 @@ class Stream extends EventEmitter {
const segStartTime = segNum * this.segmentLength
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))
+ await this.reset(segStartTime - this.segmentLength * 5)
return segStartTime
} else if (this.isTranscodeComplete) {
return false
@@ -153,7 +141,7 @@ class Stream extends EventEmitter {
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))
+ await this.reset(segStartTime - this.segmentLength * 5)
return segStartTime
}
}
@@ -217,7 +205,7 @@ class Stream extends EventEmitter {
else chunks.push(`${current_chunk[0]}-${current_chunk[current_chunk.length - 1]}`)
}
- var perc = (this.segmentsCreated.size * 100 / this.numSegments).toFixed(2) + '%'
+ var perc = ((this.segmentsCreated.size * 100) / this.numSegments).toFixed(2) + '%'
Logger.info('[STREAM-CHECK] Check Files', this.segmentsCreated.size, 'of', this.numSegments, perc, `Furthest Segment: ${this.furthestSegmentCreated}`)
// Logger.debug('[STREAM-CHECK] Chunks', chunks.join(', '))
@@ -251,6 +239,7 @@ class Stream extends EventEmitter {
async start() {
Logger.info(`[STREAM] START STREAM - Num Segments: ${this.numSegments}`)
+ /** @type {import('../libs/fluentFfmpeg/index').FfmpegCommand} */
this.ffmpeg = Ffmpeg()
this.furthestSegmentCreated = 0
@@ -289,24 +278,8 @@ class Stream extends EventEmitter {
audioCodec = 'aac'
}
- this.ffmpeg.addOption([
- `-loglevel ${logLevel}`,
- '-map 0:a',
- `-c:a ${audioCodec}`
- ])
- const hlsOptions = [
- '-f hls',
- "-copyts",
- "-avoid_negative_ts make_non_negative",
- "-max_delay 5000000",
- "-max_muxing_queue_size 2048",
- `-hls_time 6`,
- `-hls_segment_type ${this.hlsSegmentType}`,
- `-start_number ${this.segmentStartNumber}`,
- "-hls_playlist_type vod",
- "-hls_list_size 0",
- "-hls_allow_cache 0"
- ]
+ this.ffmpeg.addOption([`-loglevel ${logLevel}`, '-map 0:a', `-c:a ${audioCodec}`])
+ const hlsOptions = ['-f hls', '-copyts', '-avoid_negative_ts make_non_negative', '-max_delay 5000000', '-max_muxing_queue_size 2048', `-hls_time 6`, `-hls_segment_type ${this.hlsSegmentType}`, `-start_number ${this.segmentStartNumber}`, '-hls_playlist_type vod', '-hls_list_size 0', '-hls_allow_cache 0']
if (this.hlsSegmentType === 'fmp4') {
hlsOptions.push('-strict -2')
var fmp4InitFilename = Path.join(this.streamPath, 'init.mp4')
@@ -369,7 +342,6 @@ class Stream extends EventEmitter {
Logger.info(`[STREAM] ${this.id} notifying client that stream is ready`)
this.clientEmit('stream_open', this.toJSON())
-
}
this.isTranscodeComplete = true
this.ffmpeg = null
@@ -387,11 +359,14 @@ class Stream extends EventEmitter {
this.ffmpeg.kill('SIGKILL')
}
- await fs.remove(this.streamPath).then(() => {
- Logger.info('Deleted session data', this.streamPath)
- }).catch((err) => {
- Logger.error('Failed to delete session data', err)
- })
+ await fs
+ .remove(this.streamPath)
+ .then(() => {
+ Logger.info('Deleted session data', this.streamPath)
+ })
+ .catch((err) => {
+ Logger.error('Failed to delete session data', err)
+ })
if (errorMessage) this.clientEmit('stream_error', { id: this.id, error: (errorMessage || '').trim() })
else this.clientEmit('stream_closed', this.id)
diff --git a/server/objects/TrackProgressMonitor.js b/server/objects/TrackProgressMonitor.js
new file mode 100644
index 0000000000..24664f22e1
--- /dev/null
+++ b/server/objects/TrackProgressMonitor.js
@@ -0,0 +1,88 @@
+class TrackProgressMonitor {
+ /**
+ * @callback TrackStartedCallback
+ * @param {number} trackIndex - The index of the track that started.
+ */
+
+ /**
+ * @callback ProgressCallback
+ * @param {number} trackIndex - The index of the current track.
+ * @param {number} progressInTrack - The current track progress in percent.
+ * @param {number} totalProgress - The total progress in percent.
+ */
+
+ /**
+ * @callback TrackFinishedCallback
+ * @param {number} trackIndex - The index of the track that finished.
+ */
+
+ /**
+ * Creates a new TrackProgressMonitor.
+ * @constructor
+ * @param {number[]} trackDurations - The durations of the tracks in seconds.
+ * @param {TrackStartedCallback} trackStartedCallback - The callback to call when a track starts.
+ * @param {ProgressCallback} progressCallback - The callback to call when progress is updated.
+ * @param {TrackFinishedCallback} trackFinishedCallback - The callback to call when a track finishes.
+ */
+ constructor(trackDurations, trackStartedCallback, progressCallback, trackFinishedCallback) {
+ this.trackDurations = trackDurations
+ this.totalDuration = trackDurations.reduce((total, duration) => total + duration, 0)
+ this.trackStartedCallback = trackStartedCallback
+ this.progressCallback = progressCallback
+ this.trackFinishedCallback = trackFinishedCallback
+ this.currentTrackIndex = -1
+ this.cummulativeProgress = 0
+ this.currentTrackPercentage = 0
+ this.numTracks = this.trackDurations.length
+ this.allTracksFinished = false
+ this.#moveToNextTrack()
+ }
+
+ #outsideCurrentTrack(progress) {
+ this.currentTrackProgress = progress - this.cummulativeProgress
+ return this.currentTrackProgress >= this.currentTrackPercentage
+ }
+
+ #moveToNextTrack() {
+ if (this.currentTrackIndex >= 0) this.#trackFinished()
+ this.currentTrackIndex++
+ this.cummulativeProgress += this.currentTrackPercentage
+ if (this.currentTrackIndex >= this.numTracks) {
+ this.allTracksFinished = true
+ return
+ }
+ this.currentTrackPercentage = (this.trackDurations[this.currentTrackIndex] / this.totalDuration) * 100
+ this.#trackStarted()
+ }
+
+ #trackStarted() {
+ this.trackStartedCallback(this.currentTrackIndex)
+ }
+
+ #progressUpdated(totalProgress) {
+ const progressInTrack = (this.currentTrackProgress / this.currentTrackPercentage) * 100
+ this.progressCallback(this.currentTrackIndex, progressInTrack, totalProgress)
+ }
+
+ #trackFinished() {
+ this.trackFinishedCallback(this.currentTrackIndex)
+ }
+
+ /**
+ * Updates the track progress based on the total progress.
+ * @param {number} totalProgress - The total progress in percent.
+ */
+ update(totalProgress) {
+ while (this.#outsideCurrentTrack(totalProgress) && !this.allTracksFinished) this.#moveToNextTrack()
+ if (!this.allTracksFinished) this.#progressUpdated(totalProgress)
+ }
+
+ /**
+ * Finish the track progress monitoring.
+ * Forces all remaining tracks to finish.
+ */
+ finish() {
+ this.update(101)
+ }
+}
+module.exports = TrackProgressMonitor
diff --git a/server/objects/settings/EmailSettings.js b/server/objects/settings/EmailSettings.js
index 330e1b9caf..db3ad75471 100644
--- a/server/objects/settings/EmailSettings.js
+++ b/server/objects/settings/EmailSettings.js
@@ -140,7 +140,7 @@ class EmailSettings {
/**
*
* @param {EreaderDeviceObject} device
- * @param {import('../user/User')} user
+ * @param {import('../../models/User')} user
* @returns {boolean}
*/
checkUserCanAccessDevice(device, user) {
@@ -158,7 +158,7 @@ class EmailSettings {
/**
* Get ereader devices accessible to user
*
- * @param {import('../user/User')} user
+ * @param {import('../../models/User')} user
* @returns {EreaderDeviceObject[]}
*/
getEReaderDevices(user) {
diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js
index 6ade11a9dd..8ecb8ff051 100644
--- a/server/objects/settings/ServerSettings.js
+++ b/server/objects/settings/ServerSettings.js
@@ -2,7 +2,7 @@ const Path = require('path')
const packageJson = require('../../../package.json')
const { BookshelfView } = require('../../utils/constants')
const Logger = require('../../Logger')
-const User = require('../user/User')
+const User = require('../../models/User')
class ServerSettings {
constructor(settings) {
@@ -102,7 +102,7 @@ class ServerSettings {
this.backupPath = settings.backupPath || Path.join(global.MetadataPath, 'backups')
this.backupSchedule = settings.backupSchedule || false
this.backupsToKeep = settings.backupsToKeep || 2
- this.maxBackupSize = settings.maxBackupSize || 1
+ this.maxBackupSize = settings.maxBackupSize === 0 ? 0 : settings.maxBackupSize || 1
this.loggerDailyLogsToKeep = settings.loggerDailyLogsToKeep || 7
this.loggerScannerLogsToKeep = settings.loggerScannerLogsToKeep || 2
diff --git a/server/objects/user/User.js b/server/objects/user/User.js
index 938c6d07fc..f98451b02d 100644
--- a/server/objects/user/User.js
+++ b/server/objects/user/User.js
@@ -1,4 +1,3 @@
-const Logger = require('../../Logger')
const AudioBookmark = require('./AudioBookmark')
const MediaProgress = require('./MediaProgress')
@@ -86,9 +85,9 @@ class User {
pash: this.pash,
type: this.type,
token: this.token,
- mediaProgress: this.mediaProgress ? this.mediaProgress.map(li => li.toJSON()) : [],
+ mediaProgress: this.mediaProgress ? this.mediaProgress.map((li) => li.toJSON()) : [],
seriesHideFromContinueListening: [...this.seriesHideFromContinueListening],
- bookmarks: this.bookmarks ? this.bookmarks.map(b => b.toJSON()) : [],
+ bookmarks: this.bookmarks ? this.bookmarks.map((b) => b.toJSON()) : [],
isActive: this.isActive,
isLocked: this.isLocked,
lastSeen: this.lastSeen,
@@ -107,10 +106,10 @@ class User {
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()) : [],
+ token: this.type === 'root' && hideRootToken ? '' : this.token,
+ mediaProgress: this.mediaProgress ? this.mediaProgress.map((li) => li.toJSON()) : [],
seriesHideFromContinueListening: [...this.seriesHideFromContinueListening],
- bookmarks: this.bookmarks ? this.bookmarks.map(b => b.toJSON()) : [],
+ bookmarks: this.bookmarks ? this.bookmarks.map((b) => b.toJSON()) : [],
isActive: this.isActive,
isLocked: this.isLocked,
lastSeen: this.lastSeen,
@@ -133,7 +132,7 @@ class User {
* @returns {object}
*/
toJSONForPublic(sessions) {
- const userSession = sessions?.find(s => s.userId === this.id) || null
+ const userSession = sessions?.find((s) => s.userId === this.id) || null
const session = userSession?.toJSONForClient() || null
return {
id: this.id,
@@ -157,18 +156,18 @@ class User {
this.mediaProgress = []
if (user.mediaProgress) {
- this.mediaProgress = user.mediaProgress.map(li => new MediaProgress(li)).filter(lip => lip.id)
+ this.mediaProgress = user.mediaProgress.map((li) => new MediaProgress(li)).filter((lip) => lip.id)
}
this.bookmarks = []
if (user.bookmarks) {
- this.bookmarks = user.bookmarks.filter(bm => typeof bm.libraryItemId == 'string').map(bm => new AudioBookmark(bm))
+ this.bookmarks = user.bookmarks.filter((bm) => typeof bm.libraryItemId == 'string').map((bm) => new AudioBookmark(bm))
}
this.seriesHideFromContinueListening = []
if (user.seriesHideFromContinueListening) this.seriesHideFromContinueListening = [...user.seriesHideFromContinueListening]
- this.isActive = (user.isActive === undefined || user.type === 'root') ? true : !!user.isActive
+ this.isActive = user.isActive === undefined || user.type === 'root' ? true : !!user.isActive
this.isLocked = user.type === 'root' ? false : !!user.isLocked
this.lastSeen = user.lastSeen || null
this.createdAt = user.createdAt || Date.now()
@@ -200,7 +199,8 @@ class User {
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)
+ if (key === 'isActive' || payload[key]) {
+ // pash, type, username must evaluate to true (cannot be null or empty)
if (payload[key] !== this[key]) {
hasUpdates = true
this[key] = payload[key]
@@ -267,264 +267,5 @@ class User {
}
return hasUpdates
}
-
- // List of expected permission properties from the client
- static permissionMapping = {
- canDownload: 'download',
- canUpload: 'upload',
- canDelete: 'delete',
- canUpdate: 'update',
- canAccessExplicitContent: 'accessExplicitContent',
- canAccessAllLibraries: 'accessAllLibraries',
- canAccessAllTags: 'accessAllTags',
- tagsAreDenylist: 'selectedTagsNotAccessible',
- // Direct mapping for array-based permissions
- allowedLibraries: 'librariesAccessible',
- allowedTags: 'itemTagsSelected'
- }
-
- /**
- * Update user permissions from external JSON
- *
- * @param {Object} absPermissions JSON containing user permissions
- * @returns {boolean} true if updates were made
- */
- updatePermissionsFromExternalJSON(absPermissions) {
- let hasUpdates = false
- let updatedUserPermissions = {}
-
- // Initialize all permissions to false first
- Object.keys(User.permissionMapping).forEach(mappingKey => {
- const userPermKey = User.permissionMapping[mappingKey]
- if (typeof this.permissions[userPermKey] === 'boolean') {
- updatedUserPermissions[userPermKey] = false // Default to false for boolean permissions
- }
- })
-
- // Map the boolean permissions from absPermissions
- Object.keys(absPermissions).forEach(absKey => {
- const userPermKey = User.permissionMapping[absKey]
- if (!userPermKey) {
- throw new Error(`Unexpected permission property: ${absKey}`)
- }
-
- if (updatedUserPermissions[userPermKey] !== undefined) {
- updatedUserPermissions[userPermKey] = !!absPermissions[absKey]
- }
- })
-
- // Update user permissions if changes were made
- if (JSON.stringify(this.permissions) !== JSON.stringify(updatedUserPermissions)) {
- this.permissions = updatedUserPermissions
- hasUpdates = true
- }
-
- // Handle allowedLibraries
- if (this.permissions.accessAllLibraries) {
- if (this.librariesAccessible.length) {
- this.librariesAccessible = []
- hasUpdates = true
- }
- } else if (absPermissions.allowedLibraries?.length && absPermissions.allowedLibraries.join(',') !== this.librariesAccessible.join(',')) {
- if (absPermissions.allowedLibraries.some(lid => typeof lid !== 'string')) {
- throw new Error('Invalid permission property "allowedLibraries", expecting array of strings')
- }
- this.librariesAccessible = absPermissions.allowedLibraries
- hasUpdates = true
- }
-
- // Handle allowedTags
- if (this.permissions.accessAllTags) {
- if (this.itemTagsSelected.length) {
- this.itemTagsSelected = []
- hasUpdates = true
- }
- } else if (absPermissions.allowedTags?.length && absPermissions.allowedTags.join(',') !== this.itemTagsSelected.join(',')) {
- if (absPermissions.allowedTags.some(tag => typeof tag !== 'string')) {
- throw new Error('Invalid permission property "allowedTags", expecting array of strings')
- }
- this.itemTagsSelected = absPermissions.allowedTags
- hasUpdates = true
- }
-
- return hasUpdates
- }
-
-
- /**
- * Get a sample to show how a JSON for updatePermissionsFromExternalJSON should look like
- *
- * @returns {string} JSON string
- */
- static getSampleAbsPermissions() {
- // Start with a template object where all permissions are false for simplicity
- const samplePermissions = Object.keys(User.permissionMapping).reduce((acc, key) => {
- // For array-based permissions, provide a sample array
- if (key === 'allowedLibraries') {
- acc[key] = [`5406ba8a-16e1-451d-96d7-4931b0a0d966`, `918fd848-7c1d-4a02-818a-847435a879ca`]
- } else if (key === 'allowedTags') {
- acc[key] = [`ExampleTag`, `AnotherTag`, `ThirdTag`]
- } else {
- acc[key] = false
- }
- return acc
- }, {})
-
- return JSON.stringify(samplePermissions, null, 2) // Pretty print the JSON
- }
-
- /**
- * Get first available library id for user
- *
- * @param {string[]} libraryIds
- * @returns {string|null}
- */
- getDefaultLibraryId(libraryIds) {
- // Libraries should already be in ascending display order, find first accessible
- return libraryIds.find(lid => this.checkCanAccessLibrary(lid)) || null
- }
-
- getMediaProgress(libraryItemId, episodeId = null) {
- if (!this.mediaProgress) return null
- return this.mediaProgress.find(lip => {
- if (episodeId && lip.episodeId !== episodeId) return false
- return lip.libraryItemId === libraryItemId
- })
- }
-
- getAllMediaProgressForLibraryItem(libraryItemId) {
- if (!this.mediaProgress) return []
- return this.mediaProgress.filter(li => li.libraryItemId === libraryItemId)
- }
-
- createUpdateMediaProgress(libraryItem, updatePayload, episodeId = null) {
- const itemProgress = this.mediaProgress.find(li => {
- if (episodeId && li.episodeId !== episodeId) return false
- return li.libraryItemId === libraryItem.id
- })
- if (!itemProgress) {
- const newItemProgress = new MediaProgress()
-
- newItemProgress.setData(libraryItem, updatePayload, episodeId, this.id)
- this.mediaProgress.push(newItemProgress)
- return true
- }
- const wasUpdated = itemProgress.update(updatePayload)
-
- if (updatePayload.lastUpdate) itemProgress.lastUpdate = updatePayload.lastUpdate // For local to keep update times in sync
- return wasUpdated
- }
-
- removeMediaProgress(id) {
- if (!this.mediaProgress.some(mp => mp.id === id)) return false
- this.mediaProgress = this.mediaProgress.filter(mp => mp.id !== id)
- return true
- }
-
- checkCanAccessLibrary(libraryId) {
- if (this.permissions.accessAllLibraries) return true
- if (!this.librariesAccessible) return false
- return this.librariesAccessible.includes(libraryId)
- }
-
- checkCanAccessLibraryItemWithTags(tags) {
- if (this.permissions.accessAllTags) return true
- if (this.permissions.selectedTagsNotAccessible) {
- if (!tags?.length) return true
- return tags.every(tag => !this.itemTagsSelected.includes(tag))
- }
- if (!tags?.length) return false
- return this.itemTagsSelected.some(tag => tags.includes(tag))
- }
-
- checkCanAccessLibraryItem(libraryItem) {
- if (!this.checkCanAccessLibrary(libraryItem.libraryId)) return false
-
- if (libraryItem.media.metadata.explicit && !this.canAccessExplicitContent) return false
- return this.checkCanAccessLibraryItemWithTags(libraryItem.media.tags)
- }
-
- /**
- * Checks if a user can access a library item
- * @param {string} libraryId
- * @param {boolean} explicit
- * @param {string[]} tags
- */
- checkCanAccessLibraryItemWithData(libraryId, explicit, tags) {
- if (!this.checkCanAccessLibrary(libraryId)) return false
- if (explicit && !this.canAccessExplicitContent) return false
- return this.checkCanAccessLibraryItemWithTags(tags)
- }
-
- findBookmark(libraryItemId, time) {
- return this.bookmarks.find(bm => bm.libraryItemId === libraryItemId && bm.time == time)
- }
-
- createBookmark(libraryItemId, time, title) {
- var existingBookmark = this.findBookmark(libraryItemId, time)
- if (existingBookmark) {
- Logger.warn('[User] Create Bookmark already exists for this time')
- existingBookmark.title = title
- return existingBookmark
- }
- var newBookmark = new AudioBookmark()
- newBookmark.setData(libraryItemId, time, title)
- this.bookmarks.push(newBookmark)
- return newBookmark
- }
-
- updateBookmark(libraryItemId, time, title) {
- var bookmark = this.findBookmark(libraryItemId, time)
- if (!bookmark) {
- Logger.error(`[User] updateBookmark not found`)
- return null
- }
- bookmark.title = title
- return bookmark
- }
-
- removeBookmark(libraryItemId, time) {
- this.bookmarks = this.bookmarks.filter(bm => (bm.libraryItemId !== libraryItemId || bm.time !== time))
- }
-
- checkShouldHideSeriesFromContinueListening(seriesId) {
- return this.seriesHideFromContinueListening.includes(seriesId)
- }
-
- addSeriesToHideFromContinueListening(seriesId) {
- if (this.seriesHideFromContinueListening.includes(seriesId)) return false
- this.seriesHideFromContinueListening.push(seriesId)
- return true
- }
-
- removeSeriesFromHideFromContinueListening(seriesId) {
- if (!this.seriesHideFromContinueListening.includes(seriesId)) return false
- this.seriesHideFromContinueListening = this.seriesHideFromContinueListening.filter(sid => sid !== seriesId)
- return true
- }
-
- removeProgressFromContinueListening(progressId) {
- const progress = this.mediaProgress.find(mp => mp.id === progressId)
- if (!progress) return false
- return progress.removeFromContinueListening()
- }
-
- /**
- * Number of podcast episodes not finished for library item
- * Note: libraryItem passed in from libraryHelpers is not a LibraryItem class instance
- * @param {LibraryItem|object} libraryItem
- * @returns {number}
- */
- getNumEpisodesIncompleteForPodcast(libraryItem) {
- if (!libraryItem?.media.episodes) return 0
- let numEpisodesIncomplete = 0
- for (const episode of libraryItem.media.episodes) {
- const mediaProgress = this.getMediaProgress(libraryItem.id, episode.id)
- if (!mediaProgress?.isFinished) {
- numEpisodesIncomplete++
- }
- }
- return numEpisodesIncomplete
- }
}
-module.exports = User
\ No newline at end of file
+module.exports = User
diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js
index 52c81d0249..54cd97c094 100644
--- a/server/routers/ApiRouter.js
+++ b/server/routers/ApiRouter.js
@@ -39,17 +39,24 @@ class ApiRouter {
constructor(Server) {
/** @type {import('../Auth')} */
this.auth = Server.auth
+ /** @type {import('../managers/PlaybackSessionManager')} */
this.playbackSessionManager = Server.playbackSessionManager
+ /** @type {import('../managers/AbMergeManager')} */
this.abMergeManager = Server.abMergeManager
/** @type {import('../managers/BackupManager')} */
this.backupManager = Server.backupManager
/** @type {import('../Watcher')} */
this.watcher = Server.watcher
+ /** @type {import('../managers/PodcastManager')} */
this.podcastManager = Server.podcastManager
+ /** @type {import('../managers/AudioMetadataManager')} */
this.audioMetadataManager = Server.audioMetadataManager
+ /** @type {import('../managers/RssFeedManager')} */
this.rssFeedManager = Server.rssFeedManager
this.cronManager = Server.cronManager
+ /** @type {import('../managers/NotificationManager')} */
this.notificationManager = Server.notificationManager
+ /** @type {import('../managers/EmailManager')} */
this.emailManager = Server.emailManager
this.apiCacheManager = Server.apiCacheManager
@@ -173,14 +180,12 @@ class ApiRouter {
this.router.get('/me/progress/:id/remove-from-continue-listening', MeController.removeItemFromContinueListening.bind(this))
this.router.get('/me/progress/:id/:episodeId?', MeController.getMediaProgress.bind(this))
this.router.patch('/me/progress/batch/update', MeController.batchUpdateMediaProgress.bind(this))
- this.router.patch('/me/progress/:id', MeController.createUpdateMediaProgress.bind(this))
+ this.router.patch('/me/progress/:libraryItemId/:episodeId?', MeController.createUpdateMediaProgress.bind(this))
this.router.delete('/me/progress/:id', MeController.removeMediaProgress.bind(this))
- this.router.patch('/me/progress/:id/:episodeId', MeController.createUpdateEpisodeMediaProgress.bind(this))
this.router.post('/me/item/:id/bookmark', MeController.createBookmark.bind(this))
this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this))
this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this))
this.router.patch('/me/password', MeController.updatePassword.bind(this))
- this.router.post('/me/sync-local-progress', MeController.syncLocalMediaProgress.bind(this)) // TODO: Deprecated. Removed from Android. Only used in iOS app now.
this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this))
this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this))
this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this))
@@ -239,7 +244,8 @@ class ApiRouter {
//
this.router.post('/podcasts', PodcastController.create.bind(this))
this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this))
- this.router.post('/podcasts/opml', PodcastController.getFeedsFromOPMLText.bind(this))
+ this.router.post('/podcasts/opml/parse', PodcastController.getFeedsFromOPMLText.bind(this))
+ this.router.post('/podcasts/opml/create', PodcastController.bulkCreatePodcastsFromOpmlFeedUrls.bind(this))
this.router.get('/podcasts/:id/checknew', PodcastController.middleware.bind(this), PodcastController.checkNewEpisodes.bind(this))
this.router.get('/podcasts/:id/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this))
this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this))
@@ -279,7 +285,6 @@ class ApiRouter {
this.router.get('/search/podcast', SearchController.findPodcasts.bind(this))
this.router.get('/search/authors', SearchController.findAuthor.bind(this))
this.router.get('/search/chapters', SearchController.findChapters.bind(this))
- this.router.get('/search/tracks', SearchController.findMusicTrack.bind(this))
//
// Cache Routes (Admin and up)
@@ -349,12 +354,13 @@ class ApiRouter {
* @param {string[]} mediaItemIds array of bookId or podcastEpisodeId
*/
async handleDeleteLibraryItem(mediaType, libraryItemId, mediaItemIds) {
- // Remove media progress for this library item from all users
- const users = await Database.userModel.getOldUsers()
- for (const user of users) {
- for (const mediaProgress of user.getAllMediaProgressForLibraryItem(libraryItemId)) {
- await Database.removeMediaProgress(mediaProgress.id)
+ const numProgressRemoved = await Database.mediaProgressModel.destroy({
+ where: {
+ mediaItemId: mediaItemIds
}
+ })
+ if (numProgressRemoved > 0) {
+ Logger.info(`[ApiRouter] Removed ${numProgressRemoved} media progress entries for library item "${libraryItemId}"`)
}
// TODO: Remove open sessions for library item
@@ -420,11 +426,11 @@ class ApiRouter {
const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId)
if (await fs.pathExists(itemMetadataPath)) {
- Logger.debug(`[ApiRouter] Removing item metadata path "${itemMetadataPath}"`)
+ Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`)
await fs.remove(itemMetadataPath)
}
- await Database.removeLibraryItem(libraryItemId)
+ await Database.libraryItemModel.removeById(libraryItemId)
SocketAuthority.emitter('item_removed', {
id: libraryItemId
diff --git a/server/scanner/NfoFileScanner.js b/server/scanner/NfoFileScanner.js
index e450b5c307..7d5b90d663 100644
--- a/server/scanner/NfoFileScanner.js
+++ b/server/scanner/NfoFileScanner.js
@@ -2,24 +2,26 @@ const { parseNfoMetadata } = require('../utils/parsers/parseNfoMetadata')
const { readTextFile } = require('../utils/fileUtils')
class NfoFileScanner {
- constructor() { }
+ constructor() {}
/**
* Parse metadata from .nfo file found in library scan and update bookMetadata
- *
- * @param {import('../models/LibraryItem').LibraryFileObject} nfoLibraryFileObj
- * @param {Object} bookMetadata
+ *
+ * @param {import('../models/LibraryItem').LibraryFileObject} nfoLibraryFileObj
+ * @param {Object} bookMetadata
*/
async scanBookNfoFile(nfoLibraryFileObj, bookMetadata) {
const nfoText = await readTextFile(nfoLibraryFileObj.metadata.path)
const nfoMetadata = nfoText ? await parseNfoMetadata(nfoText) : null
if (nfoMetadata) {
for (const key in nfoMetadata) {
- if (key === 'tags') { // Add tags only if tags are empty
+ if (key === 'tags') {
+ // Add tags only if tags are empty
if (nfoMetadata.tags.length) {
bookMetadata.tags = nfoMetadata.tags
}
- } else if (key === 'genres') { // Add genres only if genres are empty
+ } else if (key === 'genres') {
+ // Add genres only if genres are empty
if (nfoMetadata.genres.length) {
bookMetadata.genres = nfoMetadata.genres
}
@@ -33,10 +35,12 @@ class NfoFileScanner {
}
} else if (key === 'series') {
if (nfoMetadata.series) {
- bookMetadata.series = [{
- name: nfoMetadata.series,
- sequence: nfoMetadata.sequence || null
- }]
+ bookMetadata.series = [
+ {
+ name: nfoMetadata.series,
+ sequence: nfoMetadata.sequence || null
+ }
+ ]
}
} else if (nfoMetadata[key] && key !== 'sequence') {
bookMetadata[key] = nfoMetadata[key]
@@ -45,4 +49,4 @@ class NfoFileScanner {
}
}
}
-module.exports = new NfoFileScanner()
\ No newline at end of file
+module.exports = new NfoFileScanner()
diff --git a/server/utils/downloadWorker.js b/server/utils/downloadWorker.js
deleted file mode 100644
index 61bb7c40bc..0000000000
--- a/server/utils/downloadWorker.js
+++ /dev/null
@@ -1,92 +0,0 @@
-const Ffmpeg = require('../libs/fluentFfmpeg')
-
-if (process.env.FFMPEG_PATH) {
- Ffmpeg.setFfmpegPath(process.env.FFMPEG_PATH)
-}
-
-const { parentPort, workerData } = require("worker_threads")
-
-parentPort.postMessage({
- type: 'FFMPEG',
- level: 'debug',
- log: '[DownloadWorker] Starting Worker...'
-})
-
-const ffmpegCommand = Ffmpeg()
-const startTime = Date.now()
-
-workerData.inputs.forEach((inputData) => {
- ffmpegCommand.input(inputData.input)
- if (inputData.options) ffmpegCommand.inputOption(inputData.options)
-})
-
-if (workerData.options) ffmpegCommand.addOption(workerData.options)
-if (workerData.outputOptions && workerData.outputOptions.length) ffmpegCommand.addOutputOption(workerData.outputOptions)
-ffmpegCommand.output(workerData.output)
-
-var isKilled = false
-
-async function runFfmpeg() {
- var success = await new Promise((resolve) => {
- ffmpegCommand.on('start', (command) => {
- parentPort.postMessage({
- type: 'FFMPEG',
- level: 'info',
- log: '[DownloadWorker] FFMPEG concat started with command: ' + command
- })
- })
-
- ffmpegCommand.on('stderr', (stdErrline) => {
- parentPort.postMessage({
- type: 'FFMPEG',
- level: 'debug',
- log: '[DownloadWorker] Ffmpeg Stderr: ' + stdErrline
- })
- })
-
- ffmpegCommand.on('error', (err, stdout, stderr) => {
- if (err.message && err.message.includes('SIGKILL')) {
- // This is an intentional SIGKILL
- parentPort.postMessage({
- type: 'FFMPEG',
- level: 'info',
- log: '[DownloadWorker] User Killed worker'
- })
- } else {
- parentPort.postMessage({
- type: 'FFMPEG',
- level: 'error',
- log: '[DownloadWorker] Ffmpeg Err: ' + err.message
- })
- }
- resolve(false)
- })
-
- ffmpegCommand.on('end', (stdout, stderr) => {
- parentPort.postMessage({
- type: 'FFMPEG',
- level: 'info',
- log: '[DownloadWorker] worker ended'
- })
- resolve(true)
- })
- ffmpegCommand.run()
- })
-
- var resultMessage = {
- type: 'RESULT',
- isKilled,
- elapsed: Date.now() - startTime,
- success
- }
- parentPort.postMessage(resultMessage)
-}
-
-parentPort.on('message', (message) => {
- if (message === 'STOP') {
- isKilled = true
- ffmpegCommand.kill()
- }
-})
-
-runFfmpeg()
\ No newline at end of file
diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js
index 2a242504a7..e0f5c7da8f 100644
--- a/server/utils/ffmpegHelpers.js
+++ b/server/utils/ffmpegHelpers.js
@@ -1,10 +1,10 @@
const axios = require('axios')
const Ffmpeg = require('../libs/fluentFfmpeg')
+const ffmpgegUtils = require('../libs/fluentFfmpeg/utils')
const fs = require('../libs/fsExtra')
-const os = require('os')
const Path = require('path')
const Logger = require('../Logger')
-const { filePathToPOSIX } = require('./fileUtils')
+const { filePathToPOSIX, copyToExisting } = require('./fileUtils')
const LibraryItem = require('../objects/LibraryItem')
function escapeSingleQuotes(path) {
@@ -53,6 +53,7 @@ async function extractCoverArt(filepath, outputpath) {
await fs.ensureDir(dirname)
return new Promise((resolve) => {
+ /** @type {import('../libs/fluentFfmpeg/index').FfmpegCommand} */
var ffmpeg = Ffmpeg(filepath)
ffmpeg.addOption(['-map 0:v', '-frames:v 1'])
ffmpeg.output(outputpath)
@@ -76,6 +77,7 @@ module.exports.extractCoverArt = extractCoverArt
//This should convert based on the output file extension as well
async function resizeImage(filePath, outputPath, width, height) {
return new Promise((resolve) => {
+ /** @type {import('../libs/fluentFfmpeg/index').FfmpegCommand} */
var ffmpeg = Ffmpeg(filePath)
ffmpeg.addOption(['-vf', `scale=${width || -1}:${height || -1}`])
ffmpeg.addOutput(outputPath)
@@ -102,7 +104,7 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
method: 'GET',
responseType: 'stream',
headers: {
- 'User-Agent': 'audiobookshelf (+https://audiobookshelf.org; like iTMS)'
+ 'User-Agent': 'audiobookshelf (+https://audiobookshelf.org)'
},
timeout: 30000
}).catch((error) => {
@@ -111,6 +113,7 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
})
if (!response) return resolve(false)
+ /** @type {import('../libs/fluentFfmpeg/index').FfmpegCommand} */
const ffmpeg = Ffmpeg(response.data)
ffmpeg.addOption('-loglevel debug') // Debug logs printed on error
ffmpeg.outputOptions('-c:a', 'copy', '-map', '0:a', '-metadata', 'podcast=1')
@@ -250,10 +253,12 @@ module.exports.writeFFMetadataFile = writeFFMetadataFile
* @param {string} metadataFilePath - Path to the ffmetadata file.
* @param {number} track - The track number to embed in the audio file.
* @param {string} mimeType - The MIME type of the audio file.
- * @param {Ffmpeg} ffmpeg - The Ffmpeg instance to use (optional). Used for dependency injection in tests.
- * @returns {Promise} A promise that resolves to true if the operation is successful, false otherwise.
+ * @param {function(number): void|null} progressCB - A callback function to report progress.
+ * @param {import('../libs/fluentFfmpeg/index').FfmpegCommand} ffmpeg - The Ffmpeg instance to use (optional). Used for dependency injection in tests.
+ * @param {function(string, string): Promise} copyFunc - The function to use for copying files (optional). Used for dependency injection in tests.
+ * @returns {Promise} A promise that resolves if the operation is successful, rejects otherwise.
*/
-async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpeg = Ffmpeg()) {
+async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, progressCB = null, ffmpeg = Ffmpeg(), copyFunc = copyToExisting) {
const isMp4 = mimeType === 'audio/mp4'
const isMp3 = mimeType === 'audio/mpeg'
@@ -262,7 +267,7 @@ async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataF
const audioFileBaseName = Path.basename(audioFilePath, audioFileExt)
const tempFilePath = filePathToPOSIX(Path.join(audioFileDir, `${audioFileBaseName}.tmp${audioFileExt}`))
- return new Promise((resolve) => {
+ return new Promise((resolve, reject) => {
ffmpeg.input(audioFilePath).input(metadataFilePath).outputOptions([
'-map 0:a', // map audio stream from input file
'-map_metadata 1', // map metadata tags from metadata file first
@@ -302,21 +307,37 @@ async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataF
ffmpeg
.output(tempFilePath)
- .on('start', function (commandLine) {
+ .on('start', (commandLine) => {
Logger.debug('[ffmpegHelpers] Spawned Ffmpeg with command: ' + commandLine)
})
- .on('end', (stdout, stderr) => {
+ .on('progress', (progress) => {
+ if (!progressCB || !progress.percent) return
+ Logger.debug(`[ffmpegHelpers] Progress: ${progress.percent}%`)
+ progressCB(progress.percent)
+ })
+ .on('end', async (stdout, stderr) => {
Logger.debug('[ffmpegHelpers] ffmpeg stdout:', stdout)
Logger.debug('[ffmpegHelpers] ffmpeg stderr:', stderr)
- fs.copyFileSync(tempFilePath, audioFilePath)
- fs.unlinkSync(tempFilePath)
- resolve(true)
+ Logger.debug('[ffmpegHelpers] Moving temp file to audio file path:', `"${tempFilePath}"`, '->', `"${audioFilePath}"`)
+ try {
+ await copyFunc(tempFilePath, audioFilePath)
+ await fs.remove(tempFilePath)
+ resolve()
+ } catch (error) {
+ Logger.error(`[ffmpegHelpers] Failed to move temp file to audio file path: "${tempFilePath}" -> "${audioFilePath}"`, error)
+ reject(error)
+ }
})
.on('error', (err, stdout, stderr) => {
- Logger.error('Error adding cover image and metadata:', err)
- Logger.error('ffmpeg stdout:', stdout)
- Logger.error('ffmpeg stderr:', stderr)
- resolve(false)
+ if (err.message && err.message.includes('SIGKILL')) {
+ Logger.info(`[ffmpegHelpers] addCoverAndMetadataToFile Killed by User`)
+ reject(new Error('FFMPEG_CANCELED'))
+ } else {
+ Logger.error('Error adding cover image and metadata:', err)
+ Logger.error('ffmpeg stdout:', stdout)
+ Logger.error('ffmpeg stderr:', stderr)
+ reject(err)
+ }
})
ffmpeg.run()
@@ -366,3 +387,92 @@ function getFFMetadataObject(libraryItem, audioFilesLength) {
}
module.exports.getFFMetadataObject = getFFMetadataObject
+
+/**
+ * Merges audio files into a single output file using FFmpeg.
+ *
+ * @param {Array} audioTracks - The audio tracks to merge.
+ * @param {number} duration - The total duration of the audio tracks.
+ * @param {string} itemCachePath - The path to the item cache.
+ * @param {string} outputFilePath - The path to the output file.
+ * @param {import('../managers/AbMergeManager').AbMergeEncodeOptions} encodingOptions - The options for encoding the audio.
+ * @param {Function} [progressCB=null] - The callback function to track the progress of the merge.
+ * @param {import('../libs/fluentFfmpeg/index').FfmpegCommand} [ffmpeg=Ffmpeg()] - The FFmpeg instance to use for merging.
+ * @returns {Promise} A promise that resolves when the audio files are merged successfully.
+ */
+async function mergeAudioFiles(audioTracks, duration, itemCachePath, outputFilePath, encodingOptions, progressCB = null, ffmpeg = Ffmpeg()) {
+ const audioBitrate = encodingOptions.bitrate || '128k'
+ const audioCodec = encodingOptions.codec || 'aac'
+ const audioChannels = encodingOptions.channels || 2
+
+ // TODO: Updated in 2.2.11 to always encode even if merging multiple m4b. This is because just using the file extension as was being done before is not enough. This can be an option or do more to check if a concat is possible.
+ // const audioRequiresEncode = audioTracks[0].metadata.ext !== '.m4b'
+ const audioRequiresEncode = true
+
+ const firstTrackIsM4b = audioTracks[0].metadata.ext.toLowerCase() === '.m4b'
+ const isOneTrack = audioTracks.length === 1
+
+ let concatFilePath = null
+ if (!isOneTrack) {
+ concatFilePath = Path.join(itemCachePath, 'files.txt')
+ if ((await writeConcatFile(audioTracks, concatFilePath)) == null) {
+ throw new Error('Failed to write concat file')
+ }
+ ffmpeg.input(concatFilePath).inputOptions(['-safe 0', '-f concat'])
+ } else {
+ ffmpeg.input(audioTracks[0].metadata.path).inputOptions(firstTrackIsM4b ? ['-f mp4'] : [])
+ }
+
+ //const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
+ ffmpeg.outputOptions(['-f mp4'])
+
+ if (audioRequiresEncode) {
+ ffmpeg.outputOptions(['-map 0:a', `-acodec ${audioCodec}`, `-ac ${audioChannels}`, `-b:a ${audioBitrate}`])
+ } else {
+ ffmpeg.outputOptions(['-max_muxing_queue_size 1000'])
+
+ if (isOneTrack && firstTrackIsM4b) {
+ ffmpeg.outputOptions(['-c copy'])
+ } else {
+ ffmpeg.outputOptions(['-c:a copy'])
+ }
+ }
+
+ ffmpeg.output(outputFilePath)
+
+ return new Promise((resolve, reject) => {
+ ffmpeg
+ .on('start', (cmd) => {
+ Logger.debug(`[ffmpegHelpers] Merge Audio Files ffmpeg command: ${cmd}`)
+ })
+ .on('progress', (progress) => {
+ if (!progressCB || !progress.timemark || !duration) return
+ // Cannot rely on progress.percent as it is not accurate for concat
+ const percent = (ffmpgegUtils.timemarkToSeconds(progress.timemark) / duration) * 100
+ progressCB(percent)
+ })
+ .on('end', async (stdout, stderr) => {
+ if (concatFilePath) await fs.remove(concatFilePath)
+ Logger.debug('[ffmpegHelpers] ffmpeg stdout:', stdout)
+ Logger.debug('[ffmpegHelpers] ffmpeg stderr:', stderr)
+ Logger.debug(`[ffmpegHelpers] Audio Files Merged Successfully`)
+ resolve()
+ })
+ .on('error', async (err, stdout, stderr) => {
+ if (concatFilePath) await fs.remove(concatFilePath)
+ if (err.message && err.message.includes('SIGKILL')) {
+ Logger.info(`[ffmpegHelpers] Merge Audio Files Killed by User`)
+ reject(new Error('FFMPEG_CANCELED'))
+ } else {
+ Logger.error(`[ffmpegHelpers] Merge Audio Files Error ${err}`)
+ Logger.error('ffmpeg stdout:', stdout)
+ Logger.error('ffmpeg stderr:', stderr)
+ reject(err)
+ }
+ })
+
+ ffmpeg.run()
+ })
+}
+
+module.exports.mergeAudioFiles = mergeAudioFiles
diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js
index 5488b4d449..e4bb53a009 100644
--- a/server/utils/fileUtils.js
+++ b/server/utils/fileUtils.js
@@ -15,7 +15,7 @@ const { AudioMimeType } = require('./constants')
*/
const filePathToPOSIX = (path) => {
if (!global.isWin || !path) return path
- return path.replace(/\\/g, '/')
+ return path.startsWith('\\\\') ? '\\\\' + path.slice(2).replace(/\\/g, '/') : path.replace(/\\/g, '/')
}
module.exports.filePathToPOSIX = filePathToPOSIX
@@ -169,7 +169,7 @@ async function recurseFiles(path, relPathToReplace = null) {
extensions: true,
deep: true,
realPath: true,
- normalizePath: true
+ normalizePath: false
}
let list = await rra.list(path, options)
if (list.error) {
@@ -186,6 +186,8 @@ async function recurseFiles(path, relPathToReplace = null) {
return false
}
+ item.fullname = filePathToPOSIX(item.fullname)
+ item.path = filePathToPOSIX(item.path)
const relpath = item.fullname.replace(relPathToReplace, '')
let reldirname = Path.dirname(relpath)
if (reldirname === '.') reldirname = ''
@@ -464,3 +466,46 @@ module.exports.getDirectoriesInPath = async (dirPath, level) => {
return []
}
}
+
+/**
+ * Copies a file from the source path to an existing destination path, preserving the destination's permissions.
+ *
+ * @param {string} srcPath - The path of the source file.
+ * @param {string} destPath - The path of the existing destination file.
+ * @returns {Promise} A promise that resolves when the file has been successfully copied.
+ * @throws {Error} If there is an error reading the source file or writing the destination file.
+ */
+async function copyToExisting(srcPath, destPath) {
+ return new Promise((resolve, reject) => {
+ // Create a readable stream from the source file
+ const readStream = fs.createReadStream(srcPath)
+
+ // Create a writable stream to the destination file
+ const writeStream = fs.createWriteStream(destPath, { flags: 'w' })
+
+ // Pipe the read stream to the write stream
+ readStream.pipe(writeStream)
+
+ // Handle the end of the stream
+ writeStream.on('finish', () => {
+ Logger.debug(`[copyToExisting] Successfully copied file from ${srcPath} to ${destPath}`)
+ resolve()
+ })
+
+ // Handle errors
+ readStream.on('error', (error) => {
+ Logger.error(`[copyToExisting] Error reading from source file ${srcPath}: ${error.message}`)
+ readStream.close()
+ writeStream.close()
+ reject(error)
+ })
+
+ writeStream.on('error', (error) => {
+ Logger.error(`[copyToExisting] Error writing to destination file ${destPath}: ${error.message}`)
+ readStream.close()
+ writeStream.close()
+ reject(error)
+ })
+ })
+}
+module.exports.copyToExisting = copyToExisting
diff --git a/server/utils/index.js b/server/utils/index.js
index 14f297c13d..2d52bcd084 100644
--- a/server/utils/index.js
+++ b/server/utils/index.js
@@ -66,6 +66,11 @@ module.exports.getId = (prepend = '') => {
return _id
}
+/**
+ *
+ * @param {number} seconds
+ * @returns {string}
+ */
function elapsedPretty(seconds) {
if (seconds > 0 && seconds < 1) {
return `${Math.floor(seconds * 1000)} ms`
@@ -73,16 +78,27 @@ function elapsedPretty(seconds) {
if (seconds < 60) {
return `${Math.floor(seconds)} sec`
}
- var minutes = Math.floor(seconds / 60)
+ let minutes = Math.floor(seconds / 60)
if (minutes < 70) {
return `${minutes} min`
}
- var hours = Math.floor(minutes / 60)
+ let hours = Math.floor(minutes / 60)
minutes -= hours * 60
- if (!minutes) {
- return `${hours} hr`
+
+ let days = Math.floor(hours / 24)
+ hours -= days * 24
+
+ const timeParts = []
+ if (days) {
+ timeParts.push(`${days} d`)
+ }
+ if (hours || (days && minutes)) {
+ timeParts.push(`${hours} hr`)
+ }
+ if (minutes) {
+ timeParts.push(`${minutes} min`)
}
- return `${hours} hr ${minutes} min`
+ return timeParts.join(' ')
}
module.exports.elapsedPretty = elapsedPretty
diff --git a/server/utils/libraryHelpers.js b/server/utils/libraryHelpers.js
index 8517660d01..ad71ee3fad 100644
--- a/server/utils/libraryHelpers.js
+++ b/server/utils/libraryHelpers.js
@@ -11,7 +11,7 @@ module.exports = {
const seriesToFilterOut = {}
books.forEach((libraryItem) => {
// get all book series for item that is not already filtered out
- const bookSeries = (libraryItem.media.metadata.series || []).filter(se => !seriesToFilterOut[se.id])
+ const bookSeries = (libraryItem.media.metadata.series || []).filter((se) => !seriesToFilterOut[se.id])
if (!bookSeries.length) return
bookSeries.forEach((bookSeriesObj) => {
@@ -43,11 +43,11 @@ module.exports = {
// Library setting to hide series with only 1 book
if (hideSingleBookSeries) {
- seriesItems = seriesItems.filter(se => se.books.length > 1)
+ seriesItems = seriesItems.filter((se) => se.books.length > 1)
}
return seriesItems.map((series) => {
- series.books = naturalSort(series.books).asc(li => li.sequence)
+ series.books = naturalSort(series.books).asc((li) => li.sequence)
return series
})
},
@@ -55,9 +55,7 @@ module.exports = {
collapseBookSeries(libraryItems, filterSeries, hideSingleBookSeries) {
// Get series from the library items. If this list is being collapsed after filtering for a series,
// don't collapse that series, only books that are in other series.
- const seriesObjects = this
- .getSeriesFromBooks(libraryItems, filterSeries, hideSingleBookSeries)
- .filter(s => s.id != filterSeries)
+ const seriesObjects = this.getSeriesFromBooks(libraryItems, filterSeries, hideSingleBookSeries).filter((s) => s.id != filterSeries)
const filteredLibraryItems = []
@@ -65,22 +63,29 @@ module.exports = {
if (li.mediaType != 'book') return
// Handle when this is the first book in a series
- seriesObjects.filter(s => s.books[0].id == li.id).forEach(series => {
- // Clone the library item as we need to attach data to it, but don't
- // want to change the global copy of the library item
- filteredLibraryItems.push(Object.assign(
- Object.create(Object.getPrototypeOf(li)),
- li, { collapsedSeries: series }))
- })
+ seriesObjects
+ .filter((s) => s.books[0].id == li.id)
+ .forEach((series) => {
+ // Clone the library item as we need to attach data to it, but don't
+ // want to change the global copy of the library item
+ filteredLibraryItems.push(Object.assign(Object.create(Object.getPrototypeOf(li)), li, { collapsedSeries: series }))
+ })
// Only included books not contained in series
- if (!seriesObjects.some(s => s.books.some(b => b.id == li.id)))
- filteredLibraryItems.push(li)
+ if (!seriesObjects.some((s) => s.books.some((b) => b.id == li.id))) filteredLibraryItems.push(li)
})
return filteredLibraryItems
},
+ /**
+ *
+ * @param {*} payload
+ * @param {string} seriesId
+ * @param {import('../models/User')} user
+ * @param {import('../objects/Library')} library
+ * @returns {Object[]}
+ */
async handleCollapseSubseries(payload, seriesId, user, library) {
const seriesWithBooks = await Database.seriesModel.findByPk(seriesId, {
include: {
@@ -112,17 +117,18 @@ module.exports = {
return []
}
-
const books = seriesWithBooks.books
payload.total = books.length
- let libraryItems = books.map((book) => {
- const libraryItem = book.libraryItem
- libraryItem.media = book
- return Database.libraryItemModel.getOldLibraryItem(libraryItem)
- }).filter(li => {
- return user.checkCanAccessLibraryItem(li)
- })
+ let libraryItems = books
+ .map((book) => {
+ const libraryItem = book.libraryItem
+ libraryItem.media = book
+ return Database.libraryItemModel.getOldLibraryItem(libraryItem)
+ })
+ .filter((li) => {
+ return user.checkCanAccessLibraryItem(li)
+ })
const collapsedItems = this.collapseBookSeries(libraryItems, seriesId, library.settings.hideSingleBookSeries)
if (!(collapsedItems.length == 1 && collapsedItems[0].collapsedSeries)) {
@@ -139,7 +145,8 @@ module.exports = {
{
[direction]: (li) => li.media.metadata.getSeries(seriesId).sequence
},
- { // If no series sequence then fallback to sorting by title (or collapsed series name for sub-series)
+ {
+ // If no series sequence then fallback to sorting by title (or collapsed series name for sub-series)
[direction]: (li) => {
if (sortingIgnorePrefix) {
return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix
@@ -150,7 +157,7 @@ module.exports = {
}
]
} else {
- // If series are collapsed and not sorting by title or sequence,
+ // If series are collapsed and not sorting by title or sequence,
// sort all collapsed series to the end in alphabetical order
if (payload.sortBy !== 'media.metadata.title') {
sortArray.push({
@@ -185,47 +192,48 @@ module.exports = {
libraryItems = libraryItems.slice(startIndex, startIndex + payload.limit)
}
- return Promise.all(libraryItems.map(async li => {
- const filteredSeries = li.media.metadata.getSeries(seriesId)
- const json = li.toJSONMinified()
- json.media.metadata.series = {
- id: filteredSeries.id,
- name: filteredSeries.name,
- sequence: filteredSeries.sequence
- }
-
- if (li.collapsedSeries) {
- json.collapsedSeries = {
- id: li.collapsedSeries.id,
- name: li.collapsedSeries.name,
- nameIgnorePrefix: li.collapsedSeries.nameIgnorePrefix,
- libraryItemIds: li.collapsedSeries.books.map(b => b.id),
- numBooks: li.collapsedSeries.books.length
+ return Promise.all(
+ libraryItems.map(async (li) => {
+ const filteredSeries = li.media.metadata.getSeries(seriesId)
+ const json = li.toJSONMinified()
+ json.media.metadata.series = {
+ id: filteredSeries.id,
+ name: filteredSeries.name,
+ sequence: filteredSeries.sequence
}
- // If collapsing by series and filtering by a series, generate the list of sequences the collapsed
- // series represents in the filtered series
- json.collapsedSeries.seriesSequenceList =
- naturalSort(li.collapsedSeries.books.filter(b => b.filterSeriesSequence).map(b => b.filterSeriesSequence)).asc()
+ if (li.collapsedSeries) {
+ json.collapsedSeries = {
+ id: li.collapsedSeries.id,
+ name: li.collapsedSeries.name,
+ nameIgnorePrefix: li.collapsedSeries.nameIgnorePrefix,
+ libraryItemIds: li.collapsedSeries.books.map((b) => b.id),
+ numBooks: li.collapsedSeries.books.length
+ }
+
+ // If collapsing by series and filtering by a series, generate the list of sequences the collapsed
+ // series represents in the filtered series
+ json.collapsedSeries.seriesSequenceList = naturalSort(li.collapsedSeries.books.filter((b) => b.filterSeriesSequence).map((b) => b.filterSeriesSequence))
+ .asc()
.reduce((ranges, currentSequence) => {
let lastRange = ranges.at(-1)
let isNumber = /^(\d+|\d+\.\d*|\d*\.\d+)$/.test(currentSequence)
if (isNumber) currentSequence = parseFloat(currentSequence)
- if (lastRange && isNumber && lastRange.isNumber && ((lastRange.end + 1) == currentSequence)) {
+ if (lastRange && isNumber && lastRange.isNumber && lastRange.end + 1 == currentSequence) {
lastRange.end = currentSequence
- }
- else {
+ } else {
ranges.push({ start: currentSequence, end: currentSequence, isNumber: isNumber })
}
return ranges
}, [])
- .map(r => r.start == r.end ? r.start : `${r.start}-${r.end}`)
+ .map((r) => (r.start == r.end ? r.start : `${r.start}-${r.end}`))
.join(', ')
- }
+ }
- return json
- }))
+ return json
+ })
+ )
}
}
diff --git a/server/utils/longTimeout.js b/server/utils/longTimeout.js
new file mode 100644
index 0000000000..6ea05a657c
--- /dev/null
+++ b/server/utils/longTimeout.js
@@ -0,0 +1,36 @@
+/**
+ * Handle timeouts greater than 32-bit signed integer
+ */
+class LongTimeout {
+ constructor() {
+ this.timeout = 0
+ this.timer = null
+ }
+
+ clear() {
+ clearTimeout(this.timer)
+ }
+
+ /**
+ *
+ * @param {Function} fn
+ * @param {number} timeout
+ */
+ set(fn, timeout) {
+ const maxValue = 2147483647
+
+ const handleTimeout = () => {
+ if (this.timeout > 0) {
+ let delay = Math.min(this.timeout, maxValue)
+ this.timeout = this.timeout - delay
+ this.timer = setTimeout(handleTimeout, delay)
+ return
+ }
+ fn()
+ }
+
+ this.timeout = timeout
+ handleTimeout()
+ }
+}
+module.exports = LongTimeout
diff --git a/server/utils/migrations/dbMigration.js b/server/utils/migrations/dbMigration.js
index 3d38cca6ad..85631783ce 100644
--- a/server/utils/migrations/dbMigration.js
+++ b/server/utils/migrations/dbMigration.js
@@ -1,6 +1,6 @@
const { DataTypes, QueryInterface } = require('sequelize')
const Path = require('path')
-const uuidv4 = require("uuid").v4
+const uuidv4 = require('uuid').v4
const Logger = require('../../Logger')
const fs = require('../../libs/fsExtra')
const oldDbFiles = require('./oldDbFiles')
@@ -36,25 +36,14 @@ function getDeviceInfoString(deviceInfo, UserId) {
if (!deviceInfo) return null
if (deviceInfo.deviceId) return deviceInfo.deviceId
- const keys = [
- UserId,
- deviceInfo.browserName || null,
- deviceInfo.browserVersion || null,
- deviceInfo.osName || null,
- deviceInfo.osVersion || null,
- deviceInfo.clientVersion || null,
- deviceInfo.manufacturer || null,
- deviceInfo.model || null,
- deviceInfo.sdkVersion || null,
- deviceInfo.ipAddress || null
- ].map(k => k || '')
+ const keys = [UserId, deviceInfo.browserName || null, deviceInfo.browserVersion || null, deviceInfo.osName || null, deviceInfo.osVersion || null, deviceInfo.clientVersion || null, deviceInfo.manufacturer || null, deviceInfo.model || null, deviceInfo.sdkVersion || null, deviceInfo.ipAddress || null].map((k) => k || '')
return 'temp-' + Buffer.from(keys.join('-'), 'utf-8').toString('base64')
}
/**
* Migrate oldLibraryItem.media to Book model
* Migrate BookSeries and BookAuthor
- * @param {objects.LibraryItem} oldLibraryItem
+ * @param {objects.LibraryItem} oldLibraryItem
* @param {object} LibraryItem models.LibraryItem object
* @returns {object} { book: object, bookSeries: [], bookAuthor: [] }
*/
@@ -67,7 +56,7 @@ function migrateBook(oldLibraryItem, LibraryItem) {
bookAuthor: []
}
- const tracks = (oldBook.audioFiles || []).filter(af => !af.exclude && !af.invalid)
+ const tracks = (oldBook.audioFiles || []).filter((af) => !af.exclude && !af.invalid)
let duration = 0
for (const track of tracks) {
if (track.duration !== null && !isNaN(track.duration)) {
@@ -156,7 +145,7 @@ function migrateBook(oldLibraryItem, LibraryItem) {
/**
* Migrate oldLibraryItem.media to Podcast model
* Migrate PodcastEpisode
- * @param {objects.LibraryItem} oldLibraryItem
+ * @param {objects.LibraryItem} oldLibraryItem
* @param {object} LibraryItem models.LibraryItem object
* @returns {object} { podcast: object, podcastEpisode: [] }
*/
@@ -239,7 +228,7 @@ function migratePodcast(oldLibraryItem, LibraryItem) {
/**
* Migrate libraryItems to LibraryItem, Book, Podcast models
- * @param {Array} oldLibraryItems
+ * @param {Array} oldLibraryItems
* @returns {object} { libraryItem: [], book: [], podcast: [], podcastEpisode: [], bookSeries: [], bookAuthor: [] }
*/
function migrateLibraryItems(oldLibraryItems) {
@@ -298,7 +287,7 @@ function migrateLibraryItems(oldLibraryItems) {
updatedAt: oldLibraryItem.updatedAt,
libraryId,
libraryFolderId,
- libraryFiles: oldLibraryItem.libraryFiles.map(lf => {
+ libraryFiles: oldLibraryItem.libraryFiles.map((lf) => {
if (lf.isSupplementary === undefined) lf.isSupplementary = null
return lf
})
@@ -306,7 +295,7 @@ function migrateLibraryItems(oldLibraryItems) {
oldDbIdMap.libraryItems[oldLibraryItem.id] = LibraryItem.id
_newRecords.libraryItem.push(LibraryItem)
- //
+ //
// Migrate Book/Podcast
//
if (oldLibraryItem.mediaType === 'book') {
@@ -329,7 +318,7 @@ function migrateLibraryItems(oldLibraryItems) {
/**
* Migrate Library and LibraryFolder
- * @param {Array} oldLibraries
+ * @param {Array} oldLibraries
* @returns {object} { library: [], libraryFolder: [] }
*/
function migrateLibraries(oldLibraries) {
@@ -343,7 +332,7 @@ function migrateLibraries(oldLibraries) {
continue
}
- //
+ //
// Migrate Library
//
const Library = {
@@ -361,7 +350,7 @@ function migrateLibraries(oldLibraries) {
oldDbIdMap.libraries[oldLibrary.id] = Library.id
_newRecords.library.push(Library)
- //
+ //
// Migrate LibraryFolders
//
for (const oldFolder of oldLibrary.folders) {
@@ -382,21 +371,27 @@ function migrateLibraries(oldLibraries) {
/**
* Migrate Author
* Previously Authors were shared between libraries, this will ensure every author has one library
- * @param {Array} oldAuthors
- * @param {Array} oldLibraryItems
+ * @param {Array} oldAuthors
+ * @param {Array} oldLibraryItems
* @returns {Array} Array of Author model objs
*/
function migrateAuthors(oldAuthors, oldLibraryItems) {
const _newRecords = []
for (const oldAuthor of oldAuthors) {
// Get an array of NEW library ids that have this author
- const librariesWithThisAuthor = [...new Set(oldLibraryItems.map(li => {
- if (!li.media.metadata.authors?.some(au => au.id === oldAuthor.id)) return null
- if (!oldDbIdMap.libraries[li.libraryId]) {
- Logger.warn(`[dbMigration] Authors library id ${li.libraryId} was not migrated`)
- }
- return oldDbIdMap.libraries[li.libraryId]
- }).filter(lid => lid))]
+ const librariesWithThisAuthor = [
+ ...new Set(
+ oldLibraryItems
+ .map((li) => {
+ if (!li.media.metadata.authors?.some((au) => au.id === oldAuthor.id)) return null
+ if (!oldDbIdMap.libraries[li.libraryId]) {
+ Logger.warn(`[dbMigration] Authors library id ${li.libraryId} was not migrated`)
+ }
+ return oldDbIdMap.libraries[li.libraryId]
+ })
+ .filter((lid) => lid)
+ )
+ ]
if (!librariesWithThisAuthor.length) {
Logger.error(`[dbMigration] Author ${oldAuthor.name} was not found in any libraries`)
@@ -426,8 +421,8 @@ function migrateAuthors(oldAuthors, oldLibraryItems) {
/**
* Migrate Series
* Previously Series were shared between libraries, this will ensure every series has one library
- * @param {Array} oldSerieses
- * @param {Array} oldLibraryItems
+ * @param {Array} oldSerieses
+ * @param {Array} oldLibraryItems
* @returns {Array} Array of Series model objs
*/
function migrateSeries(oldSerieses, oldLibraryItems) {
@@ -436,10 +431,16 @@ function migrateSeries(oldSerieses, oldLibraryItems) {
// Series will be separate between libraries
for (const oldSeries of oldSerieses) {
// Get an array of NEW library ids that have this series
- const librariesWithThisSeries = [...new Set(oldLibraryItems.map(li => {
- if (!li.media.metadata.series?.some(se => se.id === oldSeries.id)) return null
- return oldDbIdMap.libraries[li.libraryId]
- }).filter(lid => lid))]
+ const librariesWithThisSeries = [
+ ...new Set(
+ oldLibraryItems
+ .map((li) => {
+ if (!li.media.metadata.series?.some((se) => se.id === oldSeries.id)) return null
+ return oldDbIdMap.libraries[li.libraryId]
+ })
+ .filter((lid) => lid)
+ )
+ ]
if (!librariesWithThisSeries.length) {
Logger.error(`[dbMigration] Series ${oldSeries.name} was not found in any libraries`)
@@ -465,7 +466,7 @@ function migrateSeries(oldSerieses, oldLibraryItems) {
/**
* Migrate users to User and MediaProgress models
- * @param {Array} oldUsers
+ * @param {Array} oldUsers
* @returns {object} { user: [], mediaProgress: [] }
*/
function migrateUsers(oldUsers) {
@@ -474,29 +475,33 @@ function migrateUsers(oldUsers) {
mediaProgress: []
}
for (const oldUser of oldUsers) {
- //
+ //
// Migrate User
//
// Convert old library ids to new ids
- const librariesAccessible = (oldUser.librariesAccessible || []).map((lid) => oldDbIdMap.libraries[lid]).filter(li => li)
+ const librariesAccessible = (oldUser.librariesAccessible || []).map((lid) => oldDbIdMap.libraries[lid]).filter((li) => li)
// Convert old library item ids to new ids
- const bookmarks = (oldUser.bookmarks || []).map(bm => {
- bm.libraryItemId = oldDbIdMap.libraryItems[bm.libraryItemId]
- return bm
- }).filter(bm => bm.libraryItemId)
+ const bookmarks = (oldUser.bookmarks || [])
+ .map((bm) => {
+ bm.libraryItemId = oldDbIdMap.libraryItems[bm.libraryItemId]
+ return bm
+ })
+ .filter((bm) => bm.libraryItemId)
// Convert old series ids to new
- const seriesHideFromContinueListening = (oldUser.seriesHideFromContinueListening || []).map(oldSeriesId => {
- // Series were split to be per library
- // This will use the first series it finds
- for (const libraryId in oldDbIdMap.series) {
- if (oldDbIdMap.series[libraryId][oldSeriesId]) {
- return oldDbIdMap.series[libraryId][oldSeriesId]
+ const seriesHideFromContinueListening = (oldUser.seriesHideFromContinueListening || [])
+ .map((oldSeriesId) => {
+ // Series were split to be per library
+ // This will use the first series it finds
+ for (const libraryId in oldDbIdMap.series) {
+ if (oldDbIdMap.series[libraryId][oldSeriesId]) {
+ return oldDbIdMap.series[libraryId][oldSeriesId]
+ }
}
- }
- return null
- }).filter(se => se)
+ return null
+ })
+ .filter((se) => se)
const User = {
id: uuidv4(),
@@ -521,7 +526,7 @@ function migrateUsers(oldUsers) {
oldDbIdMap.users[oldUser.id] = User.id
_newRecords.user.push(User)
- //
+ //
// Migrate MediaProgress
//
for (const oldMediaProgress of oldUser.mediaProgress) {
@@ -566,7 +571,7 @@ function migrateUsers(oldUsers) {
/**
* Migrate playbackSessions to PlaybackSession and Device models
- * @param {Array} oldSessions
+ * @param {Array} oldSessions
* @returns {object} { playbackSession: [], device: [] }
*/
function migrateSessions(oldSessions) {
@@ -690,7 +695,7 @@ function migrateSessions(oldSessions) {
/**
* Migrate collections to Collection & CollectionBook
- * @param {Array} oldCollections
+ * @param {Array} oldCollections
* @returns {object} { collection: [], collectionBook: [] }
*/
function migrateCollections(oldCollections) {
@@ -705,7 +710,7 @@ function migrateCollections(oldCollections) {
continue
}
- const BookIds = oldCollection.books.map(lid => oldDbIdMap.books[lid]).filter(bid => bid)
+ const BookIds = oldCollection.books.map((lid) => oldDbIdMap.books[lid]).filter((bid) => bid)
if (!BookIds.length) {
Logger.warn(`[dbMigration] migrateCollections: Collection "${oldCollection.name}" has no books`)
continue
@@ -739,7 +744,7 @@ function migrateCollections(oldCollections) {
/**
* Migrate playlists to Playlist and PlaylistMediaItem
- * @param {Array} oldPlaylists
+ * @param {Array} oldPlaylists
* @returns {object} { playlist: [], playlistMediaItem: [] }
*/
function migratePlaylists(oldPlaylists) {
@@ -806,7 +811,7 @@ function migratePlaylists(oldPlaylists) {
/**
* Migrate feeds to Feed and FeedEpisode models
- * @param {Array} oldFeeds
+ * @param {Array} oldFeeds
* @returns {object} { feed: [], feedEpisode: [] }
*/
function migrateFeeds(oldFeeds) {
@@ -907,14 +912,14 @@ function migrateFeeds(oldFeeds) {
/**
* Migrate ServerSettings, NotificationSettings and EmailSettings to Setting model
- * @param {Array} oldSettings
+ * @param {Array} oldSettings
* @returns {Array} Array of Setting model objs
*/
function migrateSettings(oldSettings) {
const _newRecords = []
- const serverSettings = oldSettings.find(s => s.id === 'server-settings')
- const notificationSettings = oldSettings.find(s => s.id === 'notification-settings')
- const emailSettings = oldSettings.find(s => s.id === 'email-settings')
+ const serverSettings = oldSettings.find((s) => s.id === 'server-settings')
+ const notificationSettings = oldSettings.find((s) => s.id === 'notification-settings')
+ const emailSettings = oldSettings.find((s) => s.id === 'email-settings')
if (serverSettings) {
_newRecords.push({
@@ -946,7 +951,7 @@ function migrateSettings(oldSettings) {
/**
* Load old libraries and bulkCreate new Library and LibraryFolder rows
- * @param {Map} DatabaseModels
+ * @param {Map} DatabaseModels
*/
async function handleMigrateLibraries(DatabaseModels) {
const oldLibraries = await oldDbFiles.loadOldData('libraries')
@@ -959,7 +964,7 @@ async function handleMigrateLibraries(DatabaseModels) {
/**
* Load old EmailSettings, NotificationSettings and ServerSettings and bulkCreate new Setting rows
- * @param {Map} DatabaseModels
+ * @param {Map} DatabaseModels
*/
async function handleMigrateSettings(DatabaseModels) {
const oldSettings = await oldDbFiles.loadOldData('settings')
@@ -970,7 +975,7 @@ async function handleMigrateSettings(DatabaseModels) {
/**
* Load old authors and bulkCreate new Author rows
- * @param {Map} DatabaseModels
+ * @param {Map} DatabaseModels
* @param {Array} oldLibraryItems
*/
async function handleMigrateAuthors(DatabaseModels, oldLibraryItems) {
@@ -982,7 +987,7 @@ async function handleMigrateAuthors(DatabaseModels, oldLibraryItems) {
/**
* Load old series and bulkCreate new Series rows
- * @param {Map} DatabaseModels
+ * @param {Map} DatabaseModels
* @param {Array} oldLibraryItems
*/
async function handleMigrateSeries(DatabaseModels, oldLibraryItems) {
@@ -994,7 +999,7 @@ async function handleMigrateSeries(DatabaseModels, oldLibraryItems) {
/**
* bulkCreate new LibraryItem, Book and Podcast rows
- * @param {Map} DatabaseModels
+ * @param {Map} DatabaseModels
* @param {Array} oldLibraryItems
*/
async function handleMigrateLibraryItems(DatabaseModels, oldLibraryItems) {
@@ -1008,7 +1013,7 @@ async function handleMigrateLibraryItems(DatabaseModels, oldLibraryItems) {
/**
* Migrate authors, series then library items in chunks
* Authors and series require old library items loaded first
- * @param {Map} DatabaseModels
+ * @param {Map} DatabaseModels
*/
async function handleMigrateAuthorsSeriesAndLibraryItems(DatabaseModels) {
const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems')
@@ -1026,7 +1031,7 @@ async function handleMigrateAuthorsSeriesAndLibraryItems(DatabaseModels) {
/**
* Load old users and bulkCreate new User rows
- * @param {Map} DatabaseModels
+ * @param {Map} DatabaseModels
*/
async function handleMigrateUsers(DatabaseModels) {
const oldUsers = await oldDbFiles.loadOldData('users')
@@ -1039,7 +1044,7 @@ async function handleMigrateUsers(DatabaseModels) {
/**
* Load old sessions and bulkCreate new PlaybackSession & Device rows
- * @param {Map} DatabaseModels
+ * @param {Map} DatabaseModels
*/
async function handleMigrateSessions(DatabaseModels) {
const oldSessions = await oldDbFiles.loadOldData('sessions')
@@ -1055,12 +1060,11 @@ async function handleMigrateSessions(DatabaseModels) {
await DatabaseModels[model].bulkCreate(newSessionRecords[model])
}
}
-
}
/**
* Load old collections and bulkCreate new Collection, CollectionBook models
- * @param {Map} DatabaseModels
+ * @param {Map} DatabaseModels
*/
async function handleMigrateCollections(DatabaseModels) {
const oldCollections = await oldDbFiles.loadOldData('collections')
@@ -1073,7 +1077,7 @@ async function handleMigrateCollections(DatabaseModels) {
/**
* Load old playlists and bulkCreate new Playlist, PlaylistMediaItem models
- * @param {Map} DatabaseModels
+ * @param {Map} DatabaseModels
*/
async function handleMigratePlaylists(DatabaseModels) {
const oldPlaylists = await oldDbFiles.loadOldData('playlists')
@@ -1086,7 +1090,7 @@ async function handleMigratePlaylists(DatabaseModels) {
/**
* Load old feeds and bulkCreate new Feed, FeedEpisode models
- * @param {Map} DatabaseModels
+ * @param {Map} DatabaseModels
*/
async function handleMigrateFeeds(DatabaseModels) {
const oldFeeds = await oldDbFiles.loadOldData('feeds')
@@ -1152,21 +1156,36 @@ module.exports.checkShouldMigrate = async () => {
/**
* Migration from 2.3.0 to 2.3.1 - create extraData columns in LibraryItem and PodcastEpisode
- * @param {QueryInterface} queryInterface
+ * @param {QueryInterface} queryInterface
*/
async function migrationPatchNewColumns(queryInterface) {
try {
- return queryInterface.sequelize.transaction(t => {
+ return queryInterface.sequelize.transaction((t) => {
return Promise.all([
- queryInterface.addColumn('libraryItems', 'extraData', {
- type: DataTypes.JSON
- }, { transaction: t }),
- queryInterface.addColumn('podcastEpisodes', 'extraData', {
- type: DataTypes.JSON
- }, { transaction: t }),
- queryInterface.addColumn('libraries', 'extraData', {
- type: DataTypes.JSON
- }, { transaction: t })
+ queryInterface.addColumn(
+ 'libraryItems',
+ 'extraData',
+ {
+ type: DataTypes.JSON
+ },
+ { transaction: t }
+ ),
+ queryInterface.addColumn(
+ 'podcastEpisodes',
+ 'extraData',
+ {
+ type: DataTypes.JSON
+ },
+ { transaction: t }
+ ),
+ queryInterface.addColumn(
+ 'libraries',
+ 'extraData',
+ {
+ type: DataTypes.JSON
+ },
+ { transaction: t }
+ )
])
})
} catch (error) {
@@ -1177,7 +1196,7 @@ async function migrationPatchNewColumns(queryInterface) {
/**
* Migration from 2.3.0 to 2.3.1 - old library item ids
- * @param {/src/Database} ctx
+ * @param {/src/Database} ctx
*/
async function handleOldLibraryItems(ctx) {
const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems')
@@ -1188,7 +1207,7 @@ async function handleOldLibraryItems(ctx) {
for (const libraryItem of libraryItems) {
// Find matching old library item by ino
- const matchingOldLibraryItem = oldLibraryItems.find(oli => oli.ino === libraryItem.ino)
+ const matchingOldLibraryItem = oldLibraryItems.find((oli) => oli.ino === libraryItem.ino)
if (matchingOldLibraryItem) {
oldDbIdMap.libraryItems[matchingOldLibraryItem.id] = libraryItem.id
@@ -1202,7 +1221,7 @@ async function handleOldLibraryItems(ctx) {
if (libraryItem.media.episodes?.length && matchingOldLibraryItem.media.episodes?.length) {
for (const podcastEpisode of libraryItem.media.episodes) {
// Find matching old episode by audio file ino
- const matchingOldPodcastEpisode = matchingOldLibraryItem.media.episodes.find(oep => oep.audioFile?.ino && oep.audioFile.ino === podcastEpisode.audioFile?.ino)
+ const matchingOldPodcastEpisode = matchingOldLibraryItem.media.episodes.find((oep) => oep.audioFile?.ino && oep.audioFile.ino === podcastEpisode.audioFile?.ino)
if (matchingOldPodcastEpisode) {
oldDbIdMap.podcastEpisodes[matchingOldPodcastEpisode.id] = podcastEpisode.id
@@ -1235,7 +1254,7 @@ async function handleOldLibraryItems(ctx) {
/**
* Migration from 2.3.0 to 2.3.1 - updating oldLibraryId
- * @param {/src/Database} ctx
+ * @param {/src/Database} ctx
*/
async function handleOldLibraries(ctx) {
const oldLibraries = await oldDbFiles.loadOldData('libraries')
@@ -1244,11 +1263,11 @@ async function handleOldLibraries(ctx) {
let librariesUpdated = 0
for (const library of libraries) {
// Find matching old library using exact match on folder paths, exact match on library name
- const matchingOldLibrary = oldLibraries.find(ol => {
+ const matchingOldLibrary = oldLibraries.find((ol) => {
if (ol.name !== library.name) {
return false
}
- const folderPaths = ol.folders?.map(f => f.fullPath) || []
+ const folderPaths = ol.folders?.map((f) => f.fullPath) || []
return folderPaths.join(',') === library.folderPaths.join(',')
})
@@ -1264,42 +1283,51 @@ async function handleOldLibraries(ctx) {
/**
* Migration from 2.3.0 to 2.3.1 - fixing librariesAccessible and bookmarks
- * @param {/src/Database} ctx
+ * @param {import('../../Database')} ctx
*/
async function handleOldUsers(ctx) {
- const users = await ctx.models.user.getOldUsers()
+ const usersNew = await ctx.userModel.findAll({
+ include: ctx.models.mediaProgress
+ })
+ const users = usersNew.map((u) => ctx.userModel.getOldUser(u))
let usersUpdated = 0
for (const user of users) {
let hasUpdates = false
if (user.bookmarks?.length) {
- user.bookmarks = user.bookmarks.map(bm => {
- // Only update if this is not the old id format
- if (!bm.libraryItemId.startsWith('li_')) return bm
+ user.bookmarks = user.bookmarks
+ .map((bm) => {
+ // Only update if this is not the old id format
+ if (!bm.libraryItemId.startsWith('li_')) return bm
- bm.libraryItemId = oldDbIdMap.libraryItems[bm.libraryItemId]
- hasUpdates = true
- return bm
- }).filter(bm => bm.libraryItemId)
+ bm.libraryItemId = oldDbIdMap.libraryItems[bm.libraryItemId]
+ hasUpdates = true
+ return bm
+ })
+ .filter((bm) => bm.libraryItemId)
}
// Convert old library ids to new library ids
if (user.librariesAccessible?.length) {
- user.librariesAccessible = user.librariesAccessible.map(lid => {
- if (!lid.startsWith('lib_') && lid !== 'main') return lid // Already not an old library id so dont change
- hasUpdates = true
- return oldDbIdMap.libraries[lid]
- }).filter(lid => lid)
+ user.librariesAccessible = user.librariesAccessible
+ .map((lid) => {
+ if (!lid.startsWith('lib_') && lid !== 'main') return lid // Already not an old library id so dont change
+ hasUpdates = true
+ return oldDbIdMap.libraries[lid]
+ })
+ .filter((lid) => lid)
}
if (user.seriesHideFromContinueListening?.length) {
- user.seriesHideFromContinueListening = user.seriesHideFromContinueListening.map((seriesId) => {
- if (seriesId.startsWith('se_')) {
- hasUpdates = true
- return null // Filter out old series ids
- }
- return seriesId
- }).filter(se => se)
+ user.seriesHideFromContinueListening = user.seriesHideFromContinueListening
+ .map((seriesId) => {
+ if (seriesId.startsWith('se_')) {
+ hasUpdates = true
+ return null // Filter out old series ids
+ }
+ return seriesId
+ })
+ .filter((se) => se)
}
if (hasUpdates) {
@@ -1312,7 +1340,7 @@ async function handleOldUsers(ctx) {
/**
* Migration from 2.3.0 to 2.3.1
- * @param {/src/Database} ctx
+ * @param {/src/Database} ctx
*/
module.exports.migrationPatch = async (ctx) => {
const queryInterface = ctx.sequelize.getQueryInterface()
@@ -1328,7 +1356,7 @@ module.exports.migrationPatch = async (ctx) => {
}
const oldDbPath = Path.join(global.ConfigPath, 'oldDb.zip')
- if (!await fs.pathExists(oldDbPath)) {
+ if (!(await fs.pathExists(oldDbPath))) {
Logger.info(`[dbMigration] Migration patch 2.3.0+ unnecessary - no oldDb.zip found`)
return
}
@@ -1337,7 +1365,7 @@ module.exports.migrationPatch = async (ctx) => {
Logger.info(`[dbMigration] Applying migration patch from 2.3.0+`)
// Extract from oldDb.zip
- if (!await oldDbFiles.checkExtractItemsUsersAndLibraries()) {
+ if (!(await oldDbFiles.checkExtractItemsUsersAndLibraries())) {
return
}
@@ -1354,8 +1382,8 @@ module.exports.migrationPatch = async (ctx) => {
/**
* Migration from 2.3.3 to 2.3.4
* Populating the size column on libraryItem
- * @param {/src/Database} ctx
- * @param {number} offset
+ * @param {/src/Database} ctx
+ * @param {number} offset
*/
async function migrationPatch2LibraryItems(ctx, offset = 0) {
const libraryItems = await ctx.models.libraryItem.findAll({
@@ -1368,7 +1396,7 @@ async function migrationPatch2LibraryItems(ctx, offset = 0) {
for (const libraryItem of libraryItems) {
if (libraryItem.libraryFiles?.length) {
let size = 0
- libraryItem.libraryFiles.forEach(lf => {
+ libraryItem.libraryFiles.forEach((lf) => {
if (!isNaN(lf.metadata?.size)) {
size += Number(lf.metadata.size)
}
@@ -1396,8 +1424,8 @@ async function migrationPatch2LibraryItems(ctx, offset = 0) {
/**
* Migration from 2.3.3 to 2.3.4
* Populating the duration & titleIgnorePrefix column on book
- * @param {/src/Database} ctx
- * @param {number} offset
+ * @param {/src/Database} ctx
+ * @param {number} offset
*/
async function migrationPatch2Books(ctx, offset = 0) {
const books = await ctx.models.book.findAll({
@@ -1411,7 +1439,7 @@ async function migrationPatch2Books(ctx, offset = 0) {
let duration = 0
if (book.audioFiles?.length) {
- const tracks = book.audioFiles.filter(af => !af.exclude && !af.invalid)
+ const tracks = book.audioFiles.filter((af) => !af.exclude && !af.invalid)
for (const track of tracks) {
if (track.duration !== null && !isNaN(track.duration)) {
duration += track.duration
@@ -1442,8 +1470,8 @@ async function migrationPatch2Books(ctx, offset = 0) {
/**
* Migration from 2.3.3 to 2.3.4
* Populating the titleIgnorePrefix column on podcast
- * @param {/src/Database} ctx
- * @param {number} offset
+ * @param {/src/Database} ctx
+ * @param {number} offset
*/
async function migrationPatch2Podcasts(ctx, offset = 0) {
const podcasts = await ctx.models.podcast.findAll({
@@ -1476,8 +1504,8 @@ async function migrationPatch2Podcasts(ctx, offset = 0) {
/**
* Migration from 2.3.3 to 2.3.4
* Populating the nameIgnorePrefix column on series
- * @param {/src/Database} ctx
- * @param {number} offset
+ * @param {/src/Database} ctx
+ * @param {number} offset
*/
async function migrationPatch2Series(ctx, offset = 0) {
const allSeries = await ctx.models.series.findAll({
@@ -1510,8 +1538,8 @@ async function migrationPatch2Series(ctx, offset = 0) {
/**
* Migration from 2.3.3 to 2.3.4
* Populating the lastFirst column on author
- * @param {/src/Database} ctx
- * @param {number} offset
+ * @param {/src/Database} ctx
+ * @param {number} offset
*/
async function migrationPatch2Authors(ctx, offset = 0) {
const authors = await ctx.models.author.findAll({
@@ -1546,8 +1574,8 @@ async function migrationPatch2Authors(ctx, offset = 0) {
/**
* Migration from 2.3.3 to 2.3.4
* Populating the createdAt column on bookAuthor
- * @param {/src/Database} ctx
- * @param {number} offset
+ * @param {/src/Database} ctx
+ * @param {number} offset
*/
async function migrationPatch2BookAuthors(ctx, offset = 0) {
const bookAuthors = await ctx.models.bookAuthor.findAll({
@@ -1581,8 +1609,8 @@ async function migrationPatch2BookAuthors(ctx, offset = 0) {
/**
* Migration from 2.3.3 to 2.3.4
* Populating the createdAt column on bookSeries
- * @param {/src/Database} ctx
- * @param {number} offset
+ * @param {/src/Database} ctx
+ * @param {number} offset
*/
async function migrationPatch2BookSeries(ctx, offset = 0) {
const allBookSeries = await ctx.models.bookSeries.findAll({
@@ -1616,7 +1644,7 @@ async function migrationPatch2BookSeries(ctx, offset = 0) {
/**
* Migration from 2.3.3 to 2.3.4
* Adding coverPath column to Feed model
- * @param {/src/Database} ctx
+ * @param {/src/Database} ctx
*/
module.exports.migrationPatch2 = async (ctx) => {
const queryInterface = ctx.sequelize.getQueryInterface()
@@ -1631,44 +1659,95 @@ module.exports.migrationPatch2 = async (ctx) => {
Logger.info(`[dbMigration] Applying migration patch from 2.3.3+`)
try {
- await queryInterface.sequelize.transaction(t => {
+ await queryInterface.sequelize.transaction((t) => {
const queries = []
if (!bookAuthorsTableDescription?.createdAt) {
- queries.push(...[
- queryInterface.addColumn('bookAuthors', 'createdAt', {
- type: DataTypes.DATE
- }, { transaction: t }),
- queryInterface.addColumn('bookSeries', 'createdAt', {
- type: DataTypes.DATE
- }, { transaction: t }),
- ])
+ queries.push(
+ ...[
+ queryInterface.addColumn(
+ 'bookAuthors',
+ 'createdAt',
+ {
+ type: DataTypes.DATE
+ },
+ { transaction: t }
+ ),
+ queryInterface.addColumn(
+ 'bookSeries',
+ 'createdAt',
+ {
+ type: DataTypes.DATE
+ },
+ { transaction: t }
+ )
+ ]
+ )
}
if (!authorsTableDescription?.lastFirst) {
- queries.push(...[
- queryInterface.addColumn('authors', 'lastFirst', {
- type: DataTypes.STRING
- }, { transaction: t }),
- queryInterface.addColumn('libraryItems', 'size', {
- type: DataTypes.BIGINT
- }, { transaction: t }),
- queryInterface.addColumn('books', 'duration', {
- type: DataTypes.FLOAT
- }, { transaction: t }),
- queryInterface.addColumn('books', 'titleIgnorePrefix', {
- type: DataTypes.STRING
- }, { transaction: t }),
- queryInterface.addColumn('podcasts', 'titleIgnorePrefix', {
- type: DataTypes.STRING
- }, { transaction: t }),
- queryInterface.addColumn('series', 'nameIgnorePrefix', {
- type: DataTypes.STRING
- }, { transaction: t }),
- ])
+ queries.push(
+ ...[
+ queryInterface.addColumn(
+ 'authors',
+ 'lastFirst',
+ {
+ type: DataTypes.STRING
+ },
+ { transaction: t }
+ ),
+ queryInterface.addColumn(
+ 'libraryItems',
+ 'size',
+ {
+ type: DataTypes.BIGINT
+ },
+ { transaction: t }
+ ),
+ queryInterface.addColumn(
+ 'books',
+ 'duration',
+ {
+ type: DataTypes.FLOAT
+ },
+ { transaction: t }
+ ),
+ queryInterface.addColumn(
+ 'books',
+ 'titleIgnorePrefix',
+ {
+ type: DataTypes.STRING
+ },
+ { transaction: t }
+ ),
+ queryInterface.addColumn(
+ 'podcasts',
+ 'titleIgnorePrefix',
+ {
+ type: DataTypes.STRING
+ },
+ { transaction: t }
+ ),
+ queryInterface.addColumn(
+ 'series',
+ 'nameIgnorePrefix',
+ {
+ type: DataTypes.STRING
+ },
+ { transaction: t }
+ )
+ ]
+ )
}
if (!feedTableDescription?.coverPath) {
- queries.push(queryInterface.addColumn('feeds', 'coverPath', {
- type: DataTypes.STRING
- }, { transaction: t }))
+ queries.push(
+ queryInterface.addColumn(
+ 'feeds',
+ 'coverPath',
+ {
+ type: DataTypes.STRING
+ },
+ { transaction: t }
+ )
+ )
}
return Promise.all(queries)
})
@@ -1708,4 +1787,4 @@ module.exports.migrationPatch2 = async (ctx) => {
Logger.error(`[dbMigration] Migration from 2.3.3+ column creation failed`, error)
throw new Error('Migration 2.3.3+ failed ' + error)
}
-}
\ No newline at end of file
+}
diff --git a/server/utils/parsers/parseEpubMetadata.js b/server/utils/parsers/parseEpubMetadata.js
index bee1e8de6a..b0b3724b7e 100644
--- a/server/utils/parsers/parseEpubMetadata.js
+++ b/server/utils/parsers/parseEpubMetadata.js
@@ -4,12 +4,11 @@ const StreamZip = require('../../libs/nodeStreamZip')
const parseOpfMetadata = require('./parseOpfMetadata')
const { xmlToJSON } = require('../index')
-
/**
* Extract file from epub and return string content
- *
- * @param {string} epubPath
- * @param {string} filepath
+ *
+ * @param {string} epubPath
+ * @param {string} filepath
* @returns {Promise}
*/
async function extractFileFromEpub(epubPath, filepath) {
@@ -27,9 +26,9 @@ async function extractFileFromEpub(epubPath, filepath) {
/**
* Extract an XML file from epub and return JSON
- *
- * @param {string} epubPath
- * @param {string} xmlFilepath
+ *
+ * @param {string} epubPath
+ * @param {string} xmlFilepath
* @returns {Promise}
*/
async function extractXmlToJson(epubPath, xmlFilepath) {
@@ -40,19 +39,22 @@ async function extractXmlToJson(epubPath, xmlFilepath) {
/**
* Extract cover image from epub return true if success
- *
- * @param {string} epubPath
- * @param {string} epubImageFilepath
- * @param {string} outputCoverPath
+ *
+ * @param {string} epubPath
+ * @param {string} epubImageFilepath
+ * @param {string} outputCoverPath
* @returns {Promise}
*/
async function extractCoverImage(epubPath, epubImageFilepath, outputCoverPath) {
const zip = new StreamZip.async({ file: epubPath })
- const success = await zip.extract(epubImageFilepath, outputCoverPath).then(() => true).catch((error) => {
- Logger.error(`[parseEpubMetadata] Failed to extract image ${epubImageFilepath} from epub at "${epubPath}"`, error)
- return false
- })
+ const success = await zip
+ .extract(epubImageFilepath, outputCoverPath)
+ .then(() => true)
+ .catch((error) => {
+ Logger.error(`[parseEpubMetadata] Failed to extract image ${epubImageFilepath} from epub at "${epubPath}"`, error)
+ return false
+ })
await zip.close()
@@ -62,8 +64,8 @@ module.exports.extractCoverImage = extractCoverImage
/**
* Parse metadata from epub
- *
- * @param {import('../../models/Book').EBookFileObject} ebookFile
+ *
+ * @param {import('../../models/Book').EBookFileObject} ebookFile
* @returns {Promise}
*/
async function parse(ebookFile) {
@@ -89,7 +91,7 @@ async function parse(ebookFile) {
}
// Parse metadata from package document opf file
- const opfMetadata = parseOpfMetadata.parseOpfMetadataJson(packageJson)
+ const opfMetadata = parseOpfMetadata.parseOpfMetadataJson(structuredClone(packageJson))
if (!opfMetadata) {
Logger.error(`Unable to parse metadata in package doc with json`, JSON.stringify(packageJson, null, 2))
return null
@@ -101,8 +103,23 @@ async function parse(ebookFile) {
metadata: opfMetadata
}
- // Attempt to find filepath to cover image
- const manifestFirstImage = packageJson.package?.manifest?.[0]?.item?.find(item => item.$?.['media-type']?.startsWith('image/'))
+ // Attempt to find filepath to cover image:
+ // Metadata may include where content is the id of the cover image in the manifest
+ // Otherwise the first image in the manifest is used as the cover image
+ let packageMetadata = packageJson.package?.metadata
+ if (Array.isArray(packageMetadata)) {
+ packageMetadata = packageMetadata[0]
+ }
+ const metaCoverId = packageMetadata?.meta?.find?.((meta) => meta.$?.name === 'cover')?.$?.content
+
+ let manifestFirstImage = null
+ if (metaCoverId) {
+ manifestFirstImage = packageJson.package?.manifest?.[0]?.item?.find((item) => item.$?.id === metaCoverId)
+ }
+ if (!manifestFirstImage) {
+ manifestFirstImage = packageJson.package?.manifest?.[0]?.item?.find((item) => item.$?.['media-type']?.startsWith('image/'))
+ }
+
let coverImagePath = manifestFirstImage?.$?.href
if (coverImagePath) {
const packageDirname = Path.dirname(packageDocPath)
@@ -113,4 +130,4 @@ async function parse(ebookFile) {
return payload
}
-module.exports.parse = parse
\ No newline at end of file
+module.exports.parse = parse
diff --git a/server/utils/parsers/parseNfoMetadata.js b/server/utils/parsers/parseNfoMetadata.js
index 56e9400a71..6682a00783 100644
--- a/server/utils/parsers/parseNfoMetadata.js
+++ b/server/utils/parsers/parseNfoMetadata.js
@@ -81,6 +81,10 @@ function parseNfoMetadata(nfoText) {
case 'isbn-13':
metadata.isbn = value
break
+ case 'language':
+ case 'lang':
+ metadata.language = value
+ break
}
}
})
diff --git a/server/utils/parsers/parseOPML.js b/server/utils/parsers/parseOPML.js
index b109a4e979..a82ec33ec5 100644
--- a/server/utils/parsers/parseOPML.js
+++ b/server/utils/parsers/parseOPML.js
@@ -1,17 +1,21 @@
const h = require('htmlparser2')
const Logger = require('../../Logger')
+/**
+ *
+ * @param {string} opmlText
+ * @returns {Array<{title: string, feedUrl: string}>
+ */
function parse(opmlText) {
var feeds = []
var parser = new h.Parser({
onopentag: (name, attribs) => {
- if (name === "outline" && attribs.type === 'rss') {
+ if (name === 'outline' && attribs.type === 'rss') {
if (!attribs.xmlurl) {
Logger.error('[parseOPML] Invalid opml outline tag has no xmlurl attribute')
} else {
feeds.push({
- title: attribs.title || 'No Title',
- text: attribs.text || '',
+ title: attribs.title || attribs.text || '',
feedUrl: attribs.xmlurl
})
}
@@ -21,4 +25,4 @@ function parse(opmlText) {
parser.write(opmlText)
return feeds
}
-module.exports.parse = parse
\ No newline at end of file
+module.exports.parse = parse
diff --git a/server/utils/parsers/parseOpfMetadata.js b/server/utils/parsers/parseOpfMetadata.js
index a54196018c..8cf768cd4d 100644
--- a/server/utils/parsers/parseOpfMetadata.js
+++ b/server/utils/parsers/parseOpfMetadata.js
@@ -1,23 +1,38 @@
const { xmlToJSON } = require('../index')
const htmlSanitizer = require('../htmlSanitizer')
+/**
+ * @typedef MetadataCreatorObject
+ * @property {string} value
+ * @property {string} role
+ * @property {string} fileAs
+ *
+ * @example
+ * John Steinbeck
+ * George Orwell
+ *
+ * @param {Object} metadata
+ * @returns {MetadataCreatorObject[]}
+ */
function parseCreators(metadata) {
- if (!metadata['dc:creator']) return null
- const creators = metadata['dc:creator']
- if (!creators.length) return null
- return creators.map(c => {
+ if (!metadata['dc:creator']?.length) return null
+ return metadata['dc:creator'].map((c) => {
if (typeof c !== 'object' || !c['$'] || !c['_']) return false
+ const namespace =
+ Object.keys(c['$'])
+ .find((key) => key.startsWith('xmlns:'))
+ ?.split(':')[1] || 'opf'
return {
value: c['_'],
- role: c['$']['opf:role'] || null,
- fileAs: c['$']['opf:file-as'] || null
+ role: c['$'][`${namespace}:role`] || null,
+ fileAs: c['$'][`${namespace}:file-as`] || null
}
})
}
function fetchCreators(creators, role) {
if (!creators?.length) return null
- return [...new Set(creators.filter(c => c.role === role && c.value).map(c => c.value))]
+ return [...new Set(creators.filter((c) => c.role === role && c.value).map((c) => c.value))]
}
function fetchTagString(metadata, tag) {
@@ -59,18 +74,34 @@ function fetchPublisher(metadata) {
return fetchTagString(metadata, 'dc:publisher')
}
+/**
+ * @example
+ * 9781440633904
+ * 9780141187761
+ *
+ * @param {Object} metadata
+ * @param {string} scheme
+ * @returns {string}
+ */
+function fetchIdentifier(metadata, scheme) {
+ if (!metadata['dc:identifier']?.length) return null
+ const identifierObj = metadata['dc:identifier'].find((i) => {
+ if (!i['$']) return false
+ const namespace =
+ Object.keys(i['$'])
+ .find((key) => key.startsWith('xmlns:'))
+ ?.split(':')[1] || 'opf'
+ return i['$'][`${namespace}:scheme`] === scheme
+ })
+ return identifierObj?.['_'] || null
+}
+
function fetchISBN(metadata) {
- if (!metadata['dc:identifier'] || !metadata['dc:identifier'].length) return null
- const identifiers = metadata['dc:identifier']
- const isbnObj = identifiers.find(i => i['$'] && i['$']['opf:scheme'] === 'ISBN')
- return isbnObj ? isbnObj['_'] || null : null
+ return fetchIdentifier(metadata, 'ISBN')
}
function fetchASIN(metadata) {
- if (!metadata['dc:identifier'] || !metadata['dc:identifier'].length) return null
- const identifiers = metadata['dc:identifier']
- const asinObj = identifiers.find(i => i['$'] && i['$']['opf:scheme'] === 'ASIN')
- return asinObj ? asinObj['_'] || null : null
+ return fetchIdentifier(metadata, 'ASIN')
}
function fetchTitle(metadata) {
@@ -92,7 +123,7 @@ function fetchDescription(metadata) {
function fetchGenres(metadata) {
if (!metadata['dc:subject'] || !metadata['dc:subject'].length) return []
- return [...new Set(metadata['dc:subject'].filter(g => g && typeof g === 'string'))]
+ return [...new Set(metadata['dc:subject'].filter((g) => g && typeof g === 'string'))]
}
function fetchLanguage(metadata) {
@@ -116,20 +147,24 @@ function fetchSeries(metadataMeta) {
// If one series was found with no series_index then check if any series_index meta can be found
// this is to support when calibre:series_index is not directly underneath calibre:series
if (result.length === 1 && !result[0].sequence) {
- const seriesIndexMeta = metadataMeta.find(m => m.$?.name === 'calibre:series_index' && m.$.content?.trim())
+ const seriesIndexMeta = metadataMeta.find((m) => m.$?.name === 'calibre:series_index' && m.$.content?.trim())
if (seriesIndexMeta) {
result[0].sequence = seriesIndexMeta.$.content.trim()
}
}
- return result
+
+ // Remove duplicates
+ const dedupedResult = result.filter((se, idx) => result.findIndex((s) => s.name === se.name) === idx)
+
+ return dedupedResult
}
function fetchNarrators(creators, metadata) {
const narrators = fetchCreators(creators, 'nrt')
if (narrators?.length) return narrators
try {
- const narratorsJSON = JSON.parse(fetchTagString(metadata.meta, "calibre:user_metadata:#narrators").replace(/"/g, '"'))
- return narratorsJSON["#value#"]
+ const narratorsJSON = JSON.parse(fetchTagString(metadata.meta, 'calibre:user_metadata:#narrators').replace(/"/g, '"'))
+ return narratorsJSON['#value#']
} catch {
return null
}
@@ -137,7 +172,7 @@ function fetchNarrators(creators, metadata) {
function fetchTags(metadata) {
if (!metadata['dc:tag'] || !metadata['dc:tag'].length) return []
- return [...new Set(metadata['dc:tag'].filter(tag => tag && typeof tag === 'string'))]
+ return [...new Set(metadata['dc:tag'].filter((tag) => tag && typeof tag === 'string'))]
}
function stripPrefix(str) {
@@ -147,7 +182,7 @@ function stripPrefix(str) {
module.exports.parseOpfMetadataJson = (json) => {
// Handle or with prefix
- const packageKey = Object.keys(json).find(key => stripPrefix(key) === 'package')
+ const packageKey = Object.keys(json).find((key) => stripPrefix(key) === 'package')
if (!packageKey) return null
const prefix = packageKey.split(':').shift()
let metadata = prefix ? json[packageKey][`${prefix}:metadata`] || json[packageKey].metadata : json[packageKey].metadata
@@ -170,8 +205,8 @@ module.exports.parseOpfMetadataJson = (json) => {
}
const creators = parseCreators(metadata)
- const authors = (fetchCreators(creators, 'aut') || []).map(au => au?.trim()).filter(au => au)
- const narrators = (fetchNarrators(creators, metadata) || []).map(nrt => nrt?.trim()).filter(nrt => nrt)
+ const authors = (fetchCreators(creators, 'aut') || []).map((au) => au?.trim()).filter((au) => au)
+ const narrators = (fetchNarrators(creators, metadata) || []).map((nrt) => nrt?.trim()).filter((nrt) => nrt)
return {
title: fetchTitle(metadata),
subtitle: fetchSubtitle(metadata),
@@ -193,4 +228,4 @@ module.exports.parseOpfMetadataXML = async (xml) => {
const json = await xmlToJSON(xml)
if (!json) return null
return this.parseOpfMetadataJson(json)
-}
\ No newline at end of file
+}
diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js
index bfe540edd5..92679903ba 100644
--- a/server/utils/podcastUtils.js
+++ b/server/utils/podcastUtils.js
@@ -289,7 +289,6 @@ module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => {
const matches = []
feed.episodes.forEach((ep) => {
if (!ep.title) return
-
const epTitle = ep.title.toLowerCase().trim()
if (epTitle === searchTitle) {
matches.push({
diff --git a/server/utils/queries/authorFilters.js b/server/utils/queries/authorFilters.js
index 1faf3e55f6..bd4d08925e 100644
--- a/server/utils/queries/authorFilters.js
+++ b/server/utils/queries/authorFilters.js
@@ -60,12 +60,10 @@ module.exports = {
* @returns {Promise} oldAuthor with numBooks
*/
async search(libraryId, query, limit, offset) {
+ const matchAuthor = Database.matchExpression('name', query)
const authors = await Database.authorModel.findAll({
where: {
- name: {
- [Sequelize.Op.substring]: query
- },
- libraryId
+ [Sequelize.Op.and]: [Sequelize.literal(matchAuthor), { libraryId }]
},
attributes: {
include: [[Sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 'numBooks']]
diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js
index ffcbd83e2e..471a1c0b48 100644
--- a/server/utils/queries/libraryFilters.js
+++ b/server/utils/queries/libraryFilters.js
@@ -16,7 +16,7 @@ module.exports = {
/**
* Get library items using filter and sort
* @param {import('../../objects/Library')} library
- * @param {import('../../objects/user/User')} user
+ * @param {import('../../models/User')} user
* @param {object} options
* @returns {object} { libraryItems:LibraryItem[], count:number }
*/
@@ -42,7 +42,7 @@ module.exports = {
/**
* Get library items for continue listening & continue reading shelves
* @param {import('../../objects/Library')} library
- * @param {import('../../objects/user/User')} user
+ * @param {import('../../models/User')} user
* @param {string[]} include
* @param {number} limit
* @returns {Promise<{ items:import('../../models/LibraryItem')[], count:number }>}
@@ -79,7 +79,7 @@ module.exports = {
/**
* Get library items for most recently added shelf
* @param {import('../../objects/Library')} library
- * @param {oldUser} user
+ * @param {import('../../models/User')} user
* @param {string[]} include
* @param {number} limit
* @returns {object} { libraryItems:LibraryItem[], count:number }
@@ -127,7 +127,7 @@ module.exports = {
/**
* Get library items for continue series shelf
* @param {import('../../objects/Library')} library
- * @param {oldUser} user
+ * @param {import('../../models/User')} user
* @param {string[]} include
* @param {number} limit
* @returns {object} { libraryItems:LibraryItem[], count:number }
@@ -155,7 +155,7 @@ module.exports = {
/**
* Get library items or podcast episodes for the "Listen Again" and "Read Again" shelf
* @param {import('../../objects/Library')} library
- * @param {oldUser} user
+ * @param {import('../../models/User')} user
* @param {string[]} include
* @param {number} limit
* @returns {object} { items:object[], count:number }
@@ -192,7 +192,7 @@ module.exports = {
/**
* Get series for recent series shelf
* @param {import('../../objects/Library')} library
- * @param {import('../../objects/user/User')} user
+ * @param {import('../../models/User')} user
* @param {string[]} include
* @param {number} limit
* @returns {{ series:import('../../objects/entities/Series')[], count:number}}
@@ -235,7 +235,7 @@ module.exports = {
if (!user.canAccessExplicitContent) {
attrQuery += ' AND b.explicit = 0'
}
- if (!user.permissions.accessAllTags && user.itemTagsSelected.length) {
+ if (!user.permissions?.accessAllTags && user.permissions?.itemTagsSelected?.length) {
if (user.permissions.selectedTagsNotAccessible) {
attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) = 0'
} else {
@@ -317,7 +317,7 @@ module.exports = {
* Get most recently created authors for "Newest Authors" shelf
* Author must be linked to at least 1 book
* @param {oldLibrary} library
- * @param {oldUser} user
+ * @param {import('../../models/User')} user
* @param {number} limit
* @returns {object} { authors:oldAuthor[], count:number }
*/
@@ -360,7 +360,7 @@ module.exports = {
/**
* Get book library items for the "Discover" shelf
* @param {oldLibrary} library
- * @param {oldUser} user
+ * @param {import('../../models/User')} user
* @param {string[]} include
* @param {number} limit
* @returns {object} {libraryItems:oldLibraryItem[], count:number}
@@ -387,7 +387,7 @@ module.exports = {
/**
* Get podcast episodes most recently added
* @param {oldLibrary} library
- * @param {oldUser} user
+ * @param {import('../../models/User')} user
* @param {number} limit
* @returns {object} {libraryItems:oldLibraryItem[], count:number}
*/
@@ -408,7 +408,7 @@ module.exports = {
/**
* Get library items for an author, optional use user permissions
* @param {oldAuthor} author
- * @param {[oldUser]} user
+ * @param {import('../../models/User')} user
* @param {number} limit
* @param {number} offset
* @returns {Promise} { libraryItems:LibraryItem[], count:number }
diff --git a/server/utils/queries/libraryItemFilters.js b/server/utils/queries/libraryItemFilters.js
index 677b11c7e6..128df6fde7 100644
--- a/server/utils/queries/libraryItemFilters.js
+++ b/server/utils/queries/libraryItemFilters.js
@@ -6,7 +6,7 @@ const libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters')
module.exports = {
/**
* Get all library items that have tags
- * @param {string[]} tags
+ * @param {string[]} tags
* @returns {Promise}
*/
async getAllLibraryItemsWithTags(tags) {
@@ -71,7 +71,7 @@ module.exports = {
/**
* Get all library items that have genres
- * @param {string[]} genres
+ * @param {string[]} genres
* @returns {Promise}
*/
async getAllLibraryItemsWithGenres(genres) {
@@ -131,10 +131,10 @@ module.exports = {
},
/**
- * Get all library items that have narrators
- * @param {string[]} narrators
- * @returns {Promise}
- */
+ * Get all library items that have narrators
+ * @param {string[]} narrators
+ * @returns {Promise}
+ */
async getAllLibraryItemsWithNarrators(narrators) {
const libraryItems = []
const booksWithGenre = await Database.bookModel.findAll({
@@ -172,24 +172,24 @@ module.exports = {
/**
* Search library items
- * @param {import('../../objects/user/User')} oldUser
- * @param {import('../../objects/Library')} oldLibrary
+ * @param {import('../../models/User')} user
+ * @param {import('../../objects/Library')} oldLibrary
* @param {string} query
- * @param {number} limit
+ * @param {number} limit
* @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[], podcast:object[]}}
*/
- search(oldUser, oldLibrary, query, limit) {
+ search(user, oldLibrary, query, limit) {
if (oldLibrary.isBook) {
- return libraryItemsBookFilters.search(oldUser, oldLibrary, query, limit, 0)
+ return libraryItemsBookFilters.search(user, oldLibrary, query, limit, 0)
} else {
- return libraryItemsPodcastFilters.search(oldUser, oldLibrary, query, limit, 0)
+ return libraryItemsPodcastFilters.search(user, oldLibrary, query, limit, 0)
}
},
/**
* Get largest items in library
- * @param {string} libraryId
- * @param {number} limit
+ * @param {string} libraryId
+ * @param {number} limit
* @returns {Promise<{ id:string, title:string, size:number }[]>}
*/
async getLargestItems(libraryId, limit) {
@@ -208,12 +208,10 @@ module.exports = {
attributes: ['id', 'title']
}
],
- order: [
- ['size', 'DESC']
- ],
+ order: [['size', 'DESC']],
limit
})
- return libraryItems.map(libraryItem => {
+ return libraryItems.map((libraryItem) => {
return {
id: libraryItem.id,
title: libraryItem.media.title,
@@ -221,4 +219,4 @@ module.exports = {
}
})
}
-}
\ No newline at end of file
+}
diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js
index c2b2a9df17..da356b3ee6 100644
--- a/server/utils/queries/libraryItemsBookFilters.js
+++ b/server/utils/queries/libraryItemsBookFilters.js
@@ -2,14 +2,13 @@ const Sequelize = require('sequelize')
const Database = require('../../Database')
const Logger = require('../../Logger')
const authorFilters = require('./authorFilters')
-const { asciiOnlyToLowerCase } = require('../index')
const ShareManager = require('../../managers/ShareManager')
module.exports = {
/**
* User permissions to restrict books for explicit content & tags
- * @param {import('../../objects/user/User')} user
+ * @param {import('../../models/User')} user
* @returns {{ bookWhere:Sequelize.WhereOptions, replacements:object }}
*/
getUserPermissionBookWhereQuery(user) {
@@ -22,8 +21,8 @@ module.exports = {
explicit: false
})
}
- if (!user.permissions.accessAllTags && user.itemTagsSelected.length) {
- replacements['userTagsSelected'] = user.itemTagsSelected
+ if (!user.permissions?.accessAllTags && user.permissions?.itemTagsSelected?.length) {
+ replacements['userTagsSelected'] = user.permissions.itemTagsSelected
if (user.permissions.selectedTagsNotAccessible) {
bookWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), 0))
} else {
@@ -274,6 +273,8 @@ module.exports = {
return [[Sequelize.literal(`CAST(\`series.bookSeries.sequence\` AS FLOAT) ${nullDir}`)]]
} else if (sortBy === 'progress') {
return [[Sequelize.literal('mediaProgresses.updatedAt'), dir]]
+ } else if (sortBy === 'random') {
+ return [Database.sequelize.random()]
}
return []
},
@@ -332,7 +333,7 @@ module.exports = {
/**
* Get library items for book media type using filter and sort
* @param {string} libraryId
- * @param {import('../../objects/user/User')} user
+ * @param {import('../../models/User')} user
* @param {string|null} filterGroup
* @param {string|null} filterValue
* @param {string} sortBy
@@ -636,7 +637,7 @@ module.exports = {
* 3. Has at least 1 unfinished book
* TODO: Reduce queries
* @param {import('../../objects/Library')} library
- * @param {import('../../objects/user/User')} user
+ * @param {import('../../models/User')} user
* @param {string[]} include
* @param {number} limit
* @param {number} offset
@@ -671,7 +672,7 @@ module.exports = {
where: [
{
id: {
- [Sequelize.Op.notIn]: user.seriesHideFromContinueListening
+ [Sequelize.Op.notIn]: user.extraData?.seriesHideFromContinueListening || []
},
libraryId
},
@@ -779,7 +780,7 @@ module.exports = {
* Random selection of books that are not started
* - only includes the first book of a not-started series
* @param {string} libraryId
- * @param {oldUser} user
+ * @param {import('../../models/User')} user
* @param {string[]} include
* @param {number} limit
* @returns {object} {libraryItems:LibraryItem, count:number}
@@ -954,41 +955,38 @@ module.exports = {
/**
* Get library items for series
* @param {import('../../objects/entities/Series')} oldSeries
- * @param {import('../../objects/user/User')} [oldUser]
+ * @param {import('../../models/User')} [user]
* @returns {Promise}
*/
- async getLibraryItemsForSeries(oldSeries, oldUser) {
- const { libraryItems } = await this.getFilteredLibraryItems(oldSeries.libraryId, oldUser, 'series', oldSeries.id, null, null, false, [], null, null)
+ async getLibraryItemsForSeries(oldSeries, user) {
+ const { libraryItems } = await this.getFilteredLibraryItems(oldSeries.libraryId, user, 'series', oldSeries.id, null, null, false, [], null, null)
return libraryItems.map((li) => Database.libraryItemModel.getOldLibraryItem(li))
},
/**
* Search books, authors, series
- * @param {import('../../objects/user/User')} oldUser
+ * @param {import('../../models/User')} user
* @param {import('../../objects/Library')} oldLibrary
* @param {string} query
* @param {number} limit
* @param {number} offset
* @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[]}}
*/
- async search(oldUser, oldLibrary, query, limit, offset) {
- const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(oldUser)
+ async search(user, oldLibrary, query, limit, offset) {
+ const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user)
+
+ const normalizedQuery = query
+
+ const matchTitle = Database.matchExpression('title', normalizedQuery)
+ const matchSubtitle = Database.matchExpression('subtitle', normalizedQuery)
// Search title, subtitle, asin, isbn
const books = await Database.bookModel.findAll({
where: [
{
[Sequelize.Op.or]: [
- {
- title: {
- [Sequelize.Op.substring]: query
- }
- },
- {
- subtitle: {
- [Sequelize.Op.substring]: query
- }
- },
+ Sequelize.literal(matchTitle),
+ Sequelize.literal(matchSubtitle),
{
asin: {
[Sequelize.Op.substring]: query
@@ -1038,32 +1036,17 @@ module.exports = {
const libraryItem = book.libraryItem
delete book.libraryItem
libraryItem.media = book
-
- let matchText = null
- let matchKey = null
- for (const key of ['title', 'subtitle', 'asin', 'isbn']) {
- const valueToLower = asciiOnlyToLowerCase(book[key])
- if (valueToLower.includes(query)) {
- matchText = book[key]
- matchKey = key
- break
- }
- }
-
- if (matchKey) {
- itemMatches.push({
- matchText,
- matchKey,
- libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
- })
- }
+ itemMatches.push({
+ libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
+ })
}
+ const matchJsonValue = Database.matchExpression('json_each.value', normalizedQuery)
+
// Search narrators
const narratorMatches = []
- const [narratorResults] = await Database.sequelize.query(`SELECT value, count(*) AS numBooks FROM books b, libraryItems li, json_each(b.narrators) WHERE json_valid(b.narrators) AND json_each.value LIKE :query AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, {
+ const [narratorResults] = await Database.sequelize.query(`SELECT value, count(*) AS numBooks FROM books b, libraryItems li, json_each(b.narrators) WHERE json_valid(b.narrators) AND ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, {
replacements: {
- query: `%${query}%`,
libraryId: oldLibrary.id,
limit,
offset
@@ -1079,9 +1062,8 @@ module.exports = {
// Search tags
const tagMatches = []
- const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.tags) WHERE json_valid(b.tags) AND json_each.value LIKE :query AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, {
+ const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.tags) WHERE json_valid(b.tags) AND ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
replacements: {
- query: `%${query}%`,
libraryId: oldLibrary.id,
limit,
offset
@@ -1095,13 +1077,33 @@ module.exports = {
})
}
+ // Search genres
+ const genreMatches = []
+ const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.genres) WHERE json_valid(b.genres) AND ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
+ replacements: {
+ libraryId: oldLibrary.id,
+ limit,
+ offset
+ },
+ raw: true
+ })
+ for (const row of genreResults) {
+ genreMatches.push({
+ name: row.value,
+ numItems: row.numItems
+ })
+ }
+
// Search series
+ const matchName = Database.matchExpression('name', normalizedQuery)
const allSeries = await Database.seriesModel.findAll({
where: {
- name: {
- [Sequelize.Op.substring]: query
- },
- libraryId: oldLibrary.id
+ [Sequelize.Op.and]: [
+ Sequelize.literal(matchName),
+ {
+ libraryId: oldLibrary.id
+ }
+ ]
},
replacements: userPermissionBookWhere.replacements,
include: {
@@ -1134,12 +1136,13 @@ module.exports = {
}
// Search authors
- const authorMatches = await authorFilters.search(oldLibrary.id, query, limit, offset)
+ const authorMatches = await authorFilters.search(oldLibrary.id, normalizedQuery, limit, offset)
return {
book: itemMatches,
narrators: narratorMatches,
tags: tagMatches,
+ genres: genreMatches,
series: seriesMatches,
authors: authorMatches
}
diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js
index 3e6e337c8d..f629b689b4 100644
--- a/server/utils/queries/libraryItemsPodcastFilters.js
+++ b/server/utils/queries/libraryItemsPodcastFilters.js
@@ -1,13 +1,11 @@
-
const Sequelize = require('sequelize')
const Database = require('../../Database')
const Logger = require('../../Logger')
-const { asciiOnlyToLowerCase } = require('../index')
module.exports = {
/**
* User permissions to restrict podcasts for explicit content & tags
- * @param {import('../../objects/user/User')} user
+ * @param {import('../../models/User')} user
* @returns {{ podcastWhere:Sequelize.WhereOptions, replacements:object }}
*/
getUserPermissionPodcastWhereQuery(user) {
@@ -18,16 +16,20 @@ module.exports = {
explicit: false
})
}
- if (!user.permissions.accessAllTags && user.itemTagsSelected.length) {
- replacements['userTagsSelected'] = user.itemTagsSelected
+
+ if (!user.permissions?.accessAllTags && user.permissions?.itemTagsSelected?.length) {
+ replacements['userTagsSelected'] = user.permissions.itemTagsSelected
if (user.permissions.selectedTagsNotAccessible) {
- podcastWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), 0))
+ bookWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), 0))
} else {
- podcastWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), {
- [Sequelize.Op.gte]: 1
- }))
+ bookWhere.push(
+ Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), {
+ [Sequelize.Op.gte]: 1
+ })
+ )
}
}
+
return {
podcastWhere,
replacements
@@ -36,8 +38,8 @@ module.exports = {
/**
* Get where options for Podcast model
- * @param {string} group
- * @param {[string]} value
+ * @param {string} group
+ * @param {[string]} value
* @returns {object} { Sequelize.WhereOptions, string[] }
*/
getMediaGroupQuery(group, value) {
@@ -63,8 +65,8 @@ module.exports = {
/**
* Get sequelize order
- * @param {string} sortBy
- * @param {boolean} sortDesc
+ * @param {string} sortBy
+ * @param {boolean} sortDesc
* @returns {Sequelize.order}
*/
getOrder(sortBy, sortDesc) {
@@ -88,21 +90,23 @@ module.exports = {
}
} else if (sortBy === 'media.numTracks') {
return [['numEpisodes', dir]]
+ } else if (sortBy === 'random') {
+ return [Database.sequelize.random()]
}
return []
},
/**
* Get library items for podcast media type using filter and sort
- * @param {string} libraryId
- * @param {oldUser} user
- * @param {[string]} filterGroup
- * @param {[string]} filterValue
- * @param {string} sortBy
- * @param {string} sortDesc
+ * @param {string} libraryId
+ * @param {import('../../models/User')} user
+ * @param {[string]} filterGroup
+ * @param {[string]} filterValue
+ * @param {string} sortBy
+ * @param {string} sortDesc
* @param {string[]} include
- * @param {number} limit
- * @param {number} offset
+ * @param {number} limit
+ * @param {number} offset
* @returns {object} { libraryItems:LibraryItem[], count:number }
*/
async getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset) {
@@ -130,7 +134,7 @@ module.exports = {
]
} else if (filterGroup === 'recent') {
libraryItemWhere['createdAt'] = {
- [Sequelize.Op.gte]: new Date(new Date() - (60 * 24 * 60 * 60 * 1000)) // 60 days ago
+ [Sequelize.Op.gte]: new Date(new Date() - 60 * 24 * 60 * 60 * 1000) // 60 days ago
}
}
@@ -154,10 +158,7 @@ module.exports = {
replacements,
distinct: true,
attributes: {
- include: [
- [Sequelize.literal(`(SELECT count(*) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'numEpisodes'],
- ...podcastIncludes
- ]
+ include: [[Sequelize.literal(`(SELECT count(*) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'numEpisodes'], ...podcastIncludes]
},
include: [
{
@@ -199,14 +200,14 @@ module.exports = {
/**
* Get podcast episodes filtered and sorted
- * @param {string} libraryId
- * @param {oldUser} user
- * @param {[string]} filterGroup
- * @param {[string]} filterValue
- * @param {string} sortBy
- * @param {string} sortDesc
- * @param {number} limit
- * @param {number} offset
+ * @param {string} libraryId
+ * @param {import('../../models/User')} user
+ * @param {[string]} filterGroup
+ * @param {[string]} filterValue
+ * @param {string} sortBy
+ * @param {string} sortDesc
+ * @param {number} limit
+ * @param {number} offset
* @param {boolean} isHomePage for home page shelves
* @returns {object} {libraryItems:LibraryItem[], count:number}
*/
@@ -251,7 +252,7 @@ module.exports = {
}
} else if (filterGroup === 'recent') {
podcastEpisodeWhere['createdAt'] = {
- [Sequelize.Op.gte]: new Date(new Date() - (60 * 24 * 60 * 60 * 1000)) // 60 days ago
+ [Sequelize.Op.gte]: new Date(new Date() - 60 * 24 * 60 * 60 * 1000) // 60 days ago
}
}
@@ -304,30 +305,27 @@ module.exports = {
/**
* Search podcasts
- * @param {import('../../objects/user/User')} oldUser
- * @param {import('../../objects/Library')} oldLibrary
- * @param {string} query
- * @param {number} limit
- * @param {number} offset
+ * @param {import('../../models/User')} user
+ * @param {import('../../objects/Library')} oldLibrary
+ * @param {string} query
+ * @param {number} limit
+ * @param {number} offset
* @returns {{podcast:object[], tags:object[]}}
*/
- async search(oldUser, oldLibrary, query, limit, offset) {
- const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(oldUser)
+ async search(user, oldLibrary, query, limit, offset) {
+ const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user)
+
+ const normalizedQuery = query
+ const matchTitle = Database.matchExpression('title', normalizedQuery)
+ const matchAuthor = Database.matchExpression('author', normalizedQuery)
+
// Search title, author, itunesId, itunesArtistId
const podcasts = await Database.podcastModel.findAll({
where: [
{
[Sequelize.Op.or]: [
- {
- title: {
- [Sequelize.Op.substring]: query
- }
- },
- {
- author: {
- [Sequelize.Op.substring]: query
- }
- },
+ Sequelize.literal(matchTitle),
+ Sequelize.literal(matchAuthor),
{
itunesId: {
[Sequelize.Op.substring]: query
@@ -363,32 +361,17 @@ module.exports = {
const libraryItem = podcast.libraryItem
delete podcast.libraryItem
libraryItem.media = podcast
-
- let matchText = null
- let matchKey = null
- for (const key of ['title', 'author', 'itunesId', 'itunesArtistId']) {
- const valueToLower = asciiOnlyToLowerCase(podcast[key])
- if (valueToLower.includes(query)) {
- matchText = podcast[key]
- matchKey = key
- break
- }
- }
-
- if (matchKey) {
- itemMatches.push({
- matchText,
- matchKey,
- libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
- })
- }
+ itemMatches.push({
+ libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
+ })
}
+ const matchJsonValue = Database.matchExpression('json_each.value', normalizedQuery)
+
// Search tags
const tagMatches = []
- const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.tags) WHERE json_valid(p.tags) AND json_each.value LIKE :query AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, {
+ const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.tags) WHERE json_valid(p.tags) AND ${matchJsonValue} AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
replacements: {
- query: `%${query}%`,
libraryId: oldLibrary.id,
limit,
offset
@@ -402,22 +385,40 @@ module.exports = {
})
}
+ // Search genres
+ const genreMatches = []
+ const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.genres) WHERE json_valid(p.genres) AND ${matchJsonValue} AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
+ replacements: {
+ libraryId: oldLibrary.id,
+ limit,
+ offset
+ },
+ raw: true
+ })
+ for (const row of genreResults) {
+ genreMatches.push({
+ name: row.value,
+ numItems: row.numItems
+ })
+ }
+
return {
podcast: itemMatches,
- tags: tagMatches
+ tags: tagMatches,
+ genres: genreMatches
}
},
/**
* Most recent podcast episodes not finished
- * @param {import('../../objects/user/User')} oldUser
- * @param {import('../../objects/Library')} oldLibrary
- * @param {number} limit
- * @param {number} offset
+ * @param {import('../../models/User')} user
+ * @param {import('../../objects/Library')} oldLibrary
+ * @param {number} limit
+ * @param {number} offset
* @returns {Promise}
*/
- async getRecentEpisodes(oldUser, oldLibrary, limit, offset) {
- const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(oldUser)
+ async getRecentEpisodes(user, oldLibrary, limit, offset) {
+ const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user)
const episodes = await Database.podcastEpisodeModel.findAll({
where: {
@@ -441,14 +442,12 @@ module.exports = {
{
model: Database.mediaProgressModel,
where: {
- userId: oldUser.id
+ userId: user.id
},
required: false
}
],
- order: [
- ['publishedAt', 'DESC']
- ],
+ order: [['publishedAt', 'DESC']],
subQuery: false,
limit,
offset
@@ -469,7 +468,7 @@ module.exports = {
/**
* Get stats for podcast library
- * @param {string} libraryId
+ * @param {string} libraryId
* @returns {Promise<{ totalSize:number, totalDuration:number, numAudioFiles:number, totalItems:number}>}
*/
async getPodcastLibraryStats(libraryId) {
@@ -491,7 +490,7 @@ module.exports = {
/**
* Genres with num podcasts
- * @param {string} libraryId
+ * @param {string} libraryId
* @returns {{genre:string, count:number}[]}
*/
async getGenresWithCount(libraryId) {
@@ -513,17 +512,13 @@ module.exports = {
/**
* Get longest podcasts in library
- * @param {string} libraryId
- * @param {number} limit
+ * @param {string} libraryId
+ * @param {number} limit
* @returns {Promise<{ id:string, title:string, duration:number }[]>}
*/
async getLongestPodcasts(libraryId, limit) {
const podcasts = await Database.podcastModel.findAll({
- attributes: [
- 'id',
- 'title',
- [Sequelize.literal(`(SELECT SUM(json_extract(pe.audioFile, '$.duration')) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'duration']
- ],
+ attributes: ['id', 'title', [Sequelize.literal(`(SELECT SUM(json_extract(pe.audioFile, '$.duration')) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'duration']],
include: {
model: Database.libraryItemModel,
attributes: ['id', 'libraryId'],
@@ -531,12 +526,10 @@ module.exports = {
libraryId
}
},
- order: [
- ['duration', 'DESC']
- ],
+ order: [['duration', 'DESC']],
limit
})
- return podcasts.map(podcast => {
+ return podcasts.map((podcast) => {
return {
id: podcast.libraryItem.id,
title: podcast.title,
@@ -544,4 +537,4 @@ module.exports = {
}
})
}
-}
\ No newline at end of file
+}
diff --git a/server/utils/queries/seriesFilters.js b/server/utils/queries/seriesFilters.js
index 69e4df0632..c03c13bff2 100644
--- a/server/utils/queries/seriesFilters.js
+++ b/server/utils/queries/seriesFilters.js
@@ -10,15 +10,15 @@ module.exports = {
/**
* Get series filtered and sorted
- *
- * @param {import('../../objects/Library')} library
- * @param {import('../../objects/user/User')} user
- * @param {string} filterBy
- * @param {string} sortBy
- * @param {boolean} sortDesc
- * @param {string[]} include
- * @param {number} limit
- * @param {number} offset
+ *
+ * @param {import('../../objects/Library')} library
+ * @param {import('../../models/User')} user
+ * @param {string} filterBy
+ * @param {string} sortBy
+ * @param {boolean} sortDesc
+ * @param {string[]} include
+ * @param {number} limit
+ * @param {number} offset
* @returns {Promise<{ series:object[], count:number }>}
*/
async getFilteredSeries(library, user, filterBy, sortBy, sortDesc, include, limit, offset) {
@@ -26,7 +26,7 @@ module.exports = {
let filterGroup = null
if (filterBy) {
const searchGroups = ['genres', 'tags', 'authors', 'progress', 'narrators', 'publishers', 'languages']
- const group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
+ const group = searchGroups.find((_group) => filterBy.startsWith(_group + '.'))
filterGroup = group || filterBy
filterValue = group ? this.decode(filterBy.replace(`${group}.`, '')) : null
}
@@ -49,9 +49,11 @@ module.exports = {
// Handle library setting to hide single book series
// TODO: Merge with existing query
if (library.settings.hideSingleBookSeries) {
- seriesWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id)`), {
- [Sequelize.Op.gt]: 1
- }))
+ seriesWhere.push(
+ Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id)`), {
+ [Sequelize.Op.gt]: 1
+ })
+ )
}
// Handle filters
@@ -91,7 +93,7 @@ module.exports = {
if (!user.canAccessExplicitContent) {
attrQuery += ' AND b.explicit = 0'
}
- if (!user.permissions.accessAllTags && user.itemTagsSelected.length) {
+ if (!user.permissions?.accessAllTags && user.permissions?.itemTagsSelected?.length) {
if (user.permissions.selectedTagsNotAccessible) {
attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) = 0'
} else {
@@ -101,9 +103,11 @@ module.exports = {
}
if (attrQuery) {
- seriesWhere.push(Sequelize.where(Sequelize.literal(`(${attrQuery})`), {
- [Sequelize.Op.gt]: 0
- }))
+ seriesWhere.push(
+ Sequelize.where(Sequelize.literal(`(${attrQuery})`), {
+ [Sequelize.Op.gt]: 0
+ })
+ )
}
const order = []
@@ -133,6 +137,8 @@ module.exports = {
} else if (sortBy === 'lastBookUpdated') {
seriesAttributes.include.push([Sequelize.literal('(SELECT MAX(b.updatedAt) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'mostRecentBookUpdated'])
order.push(['mostRecentBookUpdated', dir])
+ } else if (sortBy === 'random') {
+ order.push(Database.sequelize.random())
}
const { rows: series, count } = await Database.seriesModel.findAndCountAll({
@@ -184,7 +190,7 @@ module.exports = {
sensitivity: 'base'
})
})
- oldSeries.books = s.bookSeries.map(bs => {
+ oldSeries.books = s.bookSeries.map((bs) => {
const libraryItem = bs.book.libraryItem.toJSON()
delete bs.book.libraryItem
libraryItem.media = bs.book
diff --git a/server/utils/queries/userStats.js b/server/utils/queries/userStats.js
index 4e4080f8d3..76b69ed784 100644
--- a/server/utils/queries/userStats.js
+++ b/server/utils/queries/userStats.js
@@ -6,8 +6,8 @@ const fsExtra = require('../../libs/fsExtra')
module.exports = {
/**
- *
- * @param {string} userId
+ *
+ * @param {string} userId
* @param {number} year YYYY
* @returns {Promise}
*/
@@ -35,8 +35,8 @@ module.exports = {
},
/**
- *
- * @param {string} userId
+ *
+ * @param {string} userId
* @param {number} year YYYY
* @returns {Promise}
*/
@@ -65,11 +65,10 @@ module.exports = {
},
/**
- * @param {import('../../objects/user/User')} user
+ * @param {string} userId
* @param {number} year YYYY
*/
- async getStatsForYear(user, year) {
- const userId = user.id
+ async getStatsForYear(userId, year) {
const listeningSessions = await this.getUserListeningSessionsForYear(userId, year)
const bookProgressesFinished = await this.getBookMediaProgressFinishedForYear(userId, year)
@@ -91,7 +90,7 @@ module.exports = {
let longestAudiobookFinished = null
for (const mediaProgress of bookProgressesFinished) {
// Grab first 5 that have a cover
- if (mediaProgress.mediaItem?.coverPath && !finishedBooksWithCovers.includes(mediaProgress.mediaItem.libraryItem.id) && finishedBooksWithCovers.length < 5 && await fsExtra.pathExists(mediaProgress.mediaItem.coverPath)) {
+ if (mediaProgress.mediaItem?.coverPath && !finishedBooksWithCovers.includes(mediaProgress.mediaItem.libraryItem.id) && finishedBooksWithCovers.length < 5 && (await fsExtra.pathExists(mediaProgress.mediaItem.coverPath))) {
finishedBooksWithCovers.push(mediaProgress.mediaItem.libraryItem.id)
}
@@ -108,7 +107,7 @@ module.exports = {
// Get listening session stats
for (const ls of listeningSessions) {
// Grab first 25 that have a cover
- if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && !finishedBooksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(ls.mediaItem.coverPath)) {
+ if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && !finishedBooksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 25 && (await fsExtra.pathExists(ls.mediaItem.coverPath))) {
booksWithCovers.push(ls.mediaItem.libraryItem.id)
}
@@ -141,7 +140,7 @@ module.exports = {
})
// Filter out bad genres like "audiobook" and "audio book"
- const genres = (ls.mediaMetadata.genres || []).filter(g => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
+ const genres = (ls.mediaMetadata.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
genres.forEach((genre) => {
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
genreListeningMap[genre] += listeningSessionListeningTime
@@ -156,10 +155,13 @@ module.exports = {
totalPodcastListeningTime = Math.round(totalPodcastListeningTime)
let topAuthors = null
- topAuthors = Object.keys(authorListeningMap).map(authorName => ({
- name: authorName,
- time: Math.round(authorListeningMap[authorName])
- })).sort((a, b) => b.time - a.time).slice(0, 3)
+ topAuthors = Object.keys(authorListeningMap)
+ .map((authorName) => ({
+ name: authorName,
+ time: Math.round(authorListeningMap[authorName])
+ }))
+ .sort((a, b) => b.time - a.time)
+ .slice(0, 3)
let mostListenedNarrator = null
for (const narrator in narratorListeningMap) {
@@ -172,10 +174,13 @@ module.exports = {
}
let topGenres = null
- topGenres = Object.keys(genreListeningMap).map(genre => ({
- genre,
- time: Math.round(genreListeningMap[genre])
- })).sort((a, b) => b.time - a.time).slice(0, 3)
+ topGenres = Object.keys(genreListeningMap)
+ .map((genre) => ({
+ genre,
+ time: Math.round(genreListeningMap[genre])
+ }))
+ .sort((a, b) => b.time - a.time)
+ .slice(0, 3)
let mostListenedMonth = null
for (const month in monthListeningMap) {
diff --git a/test/server/managers/ApiCacheManager.test.js b/test/server/managers/ApiCacheManager.test.js
index dc1ee1ed49..19bbeecf68 100644
--- a/test/server/managers/ApiCacheManager.test.js
+++ b/test/server/managers/ApiCacheManager.test.js
@@ -11,8 +11,8 @@ describe('ApiCacheManager', () => {
let manager
beforeEach(() => {
- cache = { get: sinon.stub(), set: sinon.spy() }
- req = { user: { username: 'testUser' }, url: '/test-url' }
+ cache = { get: sinon.stub(), set: sinon.spy() }
+ req = { user: { username: 'testUser' }, url: '/test-url', query: {} }
res = { send: sinon.spy(), getHeaders: sinon.stub(), statusCode: 200, status: sinon.spy(), set: sinon.spy() }
next = sinon.spy()
})
@@ -94,4 +94,4 @@ describe('ApiCacheManager', () => {
expect(res.originalSend.calledWith(body)).to.be.true
})
})
-})
\ No newline at end of file
+})
diff --git a/test/server/managers/BinaryManager.test.js b/test/server/managers/BinaryManager.test.js
index e13b0cedf1..365fdff9e7 100644
--- a/test/server/managers/BinaryManager.test.js
+++ b/test/server/managers/BinaryManager.test.js
@@ -3,9 +3,9 @@ const sinon = require('sinon')
const fs = require('../../../server/libs/fsExtra')
const fileUtils = require('../../../server/utils/fileUtils')
const which = require('../../../server/libs/which')
-const ffbinaries = require('../../../server/libs/ffbinaries')
const path = require('path')
const BinaryManager = require('../../../server/managers/BinaryManager')
+const { Binary, ffbinaries } = require('../../../server/managers/BinaryManager')
const expect = chai.expect
@@ -38,7 +38,7 @@ describe('BinaryManager', () => {
it('should not install binaries if they are already found', async () => {
findStub.resolves([])
-
+
await binaryManager.init()
expect(installStub.called).to.be.false
@@ -49,10 +49,14 @@ describe('BinaryManager', () => {
})
it('should install missing binaries', async () => {
- const missingBinaries = ['ffmpeg', 'ffprobe']
+ const ffmpegBinary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)
+ const ffprobeBinary = new Binary('ffprobe', 'executable', 'FFPROBE_PATH', ['5.1'], ffbinaries)
+ const requiredBinaries = [ffmpegBinary, ffprobeBinary]
+ const missingBinaries = [ffprobeBinary]
const missingBinariesAfterInstall = []
findStub.onFirstCall().resolves(missingBinaries)
findStub.onSecondCall().resolves(missingBinariesAfterInstall)
+ binaryManager.requiredBinaries = requiredBinaries
await binaryManager.init()
@@ -64,8 +68,11 @@ describe('BinaryManager', () => {
})
it('exit if binaries are not found after installation', async () => {
- const missingBinaries = ['ffmpeg', 'ffprobe']
- const missingBinariesAfterInstall = ['ffmpeg', 'ffprobe']
+ const ffmpegBinary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)
+ const ffprobeBinary = new Binary('ffprobe', 'executable', 'FFPROBE_PATH', ['5.1'], ffbinaries)
+ const requiredBinaries = [ffmpegBinary, ffprobeBinary]
+ const missingBinaries = [ffprobeBinary]
+ const missingBinariesAfterInstall = [ffprobeBinary]
findStub.onFirstCall().resolves(missingBinaries)
findStub.onSecondCall().resolves(missingBinariesAfterInstall)
@@ -80,14 +87,15 @@ describe('BinaryManager', () => {
})
})
-
describe('findRequiredBinaries', () => {
let findBinaryStub
+ let ffmpegBinary
beforeEach(() => {
- const requiredBinaries = [{ name: 'ffmpeg', envVariable: 'FFMPEG_PATH' }]
+ ffmpegBinary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)
+ const requiredBinaries = [ffmpegBinary]
binaryManager = new BinaryManager(requiredBinaries)
- findBinaryStub = sinon.stub(binaryManager, 'findBinary')
+ findBinaryStub = sinon.stub(ffmpegBinary, 'find')
})
afterEach(() => {
@@ -108,8 +116,8 @@ describe('BinaryManager', () => {
})
it('should add missing binaries to result', async () => {
- const missingBinaries = ['ffmpeg']
- delete process.env.FFMPEG_PATH
+ const missingBinaries = [ffmpegBinary]
+ delete process.env.FFMPEG_PATH
findBinaryStub.resolves(null)
const result = await binaryManager.findRequiredBinaries()
@@ -119,22 +127,25 @@ describe('BinaryManager', () => {
expect(process.env.FFMPEG_PATH).to.be.undefined
})
})
-
+
describe('install', () => {
let isWritableStub
- let downloadBinariesStub
+ let downloadBinaryStub
+ let ffmpegBinary
beforeEach(() => {
- binaryManager = new BinaryManager()
+ ffmpegBinary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)
+ const requiredBinaries = [ffmpegBinary]
+ binaryManager = new BinaryManager(requiredBinaries)
isWritableStub = sinon.stub(fileUtils, 'isWritable')
- downloadBinariesStub = sinon.stub(ffbinaries, 'downloadBinaries')
- binaryManager.mainInstallPath = '/path/to/main/install'
- binaryManager.altInstallPath = '/path/to/alt/install'
+ downloadBinaryStub = sinon.stub(ffmpegBinary, 'download')
+ binaryManager.mainInstallDir = '/path/to/main/install'
+ binaryManager.altInstallDir = '/path/to/alt/install'
})
afterEach(() => {
isWritableStub.restore()
- downloadBinariesStub.restore()
+ downloadBinaryStub.restore()
})
it('should not install binaries if no binaries are passed', async () => {
@@ -143,240 +154,302 @@ describe('BinaryManager', () => {
await binaryManager.install(binaries)
expect(isWritableStub.called).to.be.false
- expect(downloadBinariesStub.called).to.be.false
+ expect(downloadBinaryStub.called).to.be.false
})
it('should install binaries in main install path if has access', async () => {
- const binaries = ['ffmpeg']
- const destination = binaryManager.mainInstallPath
+ const binaries = [ffmpegBinary]
+ const destination = binaryManager.mainInstallDir
isWritableStub.withArgs(destination).resolves(true)
- downloadBinariesStub.resolves()
-
+ downloadBinaryStub.resolves()
+
await binaryManager.install(binaries)
expect(isWritableStub.calledOnce).to.be.true
- expect(downloadBinariesStub.calledOnce).to.be.true
- expect(downloadBinariesStub.calledWith(binaries, sinon.match({ destination: destination }))).to.be.true
+ expect(downloadBinaryStub.calledOnce).to.be.true
+ expect(downloadBinaryStub.calledWith(destination)).to.be.true
})
it('should install binaries in alt install path if has no access to main', async () => {
- const binaries = ['ffmpeg']
- const mainDestination = binaryManager.mainInstallPath
- const destination = binaryManager.altInstallPath
+ const binaries = [ffmpegBinary]
+ const mainDestination = binaryManager.mainInstallDir
+ const destination = binaryManager.altInstallDir
isWritableStub.withArgs(mainDestination).resolves(false)
- downloadBinariesStub.resolves()
-
+ downloadBinaryStub.resolves()
+
await binaryManager.install(binaries)
expect(isWritableStub.calledOnce).to.be.true
- expect(downloadBinariesStub.calledOnce).to.be.true
- expect(downloadBinariesStub.calledWith(binaries, sinon.match({ destination: destination }))).to.be.true
+ expect(downloadBinaryStub.calledOnce).to.be.true
+ expect(downloadBinaryStub.calledWith(destination)).to.be.true
})
})
})
-describe('findBinary', () => {
- let binaryManager
- let isBinaryGoodStub
- let whichSyncStub
- let mainInstallPath
- let altInstallPath
-
- const name = 'ffmpeg'
- const envVariable = 'FFMPEG_PATH'
- const defaultPath = '/path/to/ffmpeg'
- const executable = name + (process.platform == 'win32' ? '.exe' : '')
- const whichPath = '/usr/bin/ffmpeg'
-
-
- beforeEach(() => {
- binaryManager = new BinaryManager()
- isBinaryGoodStub = sinon.stub(binaryManager, 'isBinaryGood')
- whichSyncStub = sinon.stub(which, 'sync')
- binaryManager.mainInstallPath = '/path/to/main/install'
- mainInstallPath = path.join(binaryManager.mainInstallPath, executable)
- binaryManager.altInstallPath = '/path/to/alt/install'
- altInstallPath = path.join(binaryManager.altInstallPath, executable)
- })
+describe('Binary', () => {
+ describe('find', () => {
+ let binary
+ let isGoodStub
+ let whichSyncStub
+ let mainInstallPath
+ let altInstallPath
- afterEach(() => {
- isBinaryGoodStub.restore()
- whichSyncStub.restore()
- })
-
- it('should return the defaultPath if it exists and is a good binary', async () => {
- process.env[envVariable] = defaultPath
- isBinaryGoodStub.withArgs(defaultPath).resolves(true)
-
- const result = await binaryManager.findBinary(name, envVariable)
-
- expect(result).to.equal(defaultPath)
- expect(isBinaryGoodStub.calledOnce).to.be.true
- expect(isBinaryGoodStub.calledWith(defaultPath)).to.be.true
- })
-
- it('should return the whichPath if it exists and is a good binary', async () => {
- delete process.env[envVariable]
- isBinaryGoodStub.withArgs(undefined).resolves(false)
- isBinaryGoodStub.withArgs(whichPath).resolves(true)
- whichSyncStub.returns(whichPath)
-
- const result = await binaryManager.findBinary(name, envVariable)
-
- expect(result).to.equal(whichPath)
- expect(isBinaryGoodStub.calledTwice).to.be.true
- expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
- expect(isBinaryGoodStub.calledWith(whichPath)).to.be.true
- })
-
- it('should return the mainInstallPath if it exists and is a good binary', async () => {
- delete process.env[envVariable]
- isBinaryGoodStub.withArgs(undefined).resolves(false)
- isBinaryGoodStub.withArgs(null).resolves(false)
- isBinaryGoodStub.withArgs(mainInstallPath).resolves(true)
- whichSyncStub.returns(null)
-
- const result = await binaryManager.findBinary(name, envVariable)
-
- expect(result).to.equal(mainInstallPath)
- expect(isBinaryGoodStub.callCount).to.be.equal(3)
- expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
- expect(isBinaryGoodStub.calledWith(null)).to.be.true
- expect(isBinaryGoodStub.calledWith(mainInstallPath)).to.be.true
- })
-
- it('should return the altInstallPath if it exists and is a good binary', async () => {
- delete process.env[envVariable]
- isBinaryGoodStub.withArgs(undefined).resolves(false)
- isBinaryGoodStub.withArgs(null).resolves(false)
- isBinaryGoodStub.withArgs(mainInstallPath).resolves(false)
- isBinaryGoodStub.withArgs(altInstallPath).resolves(true)
- whichSyncStub.returns(null)
-
- const result = await binaryManager.findBinary(name, envVariable)
-
- expect(result).to.equal(altInstallPath)
- expect(isBinaryGoodStub.callCount).to.be.equal(4)
- expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
- expect(isBinaryGoodStub.calledWith(null)).to.be.true
- expect(isBinaryGoodStub.calledWith(mainInstallPath)).to.be.true
- expect(isBinaryGoodStub.calledWith(altInstallPath)).to.be.true
- })
-
- it('should return null if no good binary is found', async () => {
- delete process.env[envVariable]
- isBinaryGoodStub.withArgs(undefined).resolves(false)
- isBinaryGoodStub.withArgs(null).resolves(false)
- isBinaryGoodStub.withArgs(mainInstallPath).resolves(false)
- isBinaryGoodStub.withArgs(altInstallPath).resolves(false)
- whichSyncStub.returns(null)
-
- const result = await binaryManager.findBinary(name, envVariable)
-
- expect(result).to.be.null
- expect(isBinaryGoodStub.callCount).to.be.equal(4)
- expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
- expect(isBinaryGoodStub.calledWith(null)).to.be.true
- expect(isBinaryGoodStub.calledWith(mainInstallPath)).to.be.true
- expect(isBinaryGoodStub.calledWith(altInstallPath)).to.be.true
- })
-})
+ const name = 'ffmpeg'
+ const envVariable = 'FFMPEG_PATH'
+ const defaultPath = '/path/to/ffmpeg'
+ const executable = name + (process.platform == 'win32' ? '.exe' : '')
+ const whichPath = '/usr/bin/ffmpeg'
-describe('isBinaryGood', () => {
- let binaryManager
- let fsPathExistsStub
- let execStub
- let loggerInfoStub
- let loggerErrorStub
-
- const binaryPath = '/path/to/binary'
- const execCommand = '"' + binaryPath + '"' + ' -version'
- const goodVersions = ['5.1', '6']
-
- beforeEach(() => {
- binaryManager = new BinaryManager()
- fsPathExistsStub = sinon.stub(fs, 'pathExists')
- execStub = sinon.stub(binaryManager, 'exec')
- })
+ beforeEach(() => {
+ binary = new Binary(name, 'executable', envVariable, ['5.1'], ffbinaries)
+ isGoodStub = sinon.stub(binary, 'isGood')
+ whichSyncStub = sinon.stub(which, 'sync')
+ binary.mainInstallDir = '/path/to/main/install'
+ mainInstallPath = path.join(binary.mainInstallDir, executable)
+ binary.altInstallDir = '/path/to/alt/install'
+ altInstallPath = path.join(binary.altInstallDir, executable)
+ })
- afterEach(() => {
- fsPathExistsStub.restore()
- execStub.restore()
- })
+ afterEach(() => {
+ isGoodStub.restore()
+ whichSyncStub.restore()
+ })
- it('should return false if binaryPath is falsy', async () => {
- fsPathExistsStub.resolves(true)
+ it('should return the defaultPath if it exists and is a good binary', async () => {
+ process.env[envVariable] = defaultPath
+ isGoodStub.withArgs(defaultPath).resolves(true)
- const result = await binaryManager.isBinaryGood(null, goodVersions)
+ const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)
- expect(result).to.be.false
- expect(fsPathExistsStub.called).to.be.false
- expect(execStub.called).to.be.false
- })
+ expect(result).to.equal(defaultPath)
+ expect(isGoodStub.calledOnce).to.be.true
+ expect(isGoodStub.calledWith(defaultPath)).to.be.true
+ })
- it('should return false if binaryPath does not exist', async () => {
- fsPathExistsStub.resolves(false)
+ it('should return the whichPath if it exists and is a good binary', async () => {
+ delete process.env[envVariable]
+ isGoodStub.withArgs(undefined).resolves(false)
+ whichSyncStub.returns(whichPath)
+ isGoodStub.withArgs(whichPath).resolves(true)
- const result = await binaryManager.isBinaryGood(binaryPath, goodVersions)
+ const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)
- expect(result).to.be.false
- expect(fsPathExistsStub.calledOnce).to.be.true
- expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
- expect(execStub.called).to.be.false
- })
+ expect(result).to.equal(whichPath)
+ expect(isGoodStub.calledTwice).to.be.true
+ expect(isGoodStub.calledWith(undefined)).to.be.true
+ expect(isGoodStub.calledWith(whichPath)).to.be.true
+ })
- it('should return false if failed to check version of binary', async () => {
- fsPathExistsStub.resolves(true)
- execStub.rejects(new Error('Failed to execute command'))
+ it('should return the mainInstallPath if it exists and is a good binary', async () => {
+ delete process.env[envVariable]
+ isGoodStub.withArgs(undefined).resolves(false)
+ whichSyncStub.returns(null)
+ isGoodStub.withArgs(null).resolves(false)
+ isGoodStub.withArgs(mainInstallPath).resolves(true)
- const result = await binaryManager.isBinaryGood(binaryPath, goodVersions)
+ const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)
- expect(result).to.be.false
- expect(fsPathExistsStub.calledOnce).to.be.true
- expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
- expect(execStub.calledOnce).to.be.true
- expect(execStub.calledWith(execCommand)).to.be.true
+ expect(result).to.equal(mainInstallPath)
+ expect(isGoodStub.callCount).to.be.equal(3)
+ expect(isGoodStub.calledWith(undefined)).to.be.true
+ expect(isGoodStub.calledWith(null)).to.be.true
+ expect(isGoodStub.calledWith(mainInstallPath)).to.be.true
+ })
+
+ it('should return the altInstallPath if it exists and is a good binary', async () => {
+ delete process.env[envVariable]
+ isGoodStub.withArgs(undefined).resolves(false)
+ whichSyncStub.returns(null)
+ isGoodStub.withArgs(null).resolves(false)
+ isGoodStub.withArgs(mainInstallPath).resolves(false)
+ isGoodStub.withArgs(altInstallPath).resolves(true)
+
+ const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)
+
+ expect(result).to.equal(altInstallPath)
+ expect(isGoodStub.callCount).to.be.equal(4)
+ expect(isGoodStub.calledWith(undefined)).to.be.true
+ expect(isGoodStub.calledWith(null)).to.be.true
+ expect(isGoodStub.calledWith(mainInstallPath)).to.be.true
+ expect(isGoodStub.calledWith(altInstallPath)).to.be.true
+ })
+
+ it('should return null if no good binary is found', async () => {
+ delete process.env[envVariable]
+ isGoodStub.withArgs(undefined).resolves(false)
+ whichSyncStub.returns(null)
+ isGoodStub.withArgs(null).resolves(false)
+ isGoodStub.withArgs(mainInstallPath).resolves(false)
+ isGoodStub.withArgs(altInstallPath).resolves(false)
+
+ const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)
+
+ expect(result).to.be.null
+ expect(isGoodStub.callCount).to.be.equal(4)
+ expect(isGoodStub.calledWith(undefined)).to.be.true
+ expect(isGoodStub.calledWith(null)).to.be.true
+ expect(isGoodStub.calledWith(mainInstallPath)).to.be.true
+ expect(isGoodStub.calledWith(altInstallPath)).to.be.true
+ })
})
- it('should return false if version is not found', async () => {
- const stdout = 'Some output without version'
- fsPathExistsStub.resolves(true)
- execStub.resolves({ stdout })
+ describe('isGood', () => {
+ let binary
+ let fsPathExistsStub
+ let execStub
- const result = await binaryManager.isBinaryGood(binaryPath, goodVersions)
+ const binaryPath = '/path/to/binary'
+ const execCommand = '"' + binaryPath + '"' + ' -version'
+ const goodVersions = ['5.1', '6']
- expect(result).to.be.false
- expect(fsPathExistsStub.calledOnce).to.be.true
- expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
- expect(execStub.calledOnce).to.be.true
- expect(execStub.calledWith(execCommand)).to.be.true
- })
+ beforeEach(() => {
+ binary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', goodVersions, ffbinaries)
+ fsPathExistsStub = sinon.stub(fs, 'pathExists')
+ execStub = sinon.stub(binary, 'exec')
+ })
+
+ afterEach(() => {
+ fsPathExistsStub.restore()
+ execStub.restore()
+ })
+
+ it('should return false if binaryPath is falsy', async () => {
+ fsPathExistsStub.resolves(true)
+
+ const result = await binary.isGood(null)
+
+ expect(result).to.be.false
+ expect(fsPathExistsStub.called).to.be.false
+ expect(execStub.called).to.be.false
+ })
+
+ it('should return false if binaryPath does not exist', async () => {
+ fsPathExistsStub.resolves(false)
- it('should return false if version is found but does not match a good version', async () => {
- const stdout = 'version 1.2.3'
- fsPathExistsStub.resolves(true)
- execStub.resolves({ stdout })
+ const result = await binary.isGood(binaryPath)
- const result = await binaryManager.isBinaryGood(binaryPath, goodVersions)
+ expect(result).to.be.false
+ expect(fsPathExistsStub.calledOnce).to.be.true
+ expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
+ expect(execStub.called).to.be.false
+ })
+
+ it('should return false if failed to check version of binary', async () => {
+ fsPathExistsStub.resolves(true)
+ execStub.rejects(new Error('Failed to execute command'))
+
+ const result = await binary.isGood(binaryPath)
+
+ expect(result).to.be.false
+ expect(fsPathExistsStub.calledOnce).to.be.true
+ expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
+ expect(execStub.calledOnce).to.be.true
+ expect(execStub.calledWith(execCommand)).to.be.true
+ })
+
+ it('should return false if version is not found', async () => {
+ const stdout = 'Some output without version'
+ fsPathExistsStub.resolves(true)
+ execStub.resolves({ stdout })
+
+ const result = await binary.isGood(binaryPath)
+
+ expect(result).to.be.false
+ expect(fsPathExistsStub.calledOnce).to.be.true
+ expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
+ expect(execStub.calledOnce).to.be.true
+ expect(execStub.calledWith(execCommand)).to.be.true
+ })
+
+ it('should return false if version is found but does not match a good version', async () => {
+ const stdout = 'version 1.2.3'
+ fsPathExistsStub.resolves(true)
+ execStub.resolves({ stdout })
+
+ const result = await binary.isGood(binaryPath)
- expect(result).to.be.false
- expect(fsPathExistsStub.calledOnce).to.be.true
- expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
- expect(execStub.calledOnce).to.be.true
- expect(execStub.calledWith(execCommand)).to.be.true
+ expect(result).to.be.false
+ expect(fsPathExistsStub.calledOnce).to.be.true
+ expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
+ expect(execStub.calledOnce).to.be.true
+ expect(execStub.calledWith(execCommand)).to.be.true
+ })
+
+ it('should return true if version is found and matches a good version', async () => {
+ const stdout = 'version 6.1.2'
+ fsPathExistsStub.resolves(true)
+ execStub.resolves({ stdout })
+
+ const result = await binary.isGood(binaryPath)
+
+ expect(result).to.be.true
+ expect(fsPathExistsStub.calledOnce).to.be.true
+ expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
+ expect(execStub.calledOnce).to.be.true
+ expect(execStub.calledWith(execCommand)).to.be.true
+ })
})
- it('should return true if version is found and matches a good version', async () => {
- const stdout = 'version 6.1.2'
- fsPathExistsStub.resolves(true)
- execStub.resolves({ stdout })
+ describe('getFileName', () => {
+ let originalPlatform
+
+ const mockPlatform = (platform) => {
+ Object.defineProperty(process, 'platform', { value: platform })
+ }
+
+ beforeEach(() => {
+ // Save the original process.platform descriptor
+ originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
+ })
+
+ afterEach(() => {
+ // Restore the original process.platform descriptor
+ Object.defineProperty(process, 'platform', originalPlatform)
+ })
+
+ it('should return the executable file name with .exe extension on Windows', () => {
+ const binary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)
+ mockPlatform('win32')
+
+ const result = binary.getFileName()
+
+ expect(result).to.equal('ffmpeg.exe')
+ })
+
+ it('should return the executable file name without extension on linux', () => {
+ const binary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)
+ mockPlatform('linux')
+
+ const result = binary.getFileName()
+
+ expect(result).to.equal('ffmpeg')
+ })
+
+ it('should return the library file name with .dll extension on Windows', () => {
+ const binary = new Binary('ffmpeg', 'library', 'FFMPEG_PATH', ['5.1'], ffbinaries)
+ mockPlatform('win32')
- const result = await binaryManager.isBinaryGood(binaryPath, goodVersions)
+ const result = binary.getFileName()
- expect(result).to.be.true
- expect(fsPathExistsStub.calledOnce).to.be.true
- expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
- expect(execStub.calledOnce).to.be.true
- expect(execStub.calledWith(execCommand)).to.be.true
+ expect(result).to.equal('ffmpeg.dll')
+ })
+
+ it('should return the library file name with .so extension on linux', () => {
+ const binary = new Binary('ffmpeg', 'library', 'FFMPEG_PATH', ['5.1'], ffbinaries)
+ mockPlatform('linux')
+
+ const result = binary.getFileName()
+
+ expect(result).to.equal('ffmpeg.so')
+ })
+
+ it('should return the file name without extension for other types', () => {
+ const binary = new Binary('ffmpeg', 'other', 'FFMPEG_PATH', ['5.1'], ffbinaries)
+ mockPlatform('win32')
+
+ const result = binary.getFileName()
+
+ expect(result).to.equal('ffmpeg')
+ })
})
-})
\ No newline at end of file
+})
diff --git a/test/server/objects/TrackProgressMonitor.test.js b/test/server/objects/TrackProgressMonitor.test.js
new file mode 100644
index 0000000000..b809161f5c
--- /dev/null
+++ b/test/server/objects/TrackProgressMonitor.test.js
@@ -0,0 +1,95 @@
+const chai = require('chai')
+const sinon = require('sinon')
+const TrackProgressMonitor = require('../../../server/objects/TrackProgressMonitor')
+
+const expect = chai.expect
+
+describe('TrackProgressMonitor', () => {
+ let trackDurations
+ let trackStartedCallback
+ let progressCallback
+ let trackFinishedCallback
+ let monitor
+
+ beforeEach(() => {
+ trackDurations = [10, 40, 50]
+ trackStartedCallback = sinon.spy()
+ progressCallback = sinon.spy()
+ trackFinishedCallback = sinon.spy()
+ })
+
+ it('should initialize correctly', () => {
+ monitor = new TrackProgressMonitor(trackDurations, trackStartedCallback, progressCallback, trackFinishedCallback)
+
+ expect(monitor.trackDurations).to.deep.equal(trackDurations)
+ expect(monitor.totalDuration).to.equal(100)
+ expect(monitor.trackStartedCallback).to.equal(trackStartedCallback)
+ expect(monitor.progressCallback).to.equal(progressCallback)
+ expect(monitor.trackFinishedCallback).to.equal(trackFinishedCallback)
+ expect(monitor.currentTrackIndex).to.equal(0)
+ expect(monitor.cummulativeProgress).to.equal(0)
+ expect(monitor.currentTrackPercentage).to.equal(10)
+ expect(monitor.numTracks).to.equal(trackDurations.length)
+ expect(monitor.allTracksFinished).to.be.false
+ })
+
+ it('should update the progress', () => {
+ monitor = new TrackProgressMonitor(trackDurations, trackStartedCallback, progressCallback, trackFinishedCallback)
+ monitor.update(5)
+
+ expect(monitor.currentTrackIndex).to.equal(0)
+ expect(monitor.cummulativeProgress).to.equal(0)
+ expect(monitor.currentTrackPercentage).to.equal(10)
+ expect(trackStartedCallback.calledOnceWithExactly(0)).to.be.true
+ expect(progressCallback.calledOnceWithExactly(0, 50, 5)).to.be.true
+ expect(trackFinishedCallback.notCalled).to.be.true
+ })
+
+ it('should update the progress multiple times on the same track', () => {
+ monitor = new TrackProgressMonitor(trackDurations, trackStartedCallback, progressCallback, trackFinishedCallback)
+ monitor.update(5)
+ monitor.update(7)
+
+ expect(monitor.currentTrackIndex).to.equal(0)
+ expect(monitor.cummulativeProgress).to.equal(0)
+ expect(monitor.currentTrackPercentage).to.equal(10)
+ expect(trackStartedCallback.calledOnceWithExactly(0)).to.be.true
+ expect(progressCallback.calledTwice).to.be.true
+ expect(progressCallback.calledWithExactly(0, 50, 5)).to.be.true
+ expect(progressCallback.calledWithExactly(0, 70, 7)).to.be.true
+ expect(trackFinishedCallback.notCalled).to.be.true
+ })
+
+ it('should update the progress multiple times on different tracks', () => {
+ monitor = new TrackProgressMonitor(trackDurations, trackStartedCallback, progressCallback, trackFinishedCallback)
+ monitor.update(5)
+ monitor.update(20)
+
+ expect(monitor.currentTrackIndex).to.equal(1)
+ expect(monitor.cummulativeProgress).to.equal(10)
+ expect(monitor.currentTrackPercentage).to.equal(40)
+ expect(trackStartedCallback.calledTwice).to.be.true
+ expect(trackStartedCallback.calledWithExactly(0)).to.be.true
+ expect(trackStartedCallback.calledWithExactly(1)).to.be.true
+ expect(progressCallback.calledTwice).to.be.true
+ expect(progressCallback.calledWithExactly(0, 50, 5)).to.be.true
+ expect(progressCallback.calledWithExactly(1, 25, 20)).to.be.true
+ expect(trackFinishedCallback.calledOnceWithExactly(0)).to.be.true
+ })
+
+ it('should finish all tracks', () => {
+ monitor = new TrackProgressMonitor(trackDurations, trackStartedCallback, progressCallback, trackFinishedCallback)
+ monitor.finish()
+
+ expect(monitor.allTracksFinished).to.be.true
+ expect(trackStartedCallback.calledThrice).to.be.true
+ expect(trackFinishedCallback.calledThrice).to.be.true
+ expect(progressCallback.notCalled).to.be.true
+ expect(trackStartedCallback.calledWithExactly(0)).to.be.true
+ expect(trackFinishedCallback.calledWithExactly(0)).to.be.true
+ expect(trackStartedCallback.calledWithExactly(1)).to.be.true
+ expect(trackFinishedCallback.calledWithExactly(1)).to.be.true
+ expect(trackStartedCallback.calledWithExactly(2)).to.be.true
+ expect(trackFinishedCallback.calledWithExactly(2)).to.be.true
+ })
+})
diff --git a/test/server/utils/ffmpegHelpers.test.js b/test/server/utils/ffmpegHelpers.test.js
index 82daf1ba0d..95a2c585b1 100644
--- a/test/server/utils/ffmpegHelpers.test.js
+++ b/test/server/utils/ffmpegHelpers.test.js
@@ -1,9 +1,11 @@
const { expect } = require('chai')
const sinon = require('sinon')
-const { generateFFMetadata, addCoverAndMetadataToFile } = require('../../../server/utils/ffmpegHelpers')
+const fileUtils = require('../../../server/utils/fileUtils')
const fs = require('../../../server/libs/fsExtra')
const EventEmitter = require('events')
+const { generateFFMetadata, addCoverAndMetadataToFile } = require('../../../server/utils/ffmpegHelpers')
+
global.isWin = process.platform === 'win32'
describe('generateFFMetadata', () => {
@@ -81,10 +83,10 @@ describe('addCoverAndMetadataToFile', () => {
ffmpegStub.run = sinon.stub().callsFake(() => {
ffmpegStub.emit('end')
})
- const fsCopyFileSyncStub = sinon.stub(fs, 'copyFileSync')
- const fsUnlinkSyncStub = sinon.stub(fs, 'unlinkSync')
+ const copyStub = sinon.stub().resolves()
+ const fsRemoveStub = sinon.stub(fs, 'remove').resolves()
- return { audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub, fsCopyFileSyncStub, fsUnlinkSyncStub }
+ return { audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub, copyStub, fsRemoveStub }
}
let audioFilePath = null
@@ -93,8 +95,8 @@ describe('addCoverAndMetadataToFile', () => {
let track = null
let mimeType = null
let ffmpegStub = null
- let fsCopyFileSyncStub = null
- let fsUnlinkSyncStub = null
+ let copyStub = null
+ let fsRemoveStub = null
beforeEach(() => {
const input = createTestSetup()
audioFilePath = input.audioFilePath
@@ -103,16 +105,15 @@ describe('addCoverAndMetadataToFile', () => {
track = input.track
mimeType = input.mimeType
ffmpegStub = input.ffmpegStub
- fsCopyFileSyncStub = input.fsCopyFileSyncStub
- fsUnlinkSyncStub = input.fsUnlinkSyncStub
+ copyStub = input.copyStub
+ fsRemoveStub = input.fsRemoveStub
})
it('should add cover image and metadata to audio file', async () => {
// Act
- const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
+ await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, null, ffmpegStub, copyStub)
// Assert
- expect(result).to.be.true
expect(ffmpegStub.input.calledThrice).to.be.true
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
@@ -129,12 +130,11 @@ describe('addCoverAndMetadataToFile', () => {
expect(ffmpegStub.run.calledOnce).to.be.true
- expect(fsCopyFileSyncStub.calledOnce).to.be.true
- expect(fsCopyFileSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
- expect(fsCopyFileSyncStub.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
-
- expect(fsUnlinkSyncStub.calledOnce).to.be.true
- expect(fsUnlinkSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
+ expect(copyStub.calledOnce).to.be.true
+ expect(copyStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
+ expect(copyStub.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
+ expect(fsRemoveStub.calledOnce).to.be.true
+ expect(fsRemoveStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
// Restore the stub
sinon.restore()
@@ -145,10 +145,9 @@ describe('addCoverAndMetadataToFile', () => {
coverFilePath = null
// Act
- const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
+ await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, null, ffmpegStub, copyStub)
// Assert
- expect(result).to.be.true
expect(ffmpegStub.input.calledTwice).to.be.true
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
@@ -164,12 +163,11 @@ describe('addCoverAndMetadataToFile', () => {
expect(ffmpegStub.run.calledOnce).to.be.true
- expect(fsCopyFileSyncStub.calledOnce).to.be.true
- expect(fsCopyFileSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
- expect(fsCopyFileSyncStub.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
-
- expect(fsUnlinkSyncStub.calledOnce).to.be.true
- expect(fsUnlinkSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
+ expect(copyStub.callCount).to.equal(1)
+ expect(copyStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
+ expect(copyStub.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
+ expect(fsRemoveStub.calledOnce).to.be.true
+ expect(fsRemoveStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
// Restore the stub
sinon.restore()
@@ -182,10 +180,15 @@ describe('addCoverAndMetadataToFile', () => {
})
// Act
- const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
+ try {
+ await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, null, ffmpegStub, copyStub)
+ expect.fail('Expected an error to be thrown')
+ } catch (error) {
+ // Assert
+ expect(error.message).to.equal('FFmpeg error')
+ }
// Assert
- expect(result).to.be.false
expect(ffmpegStub.input.calledThrice).to.be.true
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
@@ -202,9 +205,8 @@ describe('addCoverAndMetadataToFile', () => {
expect(ffmpegStub.run.calledOnce).to.be.true
- expect(fsCopyFileSyncStub.called).to.be.false
-
- expect(fsUnlinkSyncStub.called).to.be.false
+ expect(copyStub.called).to.be.false
+ expect(fsRemoveStub.called).to.be.false
// Restore the stub
sinon.restore()
@@ -216,10 +218,9 @@ describe('addCoverAndMetadataToFile', () => {
audioFilePath = '/path/to/audio/file.m4b'
// Act
- const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
+ await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, null, ffmpegStub, copyStub)
// Assert
- expect(result).to.be.true
expect(ffmpegStub.input.calledThrice).to.be.true
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
@@ -236,12 +237,11 @@ describe('addCoverAndMetadataToFile', () => {
expect(ffmpegStub.run.calledOnce).to.be.true
- expect(fsCopyFileSyncStub.calledOnce).to.be.true
- expect(fsCopyFileSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
- expect(fsCopyFileSyncStub.firstCall.args[1]).to.equal('/path/to/audio/file.m4b')
-
- expect(fsUnlinkSyncStub.calledOnce).to.be.true
- expect(fsUnlinkSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
+ expect(copyStub.calledOnce).to.be.true
+ expect(copyStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
+ expect(copyStub.firstCall.args[1]).to.equal('/path/to/audio/file.m4b')
+ expect(fsRemoveStub.calledOnce).to.be.true
+ expect(fsRemoveStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
// Restore the stub
sinon.restore()
diff --git a/test/server/utils/parsers/parseNfoMetadata.test.js b/test/server/utils/parsers/parseNfoMetadata.test.js
index 70e6a096f7..9ff51fbe52 100644
--- a/test/server/utils/parsers/parseNfoMetadata.test.js
+++ b/test/server/utils/parsers/parseNfoMetadata.test.js
@@ -103,6 +103,16 @@ describe('parseNfoMetadata', () => {
expect(result.asin).to.equal('B08X5JZJLH')
})
+ it('parses language', () => {
+ const nfoText = 'Language: eng'
+ const result = parseNfoMetadata(nfoText)
+ expect(result.language).to.equal('eng')
+
+ const nfoText2 = 'lang: deu'
+ const result2 = parseNfoMetadata(nfoText2)
+ expect(result2.language).to.equal('deu')
+ })
+
it('parses description', () => {
const nfoText = 'Book Description\n=========\nThis is a book.\n It\'s good'
const result = parseNfoMetadata(nfoText)