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 --from=build-client /client/dist /app/client/dist
|
||||
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
|
||||
|
||||
|
@ -94,6 +94,9 @@ export default {
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
userCanAccessExplicitContent() {
|
||||
return this.$store.getters['user/getUserCanAccessExplicitContent']
|
||||
},
|
||||
libraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
@ -239,6 +242,15 @@ export default {
|
||||
sublist: false
|
||||
}
|
||||
]
|
||||
|
||||
if (this.userCanAccessExplicitContent) {
|
||||
items.push({
|
||||
text: this.$strings.LabelExplicit,
|
||||
value: 'explicit',
|
||||
sublist: false
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userIsAdminOrUp) {
|
||||
items.push({
|
||||
text: this.$strings.LabelShareOpen,
|
||||
@ -249,7 +261,7 @@ export default {
|
||||
return items
|
||||
},
|
||||
podcastItems() {
|
||||
return [
|
||||
const items = [
|
||||
{
|
||||
text: this.$strings.LabelAll,
|
||||
value: 'all'
|
||||
@ -283,6 +295,16 @@ export default {
|
||||
sublist: false
|
||||
}
|
||||
]
|
||||
|
||||
if (this.userCanAccessExplicitContent) {
|
||||
items.push({
|
||||
text: this.$strings.LabelExplicit,
|
||||
value: 'explicit',
|
||||
sublist: false
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
selectItems() {
|
||||
if (this.isSeries) return this.seriesItems
|
||||
|
@ -35,7 +35,14 @@
|
||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||
</div>
|
||||
<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>
|
||||
|
@ -16,7 +16,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div class="w-full h-px bg-white/5 my-4" />
|
||||
@ -34,6 +34,12 @@
|
||||
{{ audioFileSize }}
|
||||
</p>
|
||||
</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>
|
||||
</modals-modal>
|
||||
@ -68,7 +74,7 @@ export default {
|
||||
return this.episode.title || 'No Episode Title'
|
||||
},
|
||||
description() {
|
||||
return this.episode.description || ''
|
||||
return this.parseDescription(this.episode.description || '')
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem?.media || {}
|
||||
@ -90,11 +96,49 @@ export default {
|
||||
|
||||
return this.$bytesPretty(size)
|
||||
},
|
||||
audioFileDuration() {
|
||||
const duration = this.episode.duration || 0
|
||||
return this.$elapsedPretty(duration)
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
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() {}
|
||||
}
|
||||
</script>
|
||||
|
@ -58,6 +58,9 @@ export const getters = {
|
||||
getUserCanAccessAllLibraries: (state) => {
|
||||
return !!state.user?.permissions?.accessAllLibraries
|
||||
},
|
||||
getUserCanAccessExplicitContent: (state) => {
|
||||
return !!state.user?.permissions?.accessExplicitContent
|
||||
},
|
||||
getLibrariesAccessible: (state, getters) => {
|
||||
if (!state.user) 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.AllowIframe) process.env.ALLOW_IFRAME = '1'
|
||||
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.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf'
|
||||
}
|
||||
|
@ -442,7 +442,17 @@ class Auth {
|
||||
// Local strategy login route (takes username and password)
|
||||
router.post('/login', passport.authenticate('local'), async (req, res) => {
|
||||
// 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)
|
||||
@ -718,6 +728,7 @@ class Auth {
|
||||
const authMethod = req.cookies.auth_method
|
||||
|
||||
res.clearCookie('auth_method')
|
||||
res.clearCookie('auth_token')
|
||||
|
||||
let logoutUrl = null
|
||||
|
||||
|
@ -766,14 +766,25 @@ class Database {
|
||||
Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`)
|
||||
}
|
||||
|
||||
// Remove mediaProgresses with duplicate mediaItemId (remove the oldest updatedAt)
|
||||
// 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)`)
|
||||
// for (const duplicateMediaProgress of duplicateMediaProgresses) {
|
||||
// Logger.warn(`Found duplicate mediaProgress for mediaItem "${duplicateMediaProgress.mediaItemId}" - removing it`)
|
||||
// await this.mediaProgressModel.destroy({
|
||||
// where: { id: duplicateMediaProgress.id }
|
||||
// })
|
||||
// }
|
||||
// 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 mp1.id, mp1.mediaItemId
|
||||
FROM mediaProgresses mp1
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
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) {
|
||||
|
@ -220,6 +220,7 @@ class Server {
|
||||
|
||||
async start() {
|
||||
Logger.info('=== Starting Server ===')
|
||||
|
||||
this.initProcessEventListeners()
|
||||
await this.init()
|
||||
|
||||
@ -281,6 +282,7 @@ class Server {
|
||||
await this.auth.initPassportJs()
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// if RouterBasePath is set, modify all requests to include the base path
|
||||
app.use((req, res, next) => {
|
||||
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
|
||||
@ -313,10 +315,6 @@ class Server {
|
||||
router.use('/hls', this.hlsRouter.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
|
||||
router.use(express.static(Path.join(global.appRoot, 'static')))
|
||||
|
||||
@ -336,32 +334,6 @@ class Server {
|
||||
// Auth routes
|
||||
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) => {
|
||||
if (Database.hasRootUser) {
|
||||
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))
|
||||
|
||||
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/'
|
||||
if (this.Host?.startsWith(unixSocketPrefix)) {
|
||||
const sockPath = this.Host.slice(unixSocketPrefix.length)
|
||||
|
@ -52,9 +52,7 @@ class FantLab {
|
||||
return []
|
||||
})
|
||||
|
||||
return Promise.all(items.map(async (item) => await this.getWork(item, timeout))).then((resArray) => {
|
||||
return resArray.filter((res) => res)
|
||||
})
|
||||
return Promise.all(items.map(async (item) => await this.getWork(item, timeout))).then((resArray) => resArray.filter(Boolean))
|
||||
}
|
||||
|
||||
/**
|
||||
@ -83,6 +81,10 @@ class FantLab {
|
||||
return null
|
||||
})
|
||||
|
||||
if (!bookData) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.cleanBookData(bookData, timeout)
|
||||
}
|
||||
|
||||
|
@ -25,6 +25,7 @@ const Fuse = require('../libs/fusejs')
|
||||
* @property {string} episode
|
||||
* @property {string} author
|
||||
* @property {string} duration
|
||||
* @property {number|null} durationSeconds - Parsed from duration string if duration is valid
|
||||
* @property {string} explicit
|
||||
* @property {number} publishedAt - Unix timestamp
|
||||
* @property {{ url: string, type?: string, length?: string }} enclosure
|
||||
@ -217,8 +218,9 @@ function extractEpisodeData(item) {
|
||||
})
|
||||
|
||||
// Extract psc:chapters if duration is set
|
||||
let episodeDuration = !isNaN(episode.duration) ? timestampToSeconds(episode.duration) : null
|
||||
if (item['psc:chapters']?.[0]?.['psc:chapter']?.length && episodeDuration) {
|
||||
episode.durationSeconds = episode.duration ? timestampToSeconds(episode.duration) : null
|
||||
|
||||
if (item['psc:chapters']?.[0]?.['psc:chapter']?.length && episode.durationSeconds) {
|
||||
// Example chapter:
|
||||
// {"id":0,"start":0,"end":43.004286,"title":"chapter 1"}
|
||||
|
||||
@ -244,7 +246,7 @@ function extractEpisodeData(item) {
|
||||
} else {
|
||||
episode.chapters = cleanedChapters.map((chapter, index) => {
|
||||
const nextChapter = cleanedChapters[index + 1]
|
||||
const end = nextChapter ? nextChapter.start : episodeDuration
|
||||
const end = nextChapter ? nextChapter.start : episode.durationSeconds
|
||||
return {
|
||||
id: chapter.id,
|
||||
title: chapter.title,
|
||||
@ -273,6 +275,7 @@ function cleanEpisodeData(data) {
|
||||
episode: data.episode || '',
|
||||
author: data.author || '',
|
||||
duration: data.duration || '',
|
||||
durationSeconds: data.durationSeconds || null,
|
||||
explicit: data.explicit || '',
|
||||
publishedAt,
|
||||
enclosure: data.enclosure,
|
||||
|
@ -186,6 +186,8 @@ module.exports = {
|
||||
mediaWhere['$series.id$'] = null
|
||||
} else if (group === 'abridged') {
|
||||
mediaWhere['abridged'] = true
|
||||
} else if (group === 'explicit') {
|
||||
mediaWhere['explicit'] = true
|
||||
} 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)`), {
|
||||
[Sequelize.Op.gte]: 1
|
||||
@ -251,6 +253,15 @@ module.exports = {
|
||||
*/
|
||||
getOrder(sortBy, sortDesc, collapseseries) {
|
||||
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') {
|
||||
return [[Sequelize.literal('libraryItem.createdAt'), dir]]
|
||||
} else if (sortBy === 'size') {
|
||||
@ -264,25 +275,16 @@ module.exports = {
|
||||
} else if (sortBy === 'media.metadata.publishedYear') {
|
||||
return [[Sequelize.literal(`CAST(\`book\`.\`publishedYear\` AS INTEGER)`), dir]]
|
||||
} else if (sortBy === 'media.metadata.authorNameLF') {
|
||||
return [
|
||||
[Sequelize.literal('`libraryItem`.`authorNamesLastFirst` COLLATE NOCASE'), dir],
|
||||
[Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]
|
||||
]
|
||||
// Sort by author name last first, secondary sort by title
|
||||
return [[Sequelize.literal('`libraryItem`.`authorNamesLastFirst` COLLATE NOCASE'), dir], getTitleOrder()]
|
||||
} else if (sortBy === 'media.metadata.authorName') {
|
||||
return [
|
||||
[Sequelize.literal('`libraryItem`.`authorNamesFirstLast` COLLATE NOCASE'), dir],
|
||||
[Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]
|
||||
]
|
||||
// Sort by author name first last, secondary sort by title
|
||||
return [[Sequelize.literal('`libraryItem`.`authorNamesFirstLast` COLLATE NOCASE'), dir], getTitleOrder()]
|
||||
} else if (sortBy === 'media.metadata.title') {
|
||||
if (collapseseries) {
|
||||
return [[Sequelize.literal('display_title COLLATE NOCASE'), dir]]
|
||||
}
|
||||
|
||||
if (global.ServerSettings.sortingIgnorePrefix) {
|
||||
return [[Sequelize.literal('`libraryItem`.`titleIgnorePrefix` COLLATE NOCASE'), dir]]
|
||||
} else {
|
||||
return [[Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]]
|
||||
}
|
||||
return [getTitleOrder()]
|
||||
} else if (sortBy === 'sequence') {
|
||||
const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST'
|
||||
return [[Sequelize.literal(`CAST(\`series.bookSeries.sequence\` AS FLOAT) ${nullDir}`)]]
|
||||
|
@ -59,6 +59,8 @@ module.exports = {
|
||||
replacements.filterValue = value
|
||||
} else if (group === 'languages') {
|
||||
mediaWhere['language'] = value
|
||||
} else if (group === 'explicit') {
|
||||
mediaWhere['explicit'] = true
|
||||
}
|
||||
|
||||
return {
|
||||
|
Loading…
x
Reference in New Issue
Block a user