mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-06 11:09:37 +00:00
Merge branch 'advplyr:master' into binary-manager
This commit is contained in:
commit
3051b963ef
65 changed files with 2467 additions and 270 deletions
107
server/Auth.js
107
server/Auth.js
|
|
@ -8,6 +8,7 @@ const ExtractJwt = require('passport-jwt').ExtractJwt
|
|||
const OpenIDClient = require('openid-client')
|
||||
const Database = require('./Database')
|
||||
const Logger = require('./Logger')
|
||||
const e = require('express')
|
||||
|
||||
/**
|
||||
* @class Class for handling all the authentication related functionality.
|
||||
|
|
@ -15,6 +16,8 @@ const Logger = require('./Logger')
|
|||
class Auth {
|
||||
|
||||
constructor() {
|
||||
// Map of openId sessions indexed by oauth2 state-variable
|
||||
this.openIdAuthSession = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -187,9 +190,10 @@ class Auth {
|
|||
* @param {import('express').Response} res
|
||||
*/
|
||||
paramsToCookies(req, res) {
|
||||
if (req.query.isRest?.toLowerCase() == 'true') {
|
||||
// Set if isRest flag is set or if mobile oauth flow is used
|
||||
if (req.query.isRest?.toLowerCase() == 'true' || req.query.redirect_uri) {
|
||||
// store the isRest flag to the is_rest cookie
|
||||
res.cookie('is_rest', req.query.isRest.toLowerCase(), {
|
||||
res.cookie('is_rest', 'true', {
|
||||
maxAge: 120000, // 2 min
|
||||
httpOnly: true
|
||||
})
|
||||
|
|
@ -283,8 +287,27 @@ class Auth {
|
|||
// for API or mobile clients
|
||||
const oidcStrategy = passport._strategy('openid-client')
|
||||
const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http'
|
||||
oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString()
|
||||
Logger.debug(`[Auth] Set oidc redirect_uri=${oidcStrategy._params.redirect_uri}`)
|
||||
|
||||
let mobile_redirect_uri = null
|
||||
|
||||
// The client wishes a different redirect_uri
|
||||
// We will allow if it is in the whitelist, by saving it into this.openIdAuthSession and setting the redirect uri to /auth/openid/mobile-redirect
|
||||
// where we will handle the redirect to it
|
||||
if (req.query.redirect_uri) {
|
||||
// Check if the redirect_uri is in the whitelist
|
||||
if (Database.serverSettings.authOpenIDMobileRedirectURIs.includes(req.query.redirect_uri) ||
|
||||
(Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')) {
|
||||
oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/mobile-redirect`).toString()
|
||||
mobile_redirect_uri = req.query.redirect_uri
|
||||
} else {
|
||||
Logger.debug(`[Auth] Invalid redirect_uri=${req.query.redirect_uri} - not in whitelist`)
|
||||
return res.status(400).send('Invalid redirect_uri')
|
||||
}
|
||||
} else {
|
||||
oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString()
|
||||
}
|
||||
|
||||
Logger.debug(`[Auth] Oidc redirect_uri=${oidcStrategy._params.redirect_uri}`)
|
||||
const client = oidcStrategy._client
|
||||
const sessionKey = oidcStrategy._key
|
||||
|
||||
|
|
@ -324,16 +347,21 @@ class Auth {
|
|||
req.session[sessionKey] = {
|
||||
...req.session[sessionKey],
|
||||
...pick(params, 'nonce', 'state', 'max_age', 'response_type'),
|
||||
mobile: req.query.isRest?.toLowerCase() === 'true' // Used in the abs callback later
|
||||
mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out
|
||||
sso_redirect_uri: oidcStrategy._params.redirect_uri // Save the redirect_uri (for the SSO Provider) for the callback
|
||||
}
|
||||
|
||||
// We cannot save redirect_uri in the session, because it the mobile client uses browser instead of the API
|
||||
// for the request to mobile-redirect and as such the session is not shared
|
||||
this.openIdAuthSession.set(params.state, { mobile_redirect_uri: mobile_redirect_uri })
|
||||
|
||||
// Now get the URL to direct to
|
||||
const authorizationUrl = client.authorizationUrl({
|
||||
...params,
|
||||
scope: 'openid profile email',
|
||||
response_type: 'code',
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
code_challenge_method
|
||||
})
|
||||
|
||||
// params (isRest, callback) to a cookie that will be send to the client
|
||||
|
|
@ -347,6 +375,37 @@ class Auth {
|
|||
}
|
||||
})
|
||||
|
||||
// This will be the oauth2 callback route for mobile clients
|
||||
// It will redirect to an app-link like audiobookshelf://oauth
|
||||
router.get('/auth/openid/mobile-redirect', (req, res) => {
|
||||
try {
|
||||
// Extract the state parameter from the request
|
||||
const { state, code } = req.query
|
||||
|
||||
// Check if the state provided is in our list
|
||||
if (!state || !this.openIdAuthSession.has(state)) {
|
||||
Logger.error('[Auth] /auth/openid/mobile-redirect route: State parameter mismatch')
|
||||
return res.status(400).send('State parameter mismatch')
|
||||
}
|
||||
|
||||
let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri
|
||||
|
||||
if (!mobile_redirect_uri) {
|
||||
Logger.error('[Auth] No redirect URI')
|
||||
return res.status(400).send('No redirect URI')
|
||||
}
|
||||
|
||||
this.openIdAuthSession.delete(state)
|
||||
|
||||
const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
|
||||
// Redirect to the overwrite URI saved in the map
|
||||
res.redirect(redirectUri)
|
||||
} catch (error) {
|
||||
Logger.error(`[Auth] Error in /auth/openid/mobile-redirect route: ${error}`)
|
||||
res.status(500).send('Internal Server Error')
|
||||
}
|
||||
})
|
||||
|
||||
// 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')
|
||||
|
|
@ -403,27 +462,45 @@ class Auth {
|
|||
|
||||
// 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
|
||||
if (req.session[sessionKey].mobile) {
|
||||
return passport.authenticate('openid-client', { redirect_uri: 'audiobookshelf://oauth' }, passportCallback(req, res, next))(req, res, next)
|
||||
} else {
|
||||
return passport.authenticate('openid-client', passportCallback(req, res, next))(req, res, next)
|
||||
}
|
||||
// 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))
|
||||
|
||||
/**
|
||||
* Used to auto-populate the openid URLs in config/authentication
|
||||
* 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', async (req, res) => {
|
||||
router.get('/auth/openid/config', this.isAuthenticated, async (req, res) => {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[Auth] Non-admin user "${req.user.username}" attempted to get issuer config`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (!req.query.issuer) {
|
||||
return res.status(400).send('Invalid request. Query param \'issuer\' is required')
|
||||
}
|
||||
|
||||
// Strip trailing slash
|
||||
let issuerUrl = req.query.issuer
|
||||
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
|
||||
|
||||
const configUrl = `${issuerUrl}/.well-known/openid-configuration`
|
||||
axios.get(configUrl).then(({ data }) => {
|
||||
// Append config pathname and validate URL
|
||||
let configUrl = null
|
||||
try {
|
||||
configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`)
|
||||
if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) {
|
||||
throw new Error('Invalid pathname')
|
||||
}
|
||||
} 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')
|
||||
}
|
||||
|
||||
axios.get(configUrl.toString()).then(({ data }) => {
|
||||
res.json({
|
||||
issuer: data.issuer,
|
||||
authorization_endpoint: data.authorization_endpoint,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const Path = require('path')
|
||||
const { Sequelize } = require('sequelize')
|
||||
const { Sequelize, Op } = require('sequelize')
|
||||
|
||||
const packageJson = require('../package.json')
|
||||
const fs = require('./libs/fsExtra')
|
||||
|
|
@ -122,11 +122,16 @@ class Database {
|
|||
return this.models.feed
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/Feed')} */
|
||||
/** @type {typeof import('./models/FeedEpisode')} */
|
||||
get feedEpisodeModel() {
|
||||
return this.models.feedEpisode
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/PlaybackSession')} */
|
||||
get playbackSessionModel() {
|
||||
return this.models.playbackSession
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if db file exists
|
||||
* @returns {boolean}
|
||||
|
|
@ -693,6 +698,7 @@ class Database {
|
|||
* Clean invalid records in database
|
||||
* Series should have atleast one Book
|
||||
* Book and Podcast must have an associated LibraryItem
|
||||
* Remove playback sessions that are 3 seconds or less
|
||||
*/
|
||||
async cleanDatabase() {
|
||||
// Remove invalid Podcast records
|
||||
|
|
@ -733,6 +739,18 @@ class Database {
|
|||
Logger.warn(`Found series "${series.name}" with no books - removing it`)
|
||||
await series.destroy()
|
||||
}
|
||||
|
||||
// Remove playback sessions that were 3 seconds or less
|
||||
const badSessionsRemoved = await this.playbackSessionModel.destroy({
|
||||
where: {
|
||||
timeListening: {
|
||||
[Op.lte]: 3
|
||||
}
|
||||
}
|
||||
})
|
||||
if (badSessionsRemoved > 0) {
|
||||
Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -279,6 +279,19 @@ class Server {
|
|||
})
|
||||
app.get('/healthcheck', (req, res) => res.sendStatus(200))
|
||||
|
||||
let sigintAlreadyReceived = false
|
||||
process.on('SIGINT', async () => {
|
||||
if (!sigintAlreadyReceived) {
|
||||
sigintAlreadyReceived = true
|
||||
Logger.info('SIGINT (Ctrl+C) received. Shutting down...')
|
||||
await this.stop()
|
||||
Logger.info('Server stopped. Exiting.')
|
||||
} else {
|
||||
Logger.info('SIGINT (Ctrl+C) received again. Exiting immediately.')
|
||||
}
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
this.server.listen(this.Port, this.Host, () => {
|
||||
if (this.Host) Logger.info(`Listening on http://${this.Host}:${this.Port}`)
|
||||
else Logger.info(`Listening on port :${this.Port}`)
|
||||
|
|
@ -386,6 +399,7 @@ class Server {
|
|||
}
|
||||
|
||||
async stop() {
|
||||
Logger.info('=== Stopping Server ===')
|
||||
await this.watcher.close()
|
||||
Logger.info('Watcher Closed')
|
||||
|
||||
|
|
|
|||
|
|
@ -552,8 +552,8 @@ class LibraryController {
|
|||
* @param {import('express').Response} res
|
||||
*/
|
||||
async search(req, res) {
|
||||
if (!req.query.q) {
|
||||
return res.status(400).send('No query string')
|
||||
if (!req.query.q || typeof req.query.q !== 'string') {
|
||||
return res.status(400).send('Invalid request. Query param "q" must be a string')
|
||||
}
|
||||
const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
|
||||
const query = asciiOnlyToLowerCase(req.query.q.trim())
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const SocketAuthority = require('../SocketAuthority')
|
|||
const Database = require('../Database')
|
||||
const { sort } = require('../libs/fastSort')
|
||||
const { toNumber } = require('../utils/index')
|
||||
const userStats = require('../utils/queries/userStats')
|
||||
|
||||
class MeController {
|
||||
constructor() { }
|
||||
|
|
@ -333,5 +334,21 @@ class MeController {
|
|||
}
|
||||
res.json(req.user.toJSONForBrowser())
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/stats/year/:year
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async getStatsForYear(req, res) {
|
||||
const year = Number(req.params.year)
|
||||
if (isNaN(year) || year < 2000 || year > 9999) {
|
||||
Logger.error(`[MeController] Invalid year "${year}"`)
|
||||
return res.status(400).send('Invalid year')
|
||||
}
|
||||
const data = await userStats.getStatsForYear(req.user, year)
|
||||
res.json(data)
|
||||
}
|
||||
}
|
||||
module.exports = new MeController()
|
||||
|
|
@ -11,6 +11,7 @@ const { isObject, getTitleIgnorePrefix } = require('../utils/index')
|
|||
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 :(
|
||||
|
|
@ -629,6 +630,27 @@ class MiscController {
|
|||
} else {
|
||||
Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`)
|
||||
}
|
||||
} else if (key === 'authOpenIDMobileRedirectURIs') {
|
||||
function isValidRedirectURI(uri) {
|
||||
if (typeof uri !== 'string') return false
|
||||
const pattern = new RegExp('^\\w+://[\\w.-]+$', 'i')
|
||||
return pattern.test(uri)
|
||||
}
|
||||
|
||||
const uris = settingsUpdate[key]
|
||||
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))) {
|
||||
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${Database.serverSettings[key]}" to "${uris}"`)
|
||||
Database.serverSettings[key] = uris
|
||||
hasUpdates = true
|
||||
}
|
||||
} else {
|
||||
const updatedValueType = typeof settingsUpdate[key]
|
||||
if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) {
|
||||
|
|
@ -671,8 +693,29 @@ class MiscController {
|
|||
}
|
||||
|
||||
res.json({
|
||||
updated: hasUpdates,
|
||||
serverSettings: Database.serverSettings.toJSONForBrowser()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/me/stats/year/:year
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async getAdminStatsForYear(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get admin stats for year`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
const year = Number(req.params.year)
|
||||
if (isNaN(year) || year < 2000 || year > 9999) {
|
||||
Logger.error(`[MiscController] Invalid year "${year}"`)
|
||||
return res.status(400).send('Invalid year')
|
||||
}
|
||||
const stats = await adminStats.getStatsForYear(year)
|
||||
res.json(stats)
|
||||
}
|
||||
}
|
||||
module.exports = new MiscController()
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const fs = require('../libs/fsExtra')
|
|||
|
||||
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
|
||||
const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils')
|
||||
const { validateUrl } = require('../utils/index')
|
||||
|
||||
const Scanner = require('../scanner/Scanner')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
|
|
@ -16,7 +17,7 @@ class PodcastController {
|
|||
|
||||
async create(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[PodcastController] Non-admin user attempted to create podcast`, req.user)
|
||||
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to create podcast`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
const payload = req.body
|
||||
|
|
@ -102,10 +103,24 @@ class PodcastController {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/podcasts/feed
|
||||
*
|
||||
* @typedef getPodcastFeedReqBody
|
||||
* @property {string} rssFeed
|
||||
*
|
||||
* @param {import('express').Request<{}, {}, getPodcastFeedReqBody, {}} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async getPodcastFeed(req, res) {
|
||||
var url = req.body.rssFeed
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to get podcast feed`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const url = validateUrl(req.body.rssFeed)
|
||||
if (!url) {
|
||||
return res.status(400).send('Bad request')
|
||||
return res.status(400).send('Invalid request body. "rssFeed" must be a valid URL')
|
||||
}
|
||||
|
||||
const podcast = await getPodcastFeed(url)
|
||||
|
|
@ -116,6 +131,11 @@ class PodcastController {
|
|||
}
|
||||
|
||||
async getFeedsFromOPMLText(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to get feeds from opml`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (!req.body.opmlText) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,15 +3,18 @@ const BookFinder = require('../finders/BookFinder')
|
|||
const PodcastFinder = require('../finders/PodcastFinder')
|
||||
const AuthorFinder = require('../finders/AuthorFinder')
|
||||
const MusicFinder = require('../finders/MusicFinder')
|
||||
const Database = require("../Database")
|
||||
|
||||
class SearchController {
|
||||
constructor() { }
|
||||
|
||||
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 || ''
|
||||
const results = await BookFinder.search(provider, title, author)
|
||||
const results = await BookFinder.search(libraryItem, provider, title, author)
|
||||
res.json(results)
|
||||
}
|
||||
|
||||
|
|
@ -32,8 +35,19 @@ class SearchController {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Find podcast RSS feeds given a term
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async findPodcasts(req, res) {
|
||||
const term = req.query.term
|
||||
if (!term) {
|
||||
Logger.error('[SearchController] Invalid request query param "term" is required')
|
||||
return res.status(400).send('Invalid request query param "term" is required')
|
||||
}
|
||||
|
||||
const results = await PodcastFinder.search(term)
|
||||
res.json(results)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const Logger = require('../Logger')
|
||||
const Database = require('../Database')
|
||||
const { toNumber } = require('../utils/index')
|
||||
const { toNumber, isUUID } = require('../utils/index')
|
||||
|
||||
class SessionController {
|
||||
constructor() { }
|
||||
|
|
@ -9,35 +9,97 @@ class SessionController {
|
|||
return res.json(req.playbackSession)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/sessions
|
||||
* @this import('../routers/ApiRouter')
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').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}"`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
let listeningSessions = []
|
||||
if (req.query.user) {
|
||||
listeningSessions = await this.getUserListeningSessionsHelper(req.query.user)
|
||||
} else {
|
||||
listeningSessions = await this.getAllSessionsWithUserData()
|
||||
// Validate "user" query
|
||||
let userId = req.query.user
|
||||
if (userId && !isUUID(userId)) {
|
||||
Logger.warn(`[SessionController] Invalid "user" query string "${userId}"`)
|
||||
userId = null
|
||||
}
|
||||
// Validate "sort" query
|
||||
const validSortOrders = ['displayTitle', 'duration', 'playMethod', 'startTime', 'currentTime', 'timeListening', 'updatedAt', 'createdAt']
|
||||
let orderKey = req.query.sort || 'updatedAt'
|
||||
if (!validSortOrders.includes(orderKey)) {
|
||||
Logger.warn(`[SessionController] Invalid "sort" query string "${orderKey}" (Must be one of "${validSortOrders.join('|')}")`)
|
||||
orderKey = 'updatedAt'
|
||||
}
|
||||
let orderDesc = req.query.desc === '1' ? 'DESC' : 'ASC'
|
||||
// Validate "itemsPerPage" and "page" query
|
||||
let itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10
|
||||
if (itemsPerPage < 1) {
|
||||
Logger.warn(`[SessionController] Invalid "itemsPerPage" query string "${itemsPerPage}"`)
|
||||
itemsPerPage = 10
|
||||
}
|
||||
let page = toNumber(req.query.page, 0)
|
||||
if (page < 0) {
|
||||
Logger.warn(`[SessionController] Invalid "page" query string "${page}"`)
|
||||
page = 0
|
||||
}
|
||||
|
||||
const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10
|
||||
const page = toNumber(req.query.page, 0)
|
||||
let where = null
|
||||
const include = [
|
||||
{
|
||||
model: Database.models.device
|
||||
}
|
||||
]
|
||||
|
||||
const start = page * itemsPerPage
|
||||
const sessions = listeningSessions.slice(start, start + itemsPerPage)
|
||||
if (userId) {
|
||||
where = {
|
||||
userId
|
||||
}
|
||||
} else {
|
||||
include.push({
|
||||
model: Database.userModel,
|
||||
attributes: ['id', 'username']
|
||||
})
|
||||
}
|
||||
|
||||
const { rows, count } = await Database.playbackSessionModel.findAndCountAll({
|
||||
where,
|
||||
include,
|
||||
order: [
|
||||
[orderKey, orderDesc]
|
||||
],
|
||||
limit: itemsPerPage,
|
||||
offset: itemsPerPage * page
|
||||
})
|
||||
|
||||
// Map playback sessions to old playback sessions
|
||||
const sessions = rows.map(session => {
|
||||
const oldPlaybackSession = Database.playbackSessionModel.getOldPlaybackSession(session)
|
||||
if (session.user) {
|
||||
return {
|
||||
...oldPlaybackSession,
|
||||
user: {
|
||||
id: session.user.id,
|
||||
username: session.user.username
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return oldPlaybackSession.toJSON()
|
||||
}
|
||||
})
|
||||
|
||||
const payload = {
|
||||
total: listeningSessions.length,
|
||||
numPages: Math.ceil(listeningSessions.length / itemsPerPage),
|
||||
total: count,
|
||||
numPages: Math.ceil(count / itemsPerPage),
|
||||
page,
|
||||
itemsPerPage,
|
||||
sessions
|
||||
}
|
||||
|
||||
if (req.query.user) {
|
||||
payload.userFilter = req.query.user
|
||||
if (userId) {
|
||||
payload.userId = userId
|
||||
}
|
||||
|
||||
res.json(payload)
|
||||
|
|
@ -92,6 +154,49 @@ class SessionController {
|
|||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/sessions/batch/delete
|
||||
* @this import('../routers/ApiRouter')
|
||||
*
|
||||
* @typedef batchDeleteReqBody
|
||||
* @property {string[]} sessions
|
||||
*
|
||||
* @param {import('express').Request<{}, {}, batchDeleteReqBody, {}} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async batchDelete(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[SessionController] Non-admin user attempted to batch delete sessions "${req.user.username}"`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
// Validate session ids
|
||||
if (!req.body.sessions?.length || !Array.isArray(req.body.sessions) || req.body.sessions.some(s => !isUUID(s))) {
|
||||
Logger.error(`[SessionController] Invalid request body. "sessions" array is required`, req.body)
|
||||
return res.status(400).send('Invalid request body. "sessions" array of session id strings is required.')
|
||||
}
|
||||
|
||||
// Check if any of these sessions are open and close it
|
||||
for (const sessionId of req.body.sessions) {
|
||||
const openSession = this.playbackSessionManager.getSession(sessionId)
|
||||
if (openSession) {
|
||||
await this.playbackSessionManager.removeSession(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionsRemoved = await Database.playbackSessionModel.destroy({
|
||||
where: {
|
||||
id: req.body.sessions
|
||||
}
|
||||
})
|
||||
Logger.info(`[SessionController] ${sessionsRemoved} playback sessions removed by "${req.user.username}"`)
|
||||
res.sendStatus(200)
|
||||
} catch (error) {
|
||||
Logger.error(`[SessionController] Failed to remove playback sessions`, error)
|
||||
res.status(500).send('Failed to remove sessions')
|
||||
}
|
||||
}
|
||||
|
||||
// POST: api/session/local
|
||||
syncLocal(req, res) {
|
||||
this.playbackSessionManager.syncLocalSessionRequest(req, res)
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@ class BookFinder {
|
|||
[/ (2nd|3rd|\d+th)\s+ed(\.|ition)?/g, ''], // Remove edition
|
||||
[/(^| |\.)(m4b|m4a|mp3)( |$)/g, ''], // Remove file-type
|
||||
[/ a novel.*$/g, ''], // Remove "a novel"
|
||||
[/(^| )(un)?abridged( |$)/g, ' '], // Remove "unabridged/abridged"
|
||||
[/^\d+ | \d+$/g, ''], // Remove preceding/trailing numbers
|
||||
]
|
||||
|
||||
|
|
@ -298,6 +299,7 @@ class BookFinder {
|
|||
/**
|
||||
* Search for books including fuzzy searches
|
||||
*
|
||||
* @param {Object} libraryItem
|
||||
* @param {string} provider
|
||||
* @param {string} title
|
||||
* @param {string} author
|
||||
|
|
@ -306,7 +308,7 @@ class BookFinder {
|
|||
* @param {{titleDistance:number, authorDistance:number, maxFuzzySearches:number}} options
|
||||
* @returns {Promise<Object[]>}
|
||||
*/
|
||||
async search(provider, title, author, isbn, asin, options = {}) {
|
||||
async search(libraryItem, provider, title, author, isbn, asin, options = {}) {
|
||||
let books = []
|
||||
const maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
|
||||
const maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4
|
||||
|
|
@ -335,6 +337,7 @@ class BookFinder {
|
|||
for (const titlePart of titleParts)
|
||||
authorCandidates.add(titlePart)
|
||||
authorCandidates = await authorCandidates.getCandidates()
|
||||
loop_author:
|
||||
for (const authorCandidate of authorCandidates) {
|
||||
let titleCandidates = new BookFinder.TitleCandidates(authorCandidate)
|
||||
for (const titlePart of titleParts)
|
||||
|
|
@ -342,13 +345,27 @@ class BookFinder {
|
|||
titleCandidates = titleCandidates.getCandidates()
|
||||
for (const titleCandidate of titleCandidates) {
|
||||
if (titleCandidate == title && authorCandidate == author) continue // We already tried this
|
||||
if (++numFuzzySearches > maxFuzzySearches) return books
|
||||
if (++numFuzzySearches > maxFuzzySearches) break loop_author
|
||||
books = await this.runSearch(titleCandidate, authorCandidate, provider, asin, maxTitleDistance, maxAuthorDistance)
|
||||
if (books.length) return books
|
||||
if (books.length) break loop_author
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (books.length) {
|
||||
const resultsHaveDuration = provider.startsWith('audible')
|
||||
if (resultsHaveDuration && libraryItem?.media?.duration) {
|
||||
const libraryItemDurationMinutes = libraryItem.media.duration / 60
|
||||
// If provider results have duration, sort by ascendinge duration difference from libraryItem
|
||||
books.sort((a, b) => {
|
||||
const aDuration = a.duration || Number.POSITIVE_INFINITY
|
||||
const bDuration = b.duration || Number.POSITIVE_INFINITY
|
||||
const aDurationDiff = Math.abs(aDuration - libraryItemDurationMinutes)
|
||||
const bDurationDiff = Math.abs(bDuration - libraryItemDurationMinutes)
|
||||
return aDurationDiff - bDurationDiff
|
||||
})
|
||||
}
|
||||
}
|
||||
return books
|
||||
}
|
||||
|
||||
|
|
@ -392,12 +409,12 @@ class BookFinder {
|
|||
|
||||
if (provider === 'all') {
|
||||
for (const providerString of this.providers) {
|
||||
const providerResults = await this.search(providerString, title, author, options)
|
||||
const providerResults = await this.search(null, providerString, title, author, options)
|
||||
Logger.debug(`[BookFinder] Found ${providerResults.length} covers from ${providerString}`)
|
||||
searchResults.push(...providerResults)
|
||||
}
|
||||
} else {
|
||||
searchResults = await this.search(provider, title, author, options)
|
||||
searchResults = await this.search(null, provider, title, author, options)
|
||||
}
|
||||
Logger.debug(`[BookFinder] FindCovers search results: ${searchResults.length}`)
|
||||
|
||||
|
|
@ -455,12 +472,14 @@ function cleanTitleForCompares(title) {
|
|||
function cleanAuthorForCompares(author) {
|
||||
if (!author) return ''
|
||||
author = stripRedundantSpaces(author)
|
||||
|
||||
|
||||
let cleanAuthor = replaceAccentedChars(author).toLowerCase()
|
||||
// separate initials
|
||||
cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2')
|
||||
// remove middle initials
|
||||
cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '')
|
||||
// remove et al.
|
||||
cleanAuthor = cleanAuthor.replace(/ et al\.?(?= |$)/g, '')
|
||||
return cleanAuthor
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -103,19 +103,29 @@ class RssFeedManager {
|
|||
await Database.updateFeed(feed)
|
||||
}
|
||||
} else if (feed.entityType === 'collection') {
|
||||
const collection = await Database.collectionModel.findByPk(feed.entityId)
|
||||
const collection = await Database.collectionModel.findByPk(feed.entityId, {
|
||||
include: Database.collectionBookModel
|
||||
})
|
||||
if (collection) {
|
||||
const collectionExpanded = await collection.getOldJsonExpanded()
|
||||
|
||||
// Find most recently updated item in collection
|
||||
let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate
|
||||
// Check for most recently updated book
|
||||
collectionExpanded.books.forEach((libraryItem) => {
|
||||
if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) {
|
||||
mostRecentlyUpdatedAt = libraryItem.updatedAt
|
||||
}
|
||||
})
|
||||
// Check for most recently added collection book
|
||||
collection.collectionBooks.forEach((collectionBook) => {
|
||||
if (collectionBook.createdAt.valueOf() > mostRecentlyUpdatedAt) {
|
||||
mostRecentlyUpdatedAt = collectionBook.createdAt.valueOf()
|
||||
}
|
||||
})
|
||||
const hasBooksRemoved = collection.collectionBooks.length < feed.episodes.length
|
||||
|
||||
if (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt) {
|
||||
if (!feed.entityUpdatedAt || hasBooksRemoved || mostRecentlyUpdatedAt > feed.entityUpdatedAt) {
|
||||
Logger.debug(`[RssFeedManager] Updating RSS feed for collection "${collection.name}"`)
|
||||
|
||||
feed.updateFromCollection(collectionExpanded)
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ class Feed extends Model {
|
|||
|
||||
/**
|
||||
* Find all library item ids that have an open feed (used in library filter)
|
||||
* @returns {Promise<Array<String>>} array of library item ids
|
||||
* @returns {Promise<string[]>} array of library item ids
|
||||
*/
|
||||
static async findAllLibraryItemIds() {
|
||||
const feeds = await this.findAll({
|
||||
|
|
@ -122,8 +122,8 @@ class Feed extends Model {
|
|||
|
||||
/**
|
||||
* Find feed where and return oldFeed
|
||||
* @param {object} where sequelize where object
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
* @param {Object} where sequelize where object
|
||||
* @returns {Promise<oldFeed>} oldFeed
|
||||
*/
|
||||
static async findOneOld(where) {
|
||||
if (!where) return null
|
||||
|
|
@ -140,7 +140,7 @@ class Feed extends Model {
|
|||
/**
|
||||
* Find feed and return oldFeed
|
||||
* @param {string} id
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
* @returns {Promise<oldFeed>} oldFeed
|
||||
*/
|
||||
static async findByPkOld(id) {
|
||||
if (!id) return null
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ class ServerSettings {
|
|||
this.authOpenIDAutoLaunch = false
|
||||
this.authOpenIDAutoRegister = false
|
||||
this.authOpenIDMatchExistingBy = null
|
||||
this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth']
|
||||
|
||||
if (settings) {
|
||||
this.construct(settings)
|
||||
|
|
@ -126,6 +127,7 @@ class ServerSettings {
|
|||
this.authOpenIDAutoLaunch = !!settings.authOpenIDAutoLaunch
|
||||
this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister
|
||||
this.authOpenIDMatchExistingBy = settings.authOpenIDMatchExistingBy || null
|
||||
this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth']
|
||||
|
||||
if (!Array.isArray(this.authActiveAuthMethods)) {
|
||||
this.authActiveAuthMethods = ['local']
|
||||
|
|
@ -211,7 +213,8 @@ class ServerSettings {
|
|||
authOpenIDButtonText: this.authOpenIDButtonText,
|
||||
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
|
||||
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
|
||||
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy
|
||||
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
|
||||
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -220,6 +223,7 @@ class ServerSettings {
|
|||
delete json.tokenSecret
|
||||
delete json.authOpenIDClientID
|
||||
delete json.authOpenIDClientSecret
|
||||
delete json.authOpenIDMobileRedirectURIs
|
||||
return json
|
||||
}
|
||||
|
||||
|
|
@ -254,7 +258,8 @@ class ServerSettings {
|
|||
authOpenIDButtonText: this.authOpenIDButtonText,
|
||||
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
|
||||
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
|
||||
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy
|
||||
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
|
||||
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,27 @@ class Audible {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Audible will sometimes send sequences with "Book 1" or "2, Dramatized Adaptation"
|
||||
* @see https://github.com/advplyr/audiobookshelf/issues/2380
|
||||
* @see https://github.com/advplyr/audiobookshelf/issues/1339
|
||||
*
|
||||
* @param {string} seriesName
|
||||
* @param {string} sequence
|
||||
* @returns {string}
|
||||
*/
|
||||
cleanSeriesSequence(seriesName, sequence) {
|
||||
if (!sequence) return ''
|
||||
let updatedSequence = sequence.replace(/Book /, '').trim()
|
||||
if (updatedSequence.includes(' ')) {
|
||||
updatedSequence = updatedSequence.split(' ').shift().replace(/,$/, '')
|
||||
}
|
||||
if (sequence !== updatedSequence) {
|
||||
Logger.debug(`[Audible] Series "${seriesName}" sequence was cleaned from "${sequence}" to "${updatedSequence}"`)
|
||||
}
|
||||
return updatedSequence
|
||||
}
|
||||
|
||||
cleanResult(item) {
|
||||
const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin, formatType } = item
|
||||
|
||||
|
|
@ -25,13 +46,13 @@ class Audible {
|
|||
if (seriesPrimary) {
|
||||
series.push({
|
||||
series: seriesPrimary.name,
|
||||
sequence: (seriesPrimary.position || '').replace(/Book /, '') // Can be badly formatted see #1339
|
||||
sequence: this.cleanSeriesSequence(seriesPrimary.name, seriesPrimary.position || '')
|
||||
})
|
||||
}
|
||||
if (seriesSecondary) {
|
||||
series.push({
|
||||
series: seriesSecondary.name,
|
||||
sequence: (seriesSecondary.position || '').replace(/Book /, '')
|
||||
sequence: this.cleanSeriesSequence(seriesSecondary.name, seriesSecondary.position || '')
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -64,7 +85,7 @@ class Audible {
|
|||
}
|
||||
|
||||
asinSearch(asin, region) {
|
||||
asin = encodeURIComponent(asin);
|
||||
asin = encodeURIComponent(asin)
|
||||
var regionQuery = region ? `?region=${region}` : ''
|
||||
var url = `https://api.audnex.us/books/${asin}${regionQuery}`
|
||||
Logger.debug(`[Audible] ASIN url: ${url}`)
|
||||
|
|
|
|||
|
|
@ -180,6 +180,7 @@ class ApiRouter {
|
|||
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))
|
||||
this.router.get('/me/stats/year/:year', MeController.getStatsForYear.bind(this))
|
||||
|
||||
//
|
||||
// Backup Routes
|
||||
|
|
@ -220,6 +221,7 @@ class ApiRouter {
|
|||
this.router.get('/sessions', SessionController.getAllWithUserData.bind(this))
|
||||
this.router.delete('/sessions/:id', SessionController.middleware.bind(this), SessionController.delete.bind(this))
|
||||
this.router.get('/sessions/open', SessionController.getOpenSessions.bind(this))
|
||||
this.router.post('/sessions/batch/delete', SessionController.batchDelete.bind(this))
|
||||
this.router.post('/session/local', SessionController.syncLocal.bind(this))
|
||||
this.router.post('/session/local-all', SessionController.syncLocalSessions.bind(this))
|
||||
// TODO: Update these endpoints because they are only for open playback sessions
|
||||
|
|
@ -315,6 +317,7 @@ class ApiRouter {
|
|||
this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this))
|
||||
this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this))
|
||||
this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this))
|
||||
this.router.get('/stats/year/:year', MiscController.getAdminStatsForYear.bind(this))
|
||||
}
|
||||
|
||||
async getDirectories(dir, relpath, excludedDirs, level = 0) {
|
||||
|
|
@ -490,18 +493,6 @@ class ApiRouter {
|
|||
return userSessions.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
}
|
||||
|
||||
async getAllSessionsWithUserData() {
|
||||
const sessions = await Database.getPlaybackSessions()
|
||||
sessions.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
const minifiedUserObjects = await Database.userModel.getMinifiedUserObjects()
|
||||
return sessions.map(se => {
|
||||
return {
|
||||
...se,
|
||||
user: minifiedUserObjects.find(u => u.id === se.userId) || null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async getUserListeningStatsHelpers(userId) {
|
||||
const today = date.format(new Date(), 'YYYY-MM-DD')
|
||||
|
||||
|
|
|
|||
|
|
@ -217,7 +217,8 @@ class BookScanner {
|
|||
} else if (key === 'series') {
|
||||
// Check for series added
|
||||
for (const seriesObj of bookMetadata.series) {
|
||||
if (!media.series.some(se => se.name === seriesObj.name)) {
|
||||
const existingBookSeries = media.series.find(se => se.name === seriesObj.name)
|
||||
if (!existingBookSeries) {
|
||||
const existingSeries = Database.libraryFilterData[libraryItemData.libraryId].series.find(se => se.name === seriesObj.name)
|
||||
if (existingSeries) {
|
||||
await Database.bookSeriesModel.create({
|
||||
|
|
@ -238,6 +239,11 @@ class BookScanner {
|
|||
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added new series "${seriesObj.name}"${seriesObj.sequence ? ` with sequence "${seriesObj.sequence}"` : ''}`)
|
||||
seriesUpdated = true
|
||||
}
|
||||
} else if (seriesObj.sequence && existingBookSeries.bookSeries.sequence !== seriesObj.sequence) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" series "${seriesObj.name}" sequence "${existingBookSeries.bookSeries.sequence || ''}" => "${seriesObj.sequence}"`)
|
||||
seriesUpdated = true
|
||||
existingBookSeries.bookSeries.sequence = seriesObj.sequence
|
||||
await existingBookSeries.bookSeries.save()
|
||||
}
|
||||
}
|
||||
// Check for series removed
|
||||
|
|
@ -657,7 +663,7 @@ class BookScanner {
|
|||
if (!this.libraryItemData.metadataNfoLibraryFile) return
|
||||
await NfoFileScanner.scanBookNfoFile(this.libraryItemData.metadataNfoLibraryFile, this.bookMetadata)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Description from desc.txt and narrator from reader.txt
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -32,11 +32,8 @@ class OpfFileScanner {
|
|||
bookMetadata.narrators = opfMetadata.narrators
|
||||
}
|
||||
} else if (key === 'series') {
|
||||
if (opfMetadata.series) {
|
||||
bookMetadata.series = [{
|
||||
name: opfMetadata.series,
|
||||
sequence: opfMetadata.sequence || null
|
||||
}]
|
||||
if (opfMetadata.series?.length) {
|
||||
bookMetadata.series = opfMetadata.series
|
||||
}
|
||||
} else if (opfMetadata[key] && key !== 'sequence') {
|
||||
bookMetadata[key] = opfMetadata[key]
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class Scanner {
|
|||
var searchISBN = options.isbn || libraryItem.media.metadata.isbn
|
||||
var searchASIN = options.asin || libraryItem.media.metadata.asin
|
||||
|
||||
var results = await BookFinder.search(provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 })
|
||||
var results = await BookFinder.search(libraryItem, provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 })
|
||||
if (!results.length) {
|
||||
return {
|
||||
warning: `No ${provider} match found`
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const Path = require('path')
|
||||
const uuid = require('uuid')
|
||||
const Logger = require('../Logger')
|
||||
const { parseString } = require("xml2js")
|
||||
const areEquivalent = require('./areEquivalent')
|
||||
|
|
@ -11,24 +12,24 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => {
|
|||
str2 = str2.toLowerCase()
|
||||
}
|
||||
const track = Array(str2.length + 1).fill(null).map(() =>
|
||||
Array(str1.length + 1).fill(null));
|
||||
Array(str1.length + 1).fill(null))
|
||||
for (let i = 0; i <= str1.length; i += 1) {
|
||||
track[0][i] = i;
|
||||
track[0][i] = i
|
||||
}
|
||||
for (let j = 0; j <= str2.length; j += 1) {
|
||||
track[j][0] = j;
|
||||
track[j][0] = j
|
||||
}
|
||||
for (let j = 1; j <= str2.length; j += 1) {
|
||||
for (let i = 1; i <= str1.length; i += 1) {
|
||||
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
||||
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1
|
||||
track[j][i] = Math.min(
|
||||
track[j][i - 1] + 1, // deletion
|
||||
track[j - 1][i] + 1, // insertion
|
||||
track[j - 1][i - 1] + indicator, // substitution
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
return track[str2.length][str1.length];
|
||||
return track[str2.length][str1.length]
|
||||
}
|
||||
module.exports.levenshteinDistance = levenshteinDistance
|
||||
|
||||
|
|
@ -204,4 +205,31 @@ module.exports.asciiOnlyToLowerCase = (str) => {
|
|||
module.exports.escapeRegExp = (str) => {
|
||||
if (typeof str !== 'string') return ''
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate url string with URL class
|
||||
*
|
||||
* @param {string} rawUrl
|
||||
* @returns {string} null if invalid
|
||||
*/
|
||||
module.exports.validateUrl = (rawUrl) => {
|
||||
if (!rawUrl || typeof rawUrl !== 'string') return null
|
||||
try {
|
||||
return new URL(rawUrl).toString()
|
||||
} catch (error) {
|
||||
Logger.error(`Invalid URL "${rawUrl}"`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a valid UUID
|
||||
*
|
||||
* @param {string} str
|
||||
* @returns {boolean}
|
||||
*/
|
||||
module.exports.isUUID = (str) => {
|
||||
if (!str || typeof str !== 'string') return false
|
||||
return uuid.validate(str)
|
||||
}
|
||||
|
|
@ -100,13 +100,19 @@ function fetchLanguage(metadata) {
|
|||
}
|
||||
|
||||
function fetchSeries(metadataMeta) {
|
||||
if (!metadataMeta) return null
|
||||
return fetchTagString(metadataMeta, "calibre:series")
|
||||
}
|
||||
|
||||
function fetchVolumeNumber(metadataMeta) {
|
||||
if (!metadataMeta) return null
|
||||
return fetchTagString(metadataMeta, "calibre:series_index")
|
||||
if (!metadataMeta) return []
|
||||
const result = []
|
||||
for (let i = 0; i < metadataMeta.length; i++) {
|
||||
if (metadataMeta[i].$?.name === "calibre:series" && metadataMeta[i].$.content?.trim()) {
|
||||
const name = metadataMeta[i].$.content.trim()
|
||||
let sequence = null
|
||||
if (metadataMeta[i + 1]?.$?.name === "calibre:series_index" && metadataMeta[i + 1].$?.content?.trim()) {
|
||||
sequence = metadataMeta[i + 1].$.content.trim()
|
||||
}
|
||||
result.push({ name, sequence })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function fetchNarrators(creators, metadata) {
|
||||
|
|
@ -173,8 +179,7 @@ module.exports.parseOpfMetadataXML = async (xml) => {
|
|||
description: fetchDescription(metadata),
|
||||
genres: fetchGenres(metadata),
|
||||
language: fetchLanguage(metadata),
|
||||
series: fetchSeries(metadata.meta),
|
||||
sequence: fetchVolumeNumber(metadata.meta),
|
||||
series: fetchSeries(metadataMeta),
|
||||
tags: fetchTags(metadata)
|
||||
}
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const Logger = require('../Logger')
|
||||
const axios = require('axios')
|
||||
const ssrfFilter = require('ssrf-req-filter')
|
||||
const Logger = require('../Logger')
|
||||
const { xmlToJSON, levenshteinDistance } = require('./index')
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
|
||||
|
|
@ -216,9 +217,26 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get podcast RSS feed as JSON
|
||||
* Uses SSRF filter to prevent internal URLs
|
||||
*
|
||||
* @param {string} feedUrl
|
||||
* @param {boolean} [excludeEpisodeMetadata=false]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
|
||||
Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`)
|
||||
return axios.get(feedUrl, { timeout: 12000, responseType: 'arraybuffer' }).then(async (data) => {
|
||||
|
||||
return axios({
|
||||
url: feedUrl,
|
||||
method: 'GET',
|
||||
timeout: 12000,
|
||||
responseType: 'arraybuffer',
|
||||
headers: { Accept: 'application/rss+xml, application/xhtml+xml, application/xml' },
|
||||
httpAgent: ssrfFilter(feedUrl),
|
||||
httpsAgent: ssrfFilter(feedUrl)
|
||||
}).then(async (data) => {
|
||||
|
||||
// Adding support for ios-8859-1 encoded RSS feeds.
|
||||
// See: https://github.com/advplyr/audiobookshelf/issues/1489
|
||||
|
|
@ -231,12 +249,12 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
|
|||
|
||||
if (!data?.data) {
|
||||
Logger.error(`[podcastUtils] getPodcastFeed: Invalid podcast feed request response (${feedUrl})`)
|
||||
return false
|
||||
return null
|
||||
}
|
||||
Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}" success - parsing xml`)
|
||||
const payload = await this.parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata)
|
||||
if (!payload) {
|
||||
return false
|
||||
return null
|
||||
}
|
||||
|
||||
// RSS feed may be a private RSS feed
|
||||
|
|
@ -245,7 +263,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
|
|||
return payload.podcast
|
||||
}).catch((error) => {
|
||||
Logger.error('[podcastUtils] getPodcastFeed Error', error)
|
||||
return false
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
162
server/utils/queries/adminStats.js
Normal file
162
server/utils/queries/adminStats.js
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
const Sequelize = require('sequelize')
|
||||
const Database = require('../../Database')
|
||||
const PlaybackSession = require('../../models/PlaybackSession')
|
||||
const fsExtra = require('../../libs/fsExtra')
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
*
|
||||
* @param {number} year YYYY
|
||||
* @returns {Promise<PlaybackSession[]>}
|
||||
*/
|
||||
async getListeningSessionsForYear(year) {
|
||||
const sessions = await Database.playbackSessionModel.findAll({
|
||||
where: {
|
||||
createdAt: {
|
||||
[Sequelize.Op.gte]: `${year}-01-01`,
|
||||
[Sequelize.Op.lt]: `${year + 1}-01-01`
|
||||
}
|
||||
}
|
||||
})
|
||||
return sessions
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} year YYYY
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async getNumAuthorsAddedForYear(year) {
|
||||
const count = await Database.authorModel.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
[Sequelize.Op.gte]: `${year}-01-01`,
|
||||
[Sequelize.Op.lt]: `${year + 1}-01-01`
|
||||
}
|
||||
}
|
||||
})
|
||||
return count
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} year YYYY
|
||||
* @returns {Promise<import('../../models/Book')[]>}
|
||||
*/
|
||||
async getBooksAddedForYear(year) {
|
||||
const books = await Database.bookModel.findAll({
|
||||
attributes: ['id', 'title', 'coverPath', 'duration', 'createdAt'],
|
||||
where: {
|
||||
createdAt: {
|
||||
[Sequelize.Op.gte]: `${year}-01-01`,
|
||||
[Sequelize.Op.lt]: `${year + 1}-01-01`
|
||||
}
|
||||
},
|
||||
include: {
|
||||
model: Database.libraryItemModel,
|
||||
attributes: ['id', 'mediaId', 'mediaType', 'size'],
|
||||
required: true
|
||||
},
|
||||
order: Database.sequelize.random()
|
||||
})
|
||||
return books
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} year YYYY
|
||||
*/
|
||||
async getStatsForYear(year) {
|
||||
const booksAdded = await this.getBooksAddedForYear(year)
|
||||
|
||||
let totalBooksAddedSize = 0
|
||||
let totalBooksAddedDuration = 0
|
||||
const booksWithCovers = []
|
||||
|
||||
for (const book of booksAdded) {
|
||||
// Grab first 25 that have a cover
|
||||
if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(book.coverPath)) {
|
||||
booksWithCovers.push(book.libraryItem.id)
|
||||
}
|
||||
if (book.duration && !isNaN(book.duration)) {
|
||||
totalBooksAddedDuration += book.duration
|
||||
}
|
||||
if (book.libraryItem.size && !isNaN(book.libraryItem.size)) {
|
||||
totalBooksAddedSize += book.libraryItem.size
|
||||
}
|
||||
}
|
||||
|
||||
const numAuthorsAdded = await this.getNumAuthorsAddedForYear(year)
|
||||
|
||||
let authorListeningMap = {}
|
||||
let narratorListeningMap = {}
|
||||
let genreListeningMap = {}
|
||||
|
||||
const listeningSessions = await this.getListeningSessionsForYear(year)
|
||||
let totalListeningTime = 0
|
||||
for (const ls of listeningSessions) {
|
||||
totalListeningTime += (ls.timeListening || 0)
|
||||
|
||||
const authors = ls.mediaMetadata.authors || []
|
||||
authors.forEach((au) => {
|
||||
if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0
|
||||
authorListeningMap[au.name] += (ls.timeListening || 0)
|
||||
})
|
||||
|
||||
const narrators = ls.mediaMetadata.narrators || []
|
||||
narrators.forEach((narrator) => {
|
||||
if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0
|
||||
narratorListeningMap[narrator] += (ls.timeListening || 0)
|
||||
})
|
||||
|
||||
// Filter out bad genres like "audiobook" and "audio book"
|
||||
const genres = (ls.mediaMetadata.genres || []).filter(g => !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
|
||||
genres.forEach((genre) => {
|
||||
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
|
||||
genreListeningMap[genre] += (ls.timeListening || 0)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
let topNarrators = null
|
||||
topNarrators = Object.keys(narratorListeningMap).map(narratorName => ({
|
||||
name: narratorName,
|
||||
time: Math.round(narratorListeningMap[narratorName])
|
||||
})).sort((a, b) => b.time - a.time).slice(0, 3)
|
||||
|
||||
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)
|
||||
|
||||
// Stats for total books, size and duration for everything added this year or earlier
|
||||
const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, {
|
||||
replacements: {
|
||||
nextYear: year + 1
|
||||
}
|
||||
})
|
||||
const totalStatResults = totalStatResultsRow[0]
|
||||
|
||||
return {
|
||||
numListeningSessions: listeningSessions.length,
|
||||
numBooksAdded: booksAdded.length,
|
||||
numAuthorsAdded,
|
||||
totalBooksAddedSize,
|
||||
totalBooksAddedDuration: Math.round(totalBooksAddedDuration),
|
||||
booksAddedWithCovers: booksWithCovers,
|
||||
totalBooksSize: totalStatResults?.totalSize || 0,
|
||||
totalBooksDuration: totalStatResults?.totalDuration || 0,
|
||||
totalListeningTime,
|
||||
numBooks: totalStatResults?.totalItems || 0,
|
||||
topAuthors,
|
||||
topNarrators,
|
||||
topGenres
|
||||
}
|
||||
}
|
||||
}
|
||||
206
server/utils/queries/userStats.js
Normal file
206
server/utils/queries/userStats.js
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
const Sequelize = require('sequelize')
|
||||
const Database = require('../../Database')
|
||||
const PlaybackSession = require('../../models/PlaybackSession')
|
||||
const MediaProgress = require('../../models/MediaProgress')
|
||||
const fsExtra = require('../../libs/fsExtra')
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {number} year YYYY
|
||||
* @returns {Promise<PlaybackSession[]>}
|
||||
*/
|
||||
async getUserListeningSessionsForYear(userId, year) {
|
||||
const sessions = await Database.playbackSessionModel.findAll({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: {
|
||||
[Sequelize.Op.gte]: `${year}-01-01`,
|
||||
[Sequelize.Op.lt]: `${year + 1}-01-01`
|
||||
}
|
||||
},
|
||||
include: {
|
||||
model: Database.bookModel,
|
||||
attributes: ['id', 'coverPath'],
|
||||
include: {
|
||||
model: Database.libraryItemModel,
|
||||
attributes: ['id', 'mediaId', 'mediaType']
|
||||
},
|
||||
required: false
|
||||
},
|
||||
order: Database.sequelize.random()
|
||||
})
|
||||
return sessions
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {number} year YYYY
|
||||
* @returns {Promise<MediaProgress[]>}
|
||||
*/
|
||||
async getBookMediaProgressFinishedForYear(userId, year) {
|
||||
const progresses = await Database.mediaProgressModel.findAll({
|
||||
where: {
|
||||
userId,
|
||||
mediaItemType: 'book',
|
||||
finishedAt: {
|
||||
[Sequelize.Op.gte]: `${year}-01-01`,
|
||||
[Sequelize.Op.lt]: `${year + 1}-01-01`
|
||||
}
|
||||
},
|
||||
include: {
|
||||
model: Database.bookModel,
|
||||
attributes: ['id', 'title', 'coverPath'],
|
||||
include: {
|
||||
model: Database.libraryItemModel,
|
||||
attributes: ['id', 'mediaId', 'mediaType']
|
||||
},
|
||||
required: true
|
||||
},
|
||||
order: Database.sequelize.random()
|
||||
})
|
||||
return progresses
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {import('../../objects/user/User')} user
|
||||
* @param {number} year YYYY
|
||||
*/
|
||||
async getStatsForYear(user, year) {
|
||||
const userId = user.id
|
||||
const listeningSessions = await this.getUserListeningSessionsForYear(userId, year)
|
||||
const bookProgressesFinished = await this.getBookMediaProgressFinishedForYear(userId, year)
|
||||
|
||||
let totalBookListeningTime = 0
|
||||
let totalPodcastListeningTime = 0
|
||||
let totalListeningTime = 0
|
||||
|
||||
let authorListeningMap = {}
|
||||
let genreListeningMap = {}
|
||||
let narratorListeningMap = {}
|
||||
let monthListeningMap = {}
|
||||
let bookListeningMap = {}
|
||||
|
||||
const booksWithCovers = []
|
||||
const finishedBooksWithCovers = []
|
||||
|
||||
// Get finished book stats
|
||||
const numBooksFinished = bookProgressesFinished.length
|
||||
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)) {
|
||||
finishedBooksWithCovers.push(mediaProgress.mediaItem.libraryItem.id)
|
||||
}
|
||||
|
||||
if (mediaProgress.duration && (!longestAudiobookFinished?.duration || mediaProgress.duration > longestAudiobookFinished.duration)) {
|
||||
longestAudiobookFinished = {
|
||||
id: mediaProgress.mediaItem.id,
|
||||
title: mediaProgress.mediaItem.title,
|
||||
duration: Math.round(mediaProgress.duration),
|
||||
finishedAt: mediaProgress.finishedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
booksWithCovers.push(ls.mediaItem.libraryItem.id)
|
||||
}
|
||||
|
||||
const listeningSessionListeningTime = ls.timeListening || 0
|
||||
|
||||
const lsMonth = ls.createdAt.getMonth()
|
||||
if (!monthListeningMap[lsMonth]) monthListeningMap[lsMonth] = 0
|
||||
monthListeningMap[lsMonth] += listeningSessionListeningTime
|
||||
|
||||
totalListeningTime += listeningSessionListeningTime
|
||||
if (ls.mediaItemType === 'book') {
|
||||
totalBookListeningTime += listeningSessionListeningTime
|
||||
|
||||
if (ls.displayTitle && !bookListeningMap[ls.displayTitle]) {
|
||||
bookListeningMap[ls.displayTitle] = listeningSessionListeningTime
|
||||
} else if (ls.displayTitle) {
|
||||
bookListeningMap[ls.displayTitle] += listeningSessionListeningTime
|
||||
}
|
||||
|
||||
const authors = ls.mediaMetadata.authors || []
|
||||
authors.forEach((au) => {
|
||||
if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0
|
||||
authorListeningMap[au.name] += listeningSessionListeningTime
|
||||
})
|
||||
|
||||
const narrators = ls.mediaMetadata.narrators || []
|
||||
narrators.forEach((narrator) => {
|
||||
if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0
|
||||
narratorListeningMap[narrator] += listeningSessionListeningTime
|
||||
})
|
||||
|
||||
// Filter out bad genres like "audiobook" and "audio book"
|
||||
const genres = (ls.mediaMetadata.genres || []).filter(g => !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
|
||||
genres.forEach((genre) => {
|
||||
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
|
||||
genreListeningMap[genre] += listeningSessionListeningTime
|
||||
})
|
||||
} else {
|
||||
totalPodcastListeningTime += listeningSessionListeningTime
|
||||
}
|
||||
}
|
||||
|
||||
totalListeningTime = Math.round(totalListeningTime)
|
||||
totalBookListeningTime = Math.round(totalBookListeningTime)
|
||||
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)
|
||||
|
||||
let mostListenedNarrator = null
|
||||
for (const narrator in narratorListeningMap) {
|
||||
if (!mostListenedNarrator?.time || narratorListeningMap[narrator] > mostListenedNarrator.time) {
|
||||
mostListenedNarrator = {
|
||||
time: Math.round(narratorListeningMap[narrator]),
|
||||
name: narrator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
let mostListenedMonth = null
|
||||
for (const month in monthListeningMap) {
|
||||
if (!mostListenedMonth?.time || monthListeningMap[month] > mostListenedMonth.time) {
|
||||
mostListenedMonth = {
|
||||
month: Number(month),
|
||||
time: Math.round(monthListeningMap[month])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalListeningSessions: listeningSessions.length,
|
||||
totalListeningTime,
|
||||
totalBookListeningTime,
|
||||
totalPodcastListeningTime,
|
||||
topAuthors,
|
||||
topGenres,
|
||||
mostListenedNarrator,
|
||||
mostListenedMonth,
|
||||
numBooksFinished,
|
||||
numBooksListened: Object.keys(bookListeningMap).length,
|
||||
longestAudiobookFinished,
|
||||
booksWithCovers,
|
||||
finishedBooksWithCovers
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue