Merge branch 'advplyr:master' into audible-confidence-score

This commit is contained in:
mikiher 2025-06-26 18:09:13 +03:00 committed by GitHub
commit 9c44fc0d01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 187 additions and 65 deletions

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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 []

View File

@ -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'
} }

View File

@ -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

View File

@ -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) {

View File

@ -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)

View File

@ -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)
} }

View File

@ -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,

View File

@ -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}`)]]

View File

@ -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 {