audiobookshelf/server/managers/AchievementManager.js
2025-09-10 06:53:19 +00:00

407 lines
13 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/managers/AchievementManager.js
const Path = require('path')
const Sequelize = require('sequelize')
const fsx = require('../libs/fsExtra')
const Database = require('../Database')
const Store = require('../utils/userProgressStore')
const CATALOG_PATH = Path.join(__dirname, '..', 'data', 'achievements.catalog.json')
let _catalogCache = null
async function loadCatalog () {
if (_catalogCache) return _catalogCache
const raw = await fsx.readFile(CATALOG_PATH, 'utf8')
_catalogCache = JSON.parse(raw)
return _catalogCache
}
// === helpers ================================================================
const DAY_MS = 24 * 60 * 60 * 1000
const dayKey = (ts = Date.now()) => Math.floor(ts / DAY_MS)
const toDbUserId = (userId) => {
const n = Number(userId)
return Number.isFinite(n) ? n : userId
}
// ============================================================================
// a clean default shape persisted per user
function emptyProgress (userId) {
return {
userId,
counters: {
// thresholds (unique finishes)
finishedUniqueIds: [],
finishedUniqueCount: 0,
finishedCount: 0,
// Search Master
searchCount: 0,
// first-time flags
createdLibrary: false,
editedLibrary: false,
uploadedBook: false,
downloadedBook: false,
createdSeries: false,
createdCollection: false,
createdPlaylist: false,
performedSearch: false,
visitedUpload: false,
openedAuthor: false,
openedNarrator: false,
visitedSettings: false,
// 🔸 NEW: first time deleting an uploaded file
deletedFirstFile: false,
// 🔸 NEW: fallback detector storage
lastSeenBookCount: null,
// Daily Devotion
totalLogins: 0,
lastLoginKey: null,
todayLoginCount: 0,
loginStreak: 0,
// Reading Streak
lastFinishedKey: null,
readingStreak: 0,
finishedWithinDay: false
},
unlocked: [],
history: [],
seenPopups: []
}
}
function normalizeWithCatalog (progress, catalog) {
progress.counters = progress.counters || {}
if (!Array.isArray(progress.counters.finishedUniqueIds)) progress.counters.finishedUniqueIds = []
if (typeof progress.counters.finishedUniqueCount !== 'number') progress.counters.finishedUniqueCount = 0
const valid = new Set(Object.keys(catalog.badges || {}))
progress.unlocked = Array.isArray(progress.unlocked) ? progress.unlocked.filter(id => valid.has(id)) : []
progress.unlocked = [...new Set(progress.unlocked)]
progress.history = Array.isArray(progress.history) ? progress.history.filter(h => h && valid.has(h.badgeId)) : []
return progress
}
// thresholds
function readingThresholds () {
return [
{ id: 'new_reader', threshold: 1 },
{ id: 'bookworm', threshold: 5 },
{ id: 'avid', threshold: 10 },
{ id: 'page_turner', threshold: 25 },
{ id: 'marathoner', threshold: 50 },
{ id: 'centurion', threshold: 100 },
{ id: 'legend', threshold: 250 }
]
}
function searchThresholds () {
return [
{ id: 'first_search', threshold: 1 },
{ id: 'curious', threshold: 3 },
{ id: 'seeker', threshold: 5 },
{ id: 'explorer', threshold: 10 },
{ id: 'hunter', threshold: 50 },
{ id: 'master_queries', threshold: 100 }
]
}
function computeUnlockedFresh (progress) {
const out = new Set()
// Reading Journey (by UNIQUE finished items)
const fc = Number(progress.counters.finishedUniqueCount || 0)
for (const t of readingThresholds()) if (fc >= t.threshold) out.add(t.id)
// Search Master
const sc = Number(progress.counters.searchCount || 0)
for (const t of searchThresholds()) if (sc >= t.threshold) out.add(t.id)
// Getting Started + playlist + 🔸deletedFirstFile → Saga Initiator
const started = [
['createdLibrary', 'library_creator'],
['editedLibrary', 'library_editor'],
['uploadedBook', 'book_uploader'],
['downloadedBook', 'book_downloader'],
// 🔸 re-purpose the existing badge id for the new action
['deletedFirstFile', 'series_starter'],
['createdCollection', 'collection_maker'],
['createdPlaylist', 'tune_weaver']
]
for (const [flag, badgeId] of started) if (progress.counters[flag]) out.add(badgeId)
// Adventurers Path
const explore = [
['performedSearch', 'searcher'],
['visitedUpload', 'refined_explorer'],
['openedAuthor', 'author_finder'],
['openedNarrator', 'narrator_finder'],
['visitedSettings', 'pathfinder']
]
for (const [flag, badgeId] of explore) if (progress.counters[flag]) out.add(badgeId)
// Daily Devotion
const ls = Number(progress.counters.loginStreak || 0)
if (Number(progress.counters.totalLogins || 0) >= 1) out.add('first_login')
if (Number(progress.counters.todayLoginCount || 0) >= 2) out.add('double_dip')
if (ls >= 3) out.add('streak_3')
if (ls >= 7) out.add('streak_7')
if (ls >= 30) out.add('streak_30')
// Reading Streak
const rs = Number(progress.counters.readingStreak || 0)
if (progress.counters.finishedWithinDay) out.add('one_day_finish')
if (rs >= 2) out.add('back_to_back')
if (rs >= 5) out.add('focused_five')
if (rs >= 7) out.add('weekly_reader')
if (rs >= 30) out.add('monthly_reader')
return [...out]
}
// robust DISTINCT finished ids from DB, with fallbacks
async function getDistinctFinishedIds (userId) {
const uid = toDbUserId(userId)
const Op = Sequelize.Op
try {
const rows = await Database.mediaProgressModel.findAll({
where: { userId: uid, finishedAt: { [Op.ne]: null } },
attributes: [[Sequelize.fn('DISTINCT', Sequelize.col('libraryItemId')), 'libraryItemId']],
raw: true
})
const ids = rows.map(r => String(r.libraryItemId)).filter(Boolean)
if (ids.length) return [...new Set(ids)]
} catch {}
try {
const rows = await Database.mediaProgressModel.findAll({
where: { userId: uid, isFinished: 1 },
attributes: [[Sequelize.fn('DISTINCT', Sequelize.col('libraryItemId')), 'libraryItemId']],
raw: true
})
const ids = rows.map(r => String(r.libraryItemId)).filter(Boolean)
if (ids.length) return [...new Set(ids)]
} catch {}
try {
const rows = await Database.mediaProgressModel.findAll({
where: { userId: uid },
attributes: ['libraryItemId', 'finishedAt', 'isFinished', 'currentTime', 'duration'],
raw: true
})
const ids = []
for (const r of rows) {
const id = r && r.libraryItemId != null ? String(r.libraryItemId) : null
if (!id) continue
const finishedAt = r.finishedAt != null
const isFinished = Number(r.isFinished || 0) === 1
const ratioOk = Number(r.duration || 0) > 0 && Number(r.currentTime || 0) / Number(r.duration) >= 0.98
if (finishedAt || isFinished || ratioOk) ids.push(id)
}
if (ids.length) return [...new Set(ids)]
} catch {}
return []
}
async function finishedWithinOneDay (userId, itemId = null) {
const uid = toDbUserId(userId)
const Op = Sequelize.Op
try {
const where = itemId
? { userId: uid, libraryItemId: itemId }
: { userId: uid, finishedAt: { [Op.ne]: null } }
const mp = await Database.mediaProgressModel.findOne({
where,
order: itemId ? [['updatedAt', 'DESC']] : [['finishedAt', 'DESC']]
})
if (!mp) return false
const started = mp.createdAt ? new Date(mp.createdAt).getTime() : null
const finished = mp.finishedAt ? new Date(mp.finishedAt).getTime() : null
if (!started || !finished) return false
return (finished - started) <= DAY_MS
} catch {
return false
}
}
async function syncFromDb (userId, progress) {
let idsFromDb = []
try { idsFromDb = await getDistinctFinishedIds(userId) } catch {}
const fallbackIds = Array.isArray(progress.counters.finishedUniqueIds)
? [...new Set(progress.counters.finishedUniqueIds)]
: []
const finalIds = idsFromDb.length ? idsFromDb : fallbackIds
progress.counters.finishedUniqueIds = finalIds
progress.counters.finishedUniqueCount = finalIds.length
progress.counters.finishedCount = finalIds.length
// best-effort backfills
try {
const libCount = await Database.libraryModel.count()
if (libCount > 0) progress.counters.createdLibrary = true
} catch {}
try {
const bookCount = await Database.bookModel.count()
if (bookCount > 0) progress.counters.uploadedBook = true
} catch {}
try {
if (!progress.counters.finishedWithinDay) {
progress.counters.finishedWithinDay = await finishedWithinOneDay(userId, null)
}
} catch {}
// 🔸 NEW: fallback detection for a deletion
try {
const currentBooks = await Database.bookModel.count()
const lastSeen = progress.counters.lastSeenBookCount
progress.counters.lastSeenBookCount = currentBooks
if (
progress.counters.uploadedBook && // user has ever uploaded
lastSeen !== null &&
currentBooks < lastSeen && // total down → something was deleted
!progress.counters.deletedFirstFile // only set once
) {
progress.counters.deletedFirstFile = true
}
} catch {}
const before = new Set(progress.unlocked)
progress.unlocked = computeUnlockedFresh(progress)
const now = new Date().toISOString()
for (const id of progress.unlocked) if (!before.has(id)) {
progress.history.push({ badgeId: id, unlockedAt: now })
}
return progress
}
async function getUserProgress (userId, opts = {}) {
const catalog = await loadCatalog()
let progress = (await Store.read(userId)) || emptyProgress(userId)
progress = normalizeWithCatalog(progress, catalog)
if (opts.syncFromDb) {
await syncFromDb(userId, progress)
progress = normalizeWithCatalog(progress, catalog)
await Store.write(userId, progress)
}
return progress
}
function updateLoginStreak (c) {
const today = dayKey()
if (c.lastLoginKey === today) {
c.todayLoginCount = (c.todayLoginCount || 0) + 1
return
}
const yesterday = today - 1
if (c.lastLoginKey === yesterday) c.loginStreak = (c.loginStreak || 0) + 1
else c.loginStreak = 1
c.lastLoginKey = today
c.todayLoginCount = 1
c.totalLogins = (c.totalLogins || 0) + 1
}
function updateReadingStreakOnFinish (c) {
const today = dayKey()
if (c.lastFinishedKey === today) {
// already counted today
} else if (c.lastFinishedKey === today - 1) {
c.readingStreak = (c.readingStreak || 0) + 1
c.lastFinishedKey = today
} else {
c.readingStreak = 1
c.lastFinishedKey = today
}
}
async function applyEvent (userId, event, meta = {}) {
const catalog = await loadCatalog()
const progress = await getUserProgress(userId)
switch (event) {
case 'itemFinished': {
updateReadingStreakOnFinish(progress.counters)
const rawId = meta.itemId != null ? meta.itemId : meta.libraryItemId
const itemId = rawId != null ? String(rawId) : null
if (itemId) {
const set = new Set(progress.counters.finishedUniqueIds || [])
set.add(itemId)
progress.counters.finishedUniqueIds = [...set]
}
let ids = []
try { ids = await getDistinctFinishedIds(userId) } catch {}
if (!ids.length && progress.counters.finishedUniqueIds?.length) {
ids = [...new Set(progress.counters.finishedUniqueIds)]
}
progress.counters.finishedUniqueIds = ids
progress.counters.finishedUniqueCount = ids.length
progress.counters.finishedCount = ids.length
try {
const withinDay = await finishedWithinOneDay(userId, itemId || null)
if (withinDay) progress.counters.finishedWithinDay = true
} catch {}
break
}
// first-time/flags
case 'createdLibrary': progress.counters.createdLibrary = true; break
case 'editedLibrary': progress.counters.editedLibrary = true; break
case 'uploadedBook': progress.counters.uploadedBook = true; break
case 'downloadedBook': progress.counters.downloadedBook = true; break
case 'createdSeries': progress.counters.createdSeries = true; break
case 'createdCollection': progress.counters.createdCollection = true; break
case 'createdPlaylist': progress.counters.createdPlaylist = true; break
case 'openedAuthor': progress.counters.openedAuthor = true; break
case 'openedNarrator': progress.counters.openedNarrator = true; break
case 'visitedSettings': progress.counters.visitedSettings = true; break
case 'visitedUpload': progress.counters.visitedUpload = true; break
// 🔸 NEW: first file deletion
case 'deletedFile':
if (!progress.counters.deletedFirstFile) progress.counters.deletedFirstFile = true
break
// searches
case 'performedSearch':
progress.counters.performedSearch = true
progress.counters.searchCount = (progress.counters.searchCount || 0) + 1
break
// login
case 'userLoggedIn':
updateLoginStreak(progress.counters)
break
default: break
}
const before = new Set(progress.unlocked)
progress.unlocked = computeUnlockedFresh(progress)
const unlockedNow = []
const now = new Date().toISOString()
for (const id of progress.unlocked) {
if (!before.has(id)) {
unlockedNow.push(catalog.badges?.[id] || { id })
progress.history.push({ badgeId: id, unlockedAt: now })
}
}
await Store.write(userId, progress)
return { unlockedNow, progress }
}
module.exports = {
getCatalog: loadCatalog,
getUserProgress,
applyEvent
}