mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-07-31 14:33:52 -04:00
Merge branch 'advplyr:master' into audible-confidence-score
This commit is contained in:
commit
9c44fc0d01
@ -57,7 +57,7 @@ WORKDIR /app
|
|||||||
# Copy compiled frontend and server from build stages
|
# Copy compiled frontend and server from build stages
|
||||||
COPY --from=build-client /client/dist /app/client/dist
|
COPY --from=build-client /client/dist /app/client/dist
|
||||||
COPY --from=build-server /server /app
|
COPY --from=build-server /server /app
|
||||||
COPY --from=build-server /usr/local/lib/nusqlite3 /usr/local/lib/nusqlite3
|
COPY --from=build-server ${NUSQLITE3_PATH} ${NUSQLITE3_PATH}
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
|
@ -94,6 +94,9 @@ export default {
|
|||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
|
userCanAccessExplicitContent() {
|
||||||
|
return this.$store.getters['user/getUserCanAccessExplicitContent']
|
||||||
|
},
|
||||||
libraryMediaType() {
|
libraryMediaType() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
},
|
},
|
||||||
@ -239,6 +242,15 @@ export default {
|
|||||||
sublist: false
|
sublist: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (this.userCanAccessExplicitContent) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelExplicit,
|
||||||
|
value: 'explicit',
|
||||||
|
sublist: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (this.userIsAdminOrUp) {
|
if (this.userIsAdminOrUp) {
|
||||||
items.push({
|
items.push({
|
||||||
text: this.$strings.LabelShareOpen,
|
text: this.$strings.LabelShareOpen,
|
||||||
@ -249,7 +261,7 @@ export default {
|
|||||||
return items
|
return items
|
||||||
},
|
},
|
||||||
podcastItems() {
|
podcastItems() {
|
||||||
return [
|
const items = [
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelAll,
|
text: this.$strings.LabelAll,
|
||||||
value: 'all'
|
value: 'all'
|
||||||
@ -283,6 +295,16 @@ export default {
|
|||||||
sublist: false
|
sublist: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (this.userCanAccessExplicitContent) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelExplicit,
|
||||||
|
value: 'explicit',
|
||||||
|
sublist: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
},
|
},
|
||||||
selectItems() {
|
selectItems() {
|
||||||
if (this.isSeries) return this.seriesItems
|
if (this.isSeries) return this.seriesItems
|
||||||
|
@ -35,7 +35,14 @@
|
|||||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="episode.subtitle" class="mb-1 text-sm text-gray-300 line-clamp-2">{{ episode.subtitle }}</p>
|
<p v-if="episode.subtitle" class="mb-1 text-sm text-gray-300 line-clamp-2">{{ episode.subtitle }}</p>
|
||||||
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
<div class="flex items-center space-x-2">
|
||||||
|
<!-- published -->
|
||||||
|
<p class="text-xs text-gray-300 w-40">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||||
|
<!-- duration -->
|
||||||
|
<p v-if="episode.durationSeconds && !isNaN(episode.durationSeconds)" class="text-xs text-gray-300 min-w-28">{{ $strings.LabelDuration }}: {{ $elapsedPretty(episode.durationSeconds) }}</p>
|
||||||
|
<!-- size -->
|
||||||
|
<p v-if="episode.enclosure?.length && !isNaN(episode.enclosure.length) && Number(episode.enclosure.length) > 0" class="text-xs text-gray-300">{{ $strings.LabelSize }}: {{ $bytesPretty(Number(episode.enclosure.length)) }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
|
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
|
||||||
<div v-if="description" dir="auto" class="default-style less-spacing" v-html="description" />
|
<div v-if="description" dir="auto" class="default-style less-spacing" @click="handleDescriptionClick" v-html="description" />
|
||||||
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
|
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
|
||||||
|
|
||||||
<div class="w-full h-px bg-white/5 my-4" />
|
<div class="w-full h-px bg-white/5 my-4" />
|
||||||
@ -34,6 +34,12 @@
|
|||||||
{{ audioFileSize }}
|
{{ audioFileSize }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grow">
|
||||||
|
<p class="font-semibold text-xs mb-1">{{ $strings.LabelDuration }}</p>
|
||||||
|
<p class="mb-2 text-xs">
|
||||||
|
{{ audioFileDuration }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
@ -68,7 +74,7 @@ export default {
|
|||||||
return this.episode.title || 'No Episode Title'
|
return this.episode.title || 'No Episode Title'
|
||||||
},
|
},
|
||||||
description() {
|
description() {
|
||||||
return this.episode.description || ''
|
return this.parseDescription(this.episode.description || '')
|
||||||
},
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem?.media || {}
|
return this.libraryItem?.media || {}
|
||||||
@ -90,11 +96,49 @@ export default {
|
|||||||
|
|
||||||
return this.$bytesPretty(size)
|
return this.$bytesPretty(size)
|
||||||
},
|
},
|
||||||
|
audioFileDuration() {
|
||||||
|
const duration = this.episode.duration || 0
|
||||||
|
return this.$elapsedPretty(duration)
|
||||||
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {
|
||||||
|
handleDescriptionClick(e) {
|
||||||
|
if (e.target.matches('span.time-marker')) {
|
||||||
|
const time = parseInt(e.target.dataset.time)
|
||||||
|
if (!isNaN(time)) {
|
||||||
|
this.$eventBus.$emit('play-item', {
|
||||||
|
episodeId: this.episodeId,
|
||||||
|
libraryItemId: this.libraryItem.id,
|
||||||
|
startTime: time
|
||||||
|
})
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parseDescription(description) {
|
||||||
|
const timeMarkerLinkRegex = /<a href="#([^"]*?\b\d{1,2}:\d{1,2}(?::\d{1,2})?)">(.*?)<\/a>/g
|
||||||
|
const timeMarkerRegex = /\b\d{1,2}:\d{1,2}(?::\d{1,2})?\b/g
|
||||||
|
|
||||||
|
function convertToSeconds(time) {
|
||||||
|
const timeParts = time.split(':').map(Number)
|
||||||
|
return timeParts.reduce((acc, part, index) => acc * 60 + part, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return description
|
||||||
|
.replace(timeMarkerLinkRegex, (match, href, displayTime) => {
|
||||||
|
const time = displayTime.match(timeMarkerRegex)[0]
|
||||||
|
const seekTimeInSeconds = convertToSeconds(time)
|
||||||
|
return `<span class="time-marker cursor-pointer text-blue-400 hover:text-blue-300" data-time="${seekTimeInSeconds}">${displayTime}</span>`
|
||||||
|
})
|
||||||
|
.replace(timeMarkerRegex, (match) => {
|
||||||
|
const seekTimeInSeconds = convertToSeconds(match)
|
||||||
|
return `<span class="time-marker cursor-pointer text-blue-400 hover:text-blue-300" data-time="${seekTimeInSeconds}">${match}</span>`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -58,6 +58,9 @@ export const getters = {
|
|||||||
getUserCanAccessAllLibraries: (state) => {
|
getUserCanAccessAllLibraries: (state) => {
|
||||||
return !!state.user?.permissions?.accessAllLibraries
|
return !!state.user?.permissions?.accessAllLibraries
|
||||||
},
|
},
|
||||||
|
getUserCanAccessExplicitContent: (state) => {
|
||||||
|
return !!state.user?.permissions?.accessExplicitContent
|
||||||
|
},
|
||||||
getLibrariesAccessible: (state, getters) => {
|
getLibrariesAccessible: (state, getters) => {
|
||||||
if (!state.user) return []
|
if (!state.user) return []
|
||||||
if (getters.getUserCanAccessAllLibraries) return []
|
if (getters.getUserCanAccessAllLibraries) return []
|
||||||
|
1
index.js
1
index.js
@ -28,6 +28,7 @@ if (isDev) {
|
|||||||
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'
|
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'
|
||||||
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
|
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
|
||||||
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
|
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
|
||||||
|
if (devEnv.ReactClientPath) process.env.REACT_CLIENT_PATH = devEnv.ReactClientPath
|
||||||
process.env.SOURCE = 'local'
|
process.env.SOURCE = 'local'
|
||||||
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf'
|
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf'
|
||||||
}
|
}
|
||||||
|
@ -442,7 +442,17 @@ class Auth {
|
|||||||
// Local strategy login route (takes username and password)
|
// Local strategy login route (takes username and password)
|
||||||
router.post('/login', passport.authenticate('local'), async (req, res) => {
|
router.post('/login', passport.authenticate('local'), async (req, res) => {
|
||||||
// return the user login response json if the login was successfull
|
// return the user login response json if the login was successfull
|
||||||
res.json(await this.getUserLoginResponsePayload(req.user))
|
const userResponse = await this.getUserLoginResponsePayload(req.user)
|
||||||
|
|
||||||
|
// Experimental Next.js client uses bearer token in cookies
|
||||||
|
res.cookie('auth_token', userResponse.user.token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: req.secure || req.get('x-forwarded-proto') === 'https',
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 1000 * 60 * 60 * 24 * 7 // 7 days
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json(userResponse)
|
||||||
})
|
})
|
||||||
|
|
||||||
// openid strategy login route (this redirects to the configured openid login provider)
|
// openid strategy login route (this redirects to the configured openid login provider)
|
||||||
@ -718,6 +728,7 @@ class Auth {
|
|||||||
const authMethod = req.cookies.auth_method
|
const authMethod = req.cookies.auth_method
|
||||||
|
|
||||||
res.clearCookie('auth_method')
|
res.clearCookie('auth_method')
|
||||||
|
res.clearCookie('auth_token')
|
||||||
|
|
||||||
let logoutUrl = null
|
let logoutUrl = null
|
||||||
|
|
||||||
|
@ -766,14 +766,25 @@ class Database {
|
|||||||
Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`)
|
Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove mediaProgresses with duplicate mediaItemId (remove the oldest updatedAt)
|
// Remove mediaProgresses with duplicate mediaItemId (remove the oldest updatedAt or if updatedAt is the same, remove arbitrary one)
|
||||||
// const [duplicateMediaProgresses] = await this.sequelize.query(`SELECT id, mediaItemId FROM mediaProgresses WHERE (mediaItemId, userId, updatedAt) IN (SELECT mediaItemId, userId, MIN(updatedAt) FROM mediaProgresses GROUP BY mediaItemId, userId HAVING COUNT(*) > 1)`)
|
const [duplicateMediaProgresses] = await this.sequelize.query(`SELECT mp1.id, mp1.mediaItemId
|
||||||
// for (const duplicateMediaProgress of duplicateMediaProgresses) {
|
FROM mediaProgresses mp1
|
||||||
// Logger.warn(`Found duplicate mediaProgress for mediaItem "${duplicateMediaProgress.mediaItemId}" - removing it`)
|
WHERE EXISTS (
|
||||||
// await this.mediaProgressModel.destroy({
|
SELECT 1
|
||||||
// where: { id: duplicateMediaProgress.id }
|
FROM mediaProgresses mp2
|
||||||
// })
|
WHERE mp2.mediaItemId = mp1.mediaItemId
|
||||||
// }
|
AND mp2.userId = mp1.userId
|
||||||
|
AND (
|
||||||
|
mp2.updatedAt > mp1.updatedAt
|
||||||
|
OR (mp2.updatedAt = mp1.updatedAt AND mp2.id < mp1.id)
|
||||||
|
)
|
||||||
|
)`)
|
||||||
|
for (const duplicateMediaProgress of duplicateMediaProgresses) {
|
||||||
|
Logger.warn(`Found duplicate mediaProgress for mediaItem "${duplicateMediaProgress.mediaItemId}" - removing it`)
|
||||||
|
await this.mediaProgressModel.destroy({
|
||||||
|
where: { id: duplicateMediaProgress.id }
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTextSearchQuery(query) {
|
async createTextSearchQuery(query) {
|
||||||
|
@ -220,6 +220,7 @@ class Server {
|
|||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
Logger.info('=== Starting Server ===')
|
Logger.info('=== Starting Server ===')
|
||||||
|
|
||||||
this.initProcessEventListeners()
|
this.initProcessEventListeners()
|
||||||
await this.init()
|
await this.init()
|
||||||
|
|
||||||
@ -281,6 +282,7 @@ class Server {
|
|||||||
await this.auth.initPassportJs()
|
await this.auth.initPassportJs()
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
// if RouterBasePath is set, modify all requests to include the base path
|
// if RouterBasePath is set, modify all requests to include the base path
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
|
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
|
||||||
@ -313,10 +315,6 @@ class Server {
|
|||||||
router.use('/hls', this.hlsRouter.router)
|
router.use('/hls', this.hlsRouter.router)
|
||||||
router.use('/public', this.publicRouter.router)
|
router.use('/public', this.publicRouter.router)
|
||||||
|
|
||||||
// Static path to generated nuxt
|
|
||||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
|
||||||
router.use(express.static(distPath))
|
|
||||||
|
|
||||||
// Static folder
|
// Static folder
|
||||||
router.use(express.static(Path.join(global.appRoot, 'static')))
|
router.use(express.static(Path.join(global.appRoot, 'static')))
|
||||||
|
|
||||||
@ -336,32 +334,6 @@ class Server {
|
|||||||
// Auth routes
|
// Auth routes
|
||||||
await this.auth.initAuthRoutes(router)
|
await this.auth.initAuthRoutes(router)
|
||||||
|
|
||||||
// Client dynamic routes
|
|
||||||
const dynamicRoutes = [
|
|
||||||
'/item/:id',
|
|
||||||
'/author/:id',
|
|
||||||
'/audiobook/:id/chapters',
|
|
||||||
'/audiobook/:id/edit',
|
|
||||||
'/audiobook/:id/manage',
|
|
||||||
'/library/:library',
|
|
||||||
'/library/:library/search',
|
|
||||||
'/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',
|
|
||||||
'/library/:library/podcast/download-queue',
|
|
||||||
'/config/users/:id',
|
|
||||||
'/config/users/:id/sessions',
|
|
||||||
'/config/item-metadata-utils/:id',
|
|
||||||
'/collection/:id',
|
|
||||||
'/playlist/:id',
|
|
||||||
'/share/:slug'
|
|
||||||
]
|
|
||||||
dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
|
||||||
|
|
||||||
router.post('/init', (req, res) => {
|
router.post('/init', (req, res) => {
|
||||||
if (Database.hasRootUser) {
|
if (Database.hasRootUser) {
|
||||||
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
||||||
@ -392,6 +364,48 @@ class Server {
|
|||||||
})
|
})
|
||||||
router.get('/healthcheck', (req, res) => res.sendStatus(200))
|
router.get('/healthcheck', (req, res) => res.sendStatus(200))
|
||||||
|
|
||||||
|
const ReactClientPath = process.env.REACT_CLIENT_PATH
|
||||||
|
if (!ReactClientPath) {
|
||||||
|
// Static path to generated nuxt
|
||||||
|
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||||
|
router.use(express.static(distPath))
|
||||||
|
|
||||||
|
// Client dynamic routes
|
||||||
|
const dynamicRoutes = [
|
||||||
|
'/item/:id',
|
||||||
|
'/author/:id',
|
||||||
|
'/audiobook/:id/chapters',
|
||||||
|
'/audiobook/:id/edit',
|
||||||
|
'/audiobook/:id/manage',
|
||||||
|
'/library/:library',
|
||||||
|
'/library/:library/search',
|
||||||
|
'/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',
|
||||||
|
'/library/:library/podcast/download-queue',
|
||||||
|
'/config/users/:id',
|
||||||
|
'/config/users/:id/sessions',
|
||||||
|
'/config/item-metadata-utils/:id',
|
||||||
|
'/collection/:id',
|
||||||
|
'/playlist/:id',
|
||||||
|
'/share/:slug'
|
||||||
|
]
|
||||||
|
dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
||||||
|
} else {
|
||||||
|
// This is for using the experimental Next.js client
|
||||||
|
Logger.info(`Using React client at ${ReactClientPath}`)
|
||||||
|
const nextPath = Path.join(ReactClientPath, 'node_modules/next')
|
||||||
|
const next = require(nextPath)
|
||||||
|
const nextApp = next({ dev: Logger.isDev, dir: ReactClientPath })
|
||||||
|
const handle = nextApp.getRequestHandler()
|
||||||
|
await nextApp.prepare()
|
||||||
|
router.get('*', (req, res) => handle(req, res))
|
||||||
|
}
|
||||||
|
|
||||||
const unixSocketPrefix = 'unix/'
|
const unixSocketPrefix = 'unix/'
|
||||||
if (this.Host?.startsWith(unixSocketPrefix)) {
|
if (this.Host?.startsWith(unixSocketPrefix)) {
|
||||||
const sockPath = this.Host.slice(unixSocketPrefix.length)
|
const sockPath = this.Host.slice(unixSocketPrefix.length)
|
||||||
|
@ -52,9 +52,7 @@ class FantLab {
|
|||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
|
|
||||||
return Promise.all(items.map(async (item) => await this.getWork(item, timeout))).then((resArray) => {
|
return Promise.all(items.map(async (item) => await this.getWork(item, timeout))).then((resArray) => resArray.filter(Boolean))
|
||||||
return resArray.filter((res) => res)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -83,6 +81,10 @@ class FantLab {
|
|||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!bookData) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return this.cleanBookData(bookData, timeout)
|
return this.cleanBookData(bookData, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,6 +25,7 @@ const Fuse = require('../libs/fusejs')
|
|||||||
* @property {string} episode
|
* @property {string} episode
|
||||||
* @property {string} author
|
* @property {string} author
|
||||||
* @property {string} duration
|
* @property {string} duration
|
||||||
|
* @property {number|null} durationSeconds - Parsed from duration string if duration is valid
|
||||||
* @property {string} explicit
|
* @property {string} explicit
|
||||||
* @property {number} publishedAt - Unix timestamp
|
* @property {number} publishedAt - Unix timestamp
|
||||||
* @property {{ url: string, type?: string, length?: string }} enclosure
|
* @property {{ url: string, type?: string, length?: string }} enclosure
|
||||||
@ -217,8 +218,9 @@ function extractEpisodeData(item) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Extract psc:chapters if duration is set
|
// Extract psc:chapters if duration is set
|
||||||
let episodeDuration = !isNaN(episode.duration) ? timestampToSeconds(episode.duration) : null
|
episode.durationSeconds = episode.duration ? timestampToSeconds(episode.duration) : null
|
||||||
if (item['psc:chapters']?.[0]?.['psc:chapter']?.length && episodeDuration) {
|
|
||||||
|
if (item['psc:chapters']?.[0]?.['psc:chapter']?.length && episode.durationSeconds) {
|
||||||
// Example chapter:
|
// Example chapter:
|
||||||
// {"id":0,"start":0,"end":43.004286,"title":"chapter 1"}
|
// {"id":0,"start":0,"end":43.004286,"title":"chapter 1"}
|
||||||
|
|
||||||
@ -244,7 +246,7 @@ function extractEpisodeData(item) {
|
|||||||
} else {
|
} else {
|
||||||
episode.chapters = cleanedChapters.map((chapter, index) => {
|
episode.chapters = cleanedChapters.map((chapter, index) => {
|
||||||
const nextChapter = cleanedChapters[index + 1]
|
const nextChapter = cleanedChapters[index + 1]
|
||||||
const end = nextChapter ? nextChapter.start : episodeDuration
|
const end = nextChapter ? nextChapter.start : episode.durationSeconds
|
||||||
return {
|
return {
|
||||||
id: chapter.id,
|
id: chapter.id,
|
||||||
title: chapter.title,
|
title: chapter.title,
|
||||||
@ -273,6 +275,7 @@ function cleanEpisodeData(data) {
|
|||||||
episode: data.episode || '',
|
episode: data.episode || '',
|
||||||
author: data.author || '',
|
author: data.author || '',
|
||||||
duration: data.duration || '',
|
duration: data.duration || '',
|
||||||
|
durationSeconds: data.durationSeconds || null,
|
||||||
explicit: data.explicit || '',
|
explicit: data.explicit || '',
|
||||||
publishedAt,
|
publishedAt,
|
||||||
enclosure: data.enclosure,
|
enclosure: data.enclosure,
|
||||||
|
@ -186,6 +186,8 @@ module.exports = {
|
|||||||
mediaWhere['$series.id$'] = null
|
mediaWhere['$series.id$'] = null
|
||||||
} else if (group === 'abridged') {
|
} else if (group === 'abridged') {
|
||||||
mediaWhere['abridged'] = true
|
mediaWhere['abridged'] = true
|
||||||
|
} else if (group === 'explicit') {
|
||||||
|
mediaWhere['explicit'] = true
|
||||||
} else if (['genres', 'tags', 'narrators'].includes(group)) {
|
} else if (['genres', 'tags', 'narrators'].includes(group)) {
|
||||||
mediaWhere[group] = Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(${group}) WHERE json_valid(${group}) AND json_each.value = :filterValue)`), {
|
mediaWhere[group] = Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(${group}) WHERE json_valid(${group}) AND json_each.value = :filterValue)`), {
|
||||||
[Sequelize.Op.gte]: 1
|
[Sequelize.Op.gte]: 1
|
||||||
@ -251,6 +253,15 @@ module.exports = {
|
|||||||
*/
|
*/
|
||||||
getOrder(sortBy, sortDesc, collapseseries) {
|
getOrder(sortBy, sortDesc, collapseseries) {
|
||||||
const dir = sortDesc ? 'DESC' : 'ASC'
|
const dir = sortDesc ? 'DESC' : 'ASC'
|
||||||
|
|
||||||
|
const getTitleOrder = () => {
|
||||||
|
if (global.ServerSettings.sortingIgnorePrefix) {
|
||||||
|
return [Sequelize.literal('`libraryItem`.`titleIgnorePrefix` COLLATE NOCASE'), dir]
|
||||||
|
} else {
|
||||||
|
return [Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (sortBy === 'addedAt') {
|
if (sortBy === 'addedAt') {
|
||||||
return [[Sequelize.literal('libraryItem.createdAt'), dir]]
|
return [[Sequelize.literal('libraryItem.createdAt'), dir]]
|
||||||
} else if (sortBy === 'size') {
|
} else if (sortBy === 'size') {
|
||||||
@ -264,25 +275,16 @@ module.exports = {
|
|||||||
} else if (sortBy === 'media.metadata.publishedYear') {
|
} else if (sortBy === 'media.metadata.publishedYear') {
|
||||||
return [[Sequelize.literal(`CAST(\`book\`.\`publishedYear\` AS INTEGER)`), dir]]
|
return [[Sequelize.literal(`CAST(\`book\`.\`publishedYear\` AS INTEGER)`), dir]]
|
||||||
} else if (sortBy === 'media.metadata.authorNameLF') {
|
} else if (sortBy === 'media.metadata.authorNameLF') {
|
||||||
return [
|
// Sort by author name last first, secondary sort by title
|
||||||
[Sequelize.literal('`libraryItem`.`authorNamesLastFirst` COLLATE NOCASE'), dir],
|
return [[Sequelize.literal('`libraryItem`.`authorNamesLastFirst` COLLATE NOCASE'), dir], getTitleOrder()]
|
||||||
[Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]
|
|
||||||
]
|
|
||||||
} else if (sortBy === 'media.metadata.authorName') {
|
} else if (sortBy === 'media.metadata.authorName') {
|
||||||
return [
|
// Sort by author name first last, secondary sort by title
|
||||||
[Sequelize.literal('`libraryItem`.`authorNamesFirstLast` COLLATE NOCASE'), dir],
|
return [[Sequelize.literal('`libraryItem`.`authorNamesFirstLast` COLLATE NOCASE'), dir], getTitleOrder()]
|
||||||
[Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]
|
|
||||||
]
|
|
||||||
} else if (sortBy === 'media.metadata.title') {
|
} else if (sortBy === 'media.metadata.title') {
|
||||||
if (collapseseries) {
|
if (collapseseries) {
|
||||||
return [[Sequelize.literal('display_title COLLATE NOCASE'), dir]]
|
return [[Sequelize.literal('display_title COLLATE NOCASE'), dir]]
|
||||||
}
|
}
|
||||||
|
return [getTitleOrder()]
|
||||||
if (global.ServerSettings.sortingIgnorePrefix) {
|
|
||||||
return [[Sequelize.literal('`libraryItem`.`titleIgnorePrefix` COLLATE NOCASE'), dir]]
|
|
||||||
} else {
|
|
||||||
return [[Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]]
|
|
||||||
}
|
|
||||||
} else if (sortBy === 'sequence') {
|
} else if (sortBy === 'sequence') {
|
||||||
const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST'
|
const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST'
|
||||||
return [[Sequelize.literal(`CAST(\`series.bookSeries.sequence\` AS FLOAT) ${nullDir}`)]]
|
return [[Sequelize.literal(`CAST(\`series.bookSeries.sequence\` AS FLOAT) ${nullDir}`)]]
|
||||||
|
@ -59,6 +59,8 @@ module.exports = {
|
|||||||
replacements.filterValue = value
|
replacements.filterValue = value
|
||||||
} else if (group === 'languages') {
|
} else if (group === 'languages') {
|
||||||
mediaWhere['language'] = value
|
mediaWhere['language'] = value
|
||||||
|
} else if (group === 'explicit') {
|
||||||
|
mediaWhere['explicit'] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user