mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 18:01:42 +00:00
407 lines
13 KiB
JavaScript
407 lines
13 KiB
JavaScript
// 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)
|
||
|
||
// Adventurer’s 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
|
||
}
|