audiobookshelf/server/middleware/achievementAutoTracker.js
2025-09-10 06:53:19 +00:00

89 lines
3.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// server/middleware/achievementAutoTracker.js
// Best-effort, low-overhead server middleware that translates common API
// requests into achievement events. It listens for the final status code so we
// only record events on successful requests.
const AchievementManager = require('../managers/AchievementManager')
// normalize to YYYY-MM-DD
const today = () => new Date().toISOString().slice(0, 10)
module.exports = function achievementAutoTracker () {
return (req, res, next) => {
// Well run *after* the auth middleware inside the /api chain so req.user
// is populated (either session or JWT).
const userId = req.user && (req.user.id || req.user.userId)
? String(req.user.id || req.user.userId)
: null
const method = req.method || 'GET'
const path = req.path || ''
const q = req.query || {}
const fire = (event) => {
if (!userId) return
AchievementManager.applyEvent(userId, event).catch(() => {})
}
// Mark a login once per day on the first authed API hit we see.
if (userId) {
const t = today()
if (!req.session) req.session = {}
if (req.session.__achLoginDate !== t) {
req.session.__achLoginDate = t
fire('login')
}
}
// -------------------- SEARCH + FILTERS --------------------
// Explicit search endpoint
if (method === 'GET' && /^\/api\/libraries\/[^/]+\/search\b/.test(path)) {
fire('performedSearch')
}
// Filters on listing endpoints (heuristic: presence of filter-ish query keys)
if (method === 'GET' && /^\/api\/libraries\/[^/]+\//.test(path)) {
const keysStr = Object.keys(q).join(',').toLowerCase()
if (keysStr && /(filter|filters|sort|order|tag|genre|author|narrator|series|collection|min|max|asc|desc)/.test(keysStr)) {
fire('appliedFilter')
}
}
// -------------------- LIBRARIES ---------------------------
// Created library
if (userId && method === 'POST' && /^\/api\/libraries\b/.test(path)) {
res.on('finish', () => { if (res.statusCode < 400) fire('createdLibrary') })
}
// Edited library (PUT or PATCH /api/libraries/:id)
if (userId && (method === 'PUT' || method === 'PATCH') && /^\/api\/libraries\/[^/]+$/.test(path)) {
res.on('finish', () => { if (res.statusCode < 400) fire('editedLibrary') })
}
// -------------------- COLLECTIONS / PLAYLISTS -------------
// Created collection
if (userId && method === 'POST' && /^\/api\/collections?\b/.test(path)) {
res.on('finish', () => { if (res.statusCode < 400) fire('createdCollection') })
}
// Created playlist (covers /api/playlists or /api/playlist)
if (userId && method === 'POST' && /^\/api\/playlists?\b/.test(path)) {
res.on('finish', () => { if (res.statusCode < 400) fire('createdPlaylist') })
}
// -------------------- BOOK DOWNLOADS ----------------------
// ABS uses a few different download/serve endpoints. We fire when it looks
// like the user fetched a file for an item.
if (userId && method === 'GET') {
const isItemDownload =
/^\/api\/items\/[a-z0-9-]{36}\//.test(path) &&
/(download|ebook|file|audio|stream)/i.test(path)
const isGenericDownload = /\/download\b/i.test(path)
if (isItemDownload || isGenericDownload) {
res.on('finish', () => { if (res.statusCode < 400) fire('downloadedBook') })
}
}
next()
}
}