feat: wire up listenarr and abs routes in index.ts
This commit is contained in:
@@ -22,8 +22,10 @@ const envPath =
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
import { errorHandler } from "./middleware/errorHandler";
|
||||
import { absRouter } from "./routes/abs";
|
||||
import { adminRouter } from "./routes/admin";
|
||||
import { authRouter } from "./routes/auth";
|
||||
import { listenarrRouter } from "./routes/listenarr";
|
||||
import { overseerRouter } from "./routes/overseer";
|
||||
import { tautulliRouter } from "./routes/tautulli";
|
||||
import { transactionsRouter } from "./routes/transactions";
|
||||
@@ -54,6 +56,8 @@ app.use(
|
||||
crossOriginResourcePolicy: { policy: "cross-origin" },
|
||||
}),
|
||||
);
|
||||
// Trust proxy — behind Caddy reverse proxy
|
||||
app.set("trust proxy", 1);
|
||||
app.use(
|
||||
cors({
|
||||
origin: allowedOrigins,
|
||||
@@ -87,6 +91,8 @@ app.use("/api/wallet", walletRouter);
|
||||
app.use("/api/transactions", transactionsRouter);
|
||||
app.use("/api/admin", adminRouter);
|
||||
app.use("/api/tautulli", tautulliRouter);
|
||||
app.use("/api/abs", absRouter);
|
||||
app.use("/api/listenarr", listenarrRouter);
|
||||
app.use("/api/overseer", overseerRouter);
|
||||
app.use("/webhooks", webhookRouter);
|
||||
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { Router } from "express";
|
||||
import {
|
||||
type AuthenticatedRequest,
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
} from "../middleware/auth";
|
||||
import { asyncHandler } from "../middleware/errorHandler";
|
||||
import {
|
||||
getAbsStatus,
|
||||
getListeningSessions,
|
||||
backfillAudiobookHistory,
|
||||
getUsers,
|
||||
} from "../services/audiobookshelf";
|
||||
import { prisma } from "../utils/prisma";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get Audiobookshelf connection status
|
||||
router.get(
|
||||
"/status",
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||
const status = await getAbsStatus();
|
||||
res.json(status);
|
||||
})
|
||||
);
|
||||
|
||||
// Get raw listening sessions from Audiobookshelf
|
||||
router.get(
|
||||
"/sessions",
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { page = "0", limit = "50" } = req.query;
|
||||
const pageNum = parseInt(page as string);
|
||||
const limitNum = Math.min(parseInt(limit as string), 100);
|
||||
const data = await getListeningSessions(limitNum, pageNum);
|
||||
res.json(data);
|
||||
})
|
||||
);
|
||||
|
||||
// Get aggregate stats from Audiobookshelf
|
||||
router.get(
|
||||
"/stats",
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
// Fetch all listening sessions and aggregate
|
||||
let page = 0;
|
||||
let allSessions: any[] = [];
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore && page < 10) {
|
||||
const { sessions, numPages } = await getListeningSessions(100, page);
|
||||
allSessions = allSessions.concat(sessions);
|
||||
page++;
|
||||
if (page >= numPages) hasMore = false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000;
|
||||
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const totalListeningSeconds = allSessions.reduce(
|
||||
(sum, s) => sum + (s.timeListening || 0),
|
||||
0
|
||||
);
|
||||
const last7Days = allSessions.filter((s) => s.date >= sevenDaysAgo);
|
||||
const last30Days = allSessions.filter((s) => s.date >= thirtyDaysAgo);
|
||||
|
||||
// Per-user aggregation
|
||||
const userMap: Record<string, { username: string; seconds: number; sessions: number }> = {};
|
||||
for (const session of allSessions) {
|
||||
const uid = session.userId || "unknown";
|
||||
if (!userMap[uid]) {
|
||||
userMap[uid] = { username: uid, seconds: 0, sessions: 0 };
|
||||
}
|
||||
userMap[uid].seconds += session.timeListening || 0;
|
||||
userMap[uid].sessions++;
|
||||
}
|
||||
|
||||
res.json({
|
||||
totalSessions: allSessions.length,
|
||||
totalListeningHours: Math.round((totalListeningSeconds / 3600) * 100) / 100,
|
||||
last7DaysSessions: last7Days.length,
|
||||
last7DaysHours: Math.round(
|
||||
(last7Days.reduce((s, c) => s + (c.timeListening || 0), 0) / 3600) * 100
|
||||
) / 100,
|
||||
last30DaysSessions: last30Days.length,
|
||||
last30DaysHours: Math.round(
|
||||
(last30Days.reduce((s, c) => s + (c.timeListening || 0), 0) / 3600) * 100
|
||||
) / 100,
|
||||
topListeners: Object.values(userMap)
|
||||
.sort((a, b) => b.seconds - a.seconds)
|
||||
.slice(0, 20)
|
||||
.map((u) => ({
|
||||
...u,
|
||||
hours: Math.round((u.seconds / 3600) * 100) / 100,
|
||||
})),
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error?.message || "Failed to fetch ABS stats" });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Sync/backfill audiobook listening sessions for all users
|
||||
router.post(
|
||||
"/sync",
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { userId } = req.body;
|
||||
|
||||
if (userId) {
|
||||
// Backfill a single user
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
if (!user) return res.status(404).json({ error: "User not found" });
|
||||
|
||||
await backfillAudiobookHistory(user);
|
||||
const updatedUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
totalEarned: true,
|
||||
watchTimeMinutes: true,
|
||||
},
|
||||
});
|
||||
return res.json({ success: true, user: updatedUser });
|
||||
}
|
||||
|
||||
// Backfill all active users
|
||||
const users = await prisma.user.findMany({
|
||||
where: { isActive: true },
|
||||
});
|
||||
|
||||
const results: any[] = [];
|
||||
for (const user of users) {
|
||||
await backfillAudiobookHistory(user);
|
||||
results.push({ userId: user.id, plexUsername: user.plexUsername });
|
||||
}
|
||||
|
||||
res.json({ success: true, usersProcessed: results.length, results });
|
||||
})
|
||||
);
|
||||
|
||||
// Sync only new sessions since last sync
|
||||
router.post(
|
||||
"/sync-pending",
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { userId } = req.body;
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
const audiobookMinListenMinutes =
|
||||
settings?.audiobookMinListenMinutes || 5;
|
||||
const audiobookCreditsPerMinute =
|
||||
settings?.audiobookCreditsPerMinute || 1;
|
||||
|
||||
// Find the most recent audiobook WatchEvent to determine last sync time
|
||||
const lastEvent = await prisma.watchEvent.findFirst({
|
||||
where: {
|
||||
contentType: "audiobook",
|
||||
...(userId ? { userId } : {}),
|
||||
},
|
||||
orderBy: { watchedAt: "desc" },
|
||||
select: { watchedAt: true },
|
||||
});
|
||||
|
||||
let page = 0;
|
||||
let totalSynced = 0;
|
||||
let totalCredits = 0;
|
||||
let totalMinutes = 0;
|
||||
let hasMore = true;
|
||||
|
||||
const usersToSync = userId
|
||||
? [await prisma.user.findUnique({ where: { id: userId } })]
|
||||
: await prisma.user.findMany({ where: { isActive: true } });
|
||||
|
||||
for (const user of usersToSync) {
|
||||
if (!user) continue;
|
||||
|
||||
// Map ABS users to this coop user (same matching logic as backfillAudiobookHistory)
|
||||
const absUsers = await getUsers();
|
||||
const userMap = new Map(absUsers.map((u: any) => [u.id, u.username]));
|
||||
const plexEmail = user.email?.toLowerCase().trim() || null;
|
||||
const matchingAbsUserIds = absUsers
|
||||
.filter((u: any) => {
|
||||
const absEmail = u.email?.toLowerCase().trim() || null;
|
||||
const absUsername = u.username.toLowerCase().trim();
|
||||
const plexUsername = user.plexUsername.toLowerCase().trim();
|
||||
if (plexEmail && absEmail === plexEmail) return true;
|
||||
if (plexUsername === "bummer7" && absUsername === "heather") return true;
|
||||
return absUsername === plexUsername;
|
||||
})
|
||||
.map((u: any) => u.id);
|
||||
|
||||
if (matchingAbsUserIds.length === 0) {
|
||||
console.log(`No ABS user match for ${user.plexUsername}, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
page = 0;
|
||||
hasMore = true;
|
||||
|
||||
while (hasMore && page < 10) {
|
||||
const { sessions, numPages } = await getListeningSessions(50, page);
|
||||
|
||||
for (const session of sessions) {
|
||||
// Skip if this session isn't for this user
|
||||
if (!matchingAbsUserIds.includes(session.userId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if this session is older than our last synced event
|
||||
if (lastEvent && new Date(session.date) <= lastEvent.watchedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionId = `abs_${session.id}`;
|
||||
|
||||
if (await prisma.watchEvent.findUnique({ where: { sessionId } })) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const listenMinutes = Math.floor(
|
||||
(session.timeListening || 0) / 60
|
||||
);
|
||||
|
||||
if (listenMinutes < audiobookMinListenMinutes) continue;
|
||||
if (listenMinutes > 1440) continue;
|
||||
|
||||
const creditsEarned = Math.floor(
|
||||
listenMinutes * audiobookCreditsPerMinute
|
||||
);
|
||||
|
||||
const title =
|
||||
session.displayTitle ||
|
||||
session.mediaMetadata?.title ||
|
||||
"Unknown Audiobook";
|
||||
const author =
|
||||
session.displayAuthor ||
|
||||
session.mediaMetadata?.author ||
|
||||
null;
|
||||
|
||||
const watchEvent = await prisma.watchEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
sessionId,
|
||||
ratingKey: session.id,
|
||||
contentType: "audiobook",
|
||||
title,
|
||||
grandparentTitle: author,
|
||||
duration: session.timeListening || 0,
|
||||
percentComplete: Math.min(
|
||||
100,
|
||||
Math.floor(
|
||||
((session.timeListening || 0) /
|
||||
Math.max(session.duration, 1)) *
|
||||
100
|
||||
)
|
||||
),
|
||||
creditsEarned,
|
||||
isProcessed: true,
|
||||
watchedAt: new Date(session.date),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: "EARN",
|
||||
amount: creditsEarned,
|
||||
watchEventId: watchEvent.id,
|
||||
description: `Listened to ${title}`,
|
||||
contentTitle: title,
|
||||
},
|
||||
});
|
||||
|
||||
totalCredits += creditsEarned;
|
||||
totalMinutes += listenMinutes;
|
||||
totalSynced++;
|
||||
}
|
||||
|
||||
page++;
|
||||
if (page >= numPages || !hasMore) hasMore = false;
|
||||
}
|
||||
|
||||
if (totalCredits > 0) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totalEarned: { increment: totalCredits },
|
||||
watchTimeMinutes: { increment: totalMinutes },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
synced: totalSynced,
|
||||
creditsAwarded: totalCredits,
|
||||
minutes: totalMinutes,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export { router as absRouter };
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Router } from "express";
|
||||
import {
|
||||
type AuthenticatedRequest,
|
||||
authenticate,
|
||||
} from "../middleware/auth";
|
||||
import { asyncHandler } from "../middleware/errorHandler";
|
||||
import {
|
||||
searchAudiobooks,
|
||||
addAudiobook,
|
||||
getListenarrStatus,
|
||||
} from "../services/listenarr";
|
||||
import { prisma } from "../utils/prisma";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
"/status",
|
||||
authenticate,
|
||||
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||
const status = await getListenarrStatus();
|
||||
res.json(status);
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/search",
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { q } = req.query;
|
||||
if (!q || typeof q !== "string") {
|
||||
return res.status(400).json({ error: "Query parameter q is required" });
|
||||
}
|
||||
const results = await searchAudiobooks(q);
|
||||
res.json({ results });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/request",
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const userId = (req as any).user?.id;
|
||||
if (!userId) return res.status(401).json({ error: "Not authenticated" });
|
||||
|
||||
const {
|
||||
asin, title, subtitle, authors, narrators, imageUrl, description,
|
||||
publisher, language, releaseDate, runtimeLengthMin, genres,
|
||||
series: rawSeries, seriesNumber: rawSeriesNumber, isbn, explicit, abridged,
|
||||
} = req.body;
|
||||
|
||||
// Normalize series/seriesNumber: Listenarr expects strings, not arrays
|
||||
const series = Array.isArray(rawSeries)
|
||||
? (rawSeries[0]?.name || "")
|
||||
: typeof rawSeries === "string" ? rawSeries : "";
|
||||
const seriesNumber = Array.isArray(rawSeries)
|
||||
? (rawSeries[0]?.position || rawSeries[0]?.number || "")
|
||||
: typeof rawSeriesNumber === "string" ? rawSeriesNumber : "";
|
||||
|
||||
if (!asin || !title) {
|
||||
return res.status(400).json({ error: "asin and title are required" });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) return res.status(404).json({ error: "User not found" });
|
||||
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
const requestCost = settings?.audiobookRequestCost || 300;
|
||||
|
||||
if ((user.totalEarned - user.totalSpent) < requestCost) {
|
||||
return res.status(400).json({
|
||||
error: "Not enough credits",
|
||||
balance: user.totalEarned - user.totalSpent,
|
||||
cost: requestCost,
|
||||
});
|
||||
}
|
||||
|
||||
const authorNames = (authors || []).map((a: any) =>
|
||||
typeof a === "string" ? a : a.name || a
|
||||
);
|
||||
const narratorNames = (narrators || []).map((n: any) =>
|
||||
typeof n === "string" ? n : n.name || n
|
||||
);
|
||||
const genreNames = (genres || []).map((g: any) =>
|
||||
typeof g === "string" ? g : g.name || g
|
||||
);
|
||||
|
||||
let listenarrResult: any;
|
||||
try {
|
||||
listenarrResult = await addAudiobook({
|
||||
asin,
|
||||
title,
|
||||
subtitle,
|
||||
authors: authorNames,
|
||||
narrators: narratorNames,
|
||||
imageUrl,
|
||||
description,
|
||||
publisher,
|
||||
language,
|
||||
releaseDate,
|
||||
runtimeLengthMin,
|
||||
genres: genreNames,
|
||||
series,
|
||||
seriesNumber,
|
||||
isbn,
|
||||
explicit,
|
||||
abridged,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const detail = err?.response?.data?.errors
|
||||
? Object.values(err.response.data.errors).flat().join("; ")
|
||||
: err?.response?.data?.title || err?.message || "Listenarr rejected the request";
|
||||
return res.status(400).json({ error: detail });
|
||||
}
|
||||
|
||||
const listenarrBookId = listenarrResult?.audiobook?.id || 0;
|
||||
|
||||
const request = await prisma.contentRequest.create({
|
||||
data: {
|
||||
userId,
|
||||
overseerRequestId: listenarrBookId > 0 ? listenarrBookId : -(Date.now() % 1000000),
|
||||
mediaType: "audiobook",
|
||||
tmdbId: 0,
|
||||
title,
|
||||
creditsCost: requestCost,
|
||||
status: "APPROVED",
|
||||
requestedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const transaction = await prisma.transaction.create({
|
||||
data: {
|
||||
userId,
|
||||
type: "SPEND",
|
||||
amount: requestCost,
|
||||
requestId: request.id,
|
||||
description: `Audiobook request: ${title}`,
|
||||
contentTitle: title,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { totalSpent: { increment: requestCost } },
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
transactionId: transaction.id,
|
||||
requestId: request.id,
|
||||
cost: requestCost,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export { router as listenarrRouter };
|
||||
@@ -0,0 +1,255 @@
|
||||
import axios from "axios";
|
||||
import { prisma } from "../utils/prisma";
|
||||
|
||||
const ABS_URL = process.env.ABS_URL || "";
|
||||
const ABS_API_TOKEN = process.env.ABS_API_TOKEN || "";
|
||||
|
||||
const absApi = axios.create({
|
||||
baseURL: ABS_URL,
|
||||
timeout: 10000,
|
||||
headers: { Authorization: `Bearer ${ABS_API_TOKEN}` },
|
||||
});
|
||||
|
||||
interface AbsSession {
|
||||
id: string;
|
||||
userId: string;
|
||||
date: string;
|
||||
timeListening: number;
|
||||
duration: number;
|
||||
displayTitle: string;
|
||||
displayAuthor: string;
|
||||
mediaType: string;
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
mediaMetadata?: { title?: string; author?: string };
|
||||
}
|
||||
|
||||
interface AbsSessionsResponse {
|
||||
total: number;
|
||||
numPages: number;
|
||||
page: number;
|
||||
itemsPerPage: number;
|
||||
sessions: AbsSession[];
|
||||
}
|
||||
|
||||
// Map ABS user IDs to usernames (fetched once, cached in memory)
|
||||
let absUserCache: Record<string, string> | null = null;
|
||||
|
||||
async function getAbsUsername(userId: string): Promise<string | null> {
|
||||
if (absUserCache === null) {
|
||||
absUserCache = {};
|
||||
try {
|
||||
const resp = await absApi.get("/api/users");
|
||||
const users = resp.data?.users || [];
|
||||
for (const u of users) {
|
||||
absUserCache[u.id] = u.username;
|
||||
}
|
||||
} catch {
|
||||
// fallback: just use the raw userId
|
||||
}
|
||||
}
|
||||
return absUserCache?.[userId] || null;
|
||||
}
|
||||
|
||||
export async function getAbsStatus(): Promise<{
|
||||
connected: boolean;
|
||||
data?: any;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const resp = await absApi.get("/api/me", { timeout: 5000 });
|
||||
return {
|
||||
connected: true,
|
||||
data: {
|
||||
username: resp.data.username,
|
||||
serverVersion: resp.data.serverVersion,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
connected: false,
|
||||
error: error?.message || "Failed to connect to Audiobookshelf",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getListeningSessions(
|
||||
limit = 50,
|
||||
page = 0
|
||||
): Promise<AbsSessionsResponse> {
|
||||
const resp = await absApi.get("/api/sessions", {
|
||||
params: { itemsPerPage: limit, page },
|
||||
});
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
export async function getUsers(): Promise<
|
||||
{ id: string; username: string; email: string | null }[]
|
||||
> {
|
||||
const resp = await absApi.get("/api/users");
|
||||
return resp.data?.users || [];
|
||||
}
|
||||
|
||||
export async function backfillAudiobookHistory(user: {
|
||||
id: string;
|
||||
plexId: string;
|
||||
plexUsername: string;
|
||||
email?: string | null;
|
||||
}) {
|
||||
if (!ABS_URL || !ABS_API_TOKEN) return;
|
||||
|
||||
try {
|
||||
// Get all ABS users to map userIds to usernames
|
||||
const absUsers = await getUsers();
|
||||
const userMap = new Map(absUsers.map((u) => [u.id, u.username]));
|
||||
|
||||
// Find matching ABS user by email first, then username
|
||||
const plexEmail = user.email?.toLowerCase().trim() || null;
|
||||
const matchingAbsUserIds = absUsers
|
||||
.filter((u) => {
|
||||
const absEmail = u.email?.toLowerCase().trim() || null;
|
||||
const absUsername = u.username.toLowerCase().trim();
|
||||
const plexUsername = user.plexUsername.toLowerCase().trim();
|
||||
|
||||
if (plexEmail && absEmail === plexEmail) return true;
|
||||
|
||||
// Special-case: map Heather's ABS account to bummer7 when needed
|
||||
if (
|
||||
plexUsername === "bummer7" &&
|
||||
absUsername === "heather"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return absUsername === plexUsername;
|
||||
})
|
||||
.map((u) => u.id);
|
||||
|
||||
if (matchingAbsUserIds.length === 0) {
|
||||
console.log(
|
||||
`ABS user not found for ${user.plexUsername} (${user.email || "no email"}). Available:`,
|
||||
absUsers.map((u) => `${u.username}${u.email ? ` <${u.email}>` : ""}`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
const audiobookCreditsPerMinute =
|
||||
settings?.audiobookCreditsPerMinute || 1;
|
||||
const audiobookMinListenMinutes =
|
||||
settings?.audiobookMinListenMinutes || 5;
|
||||
|
||||
// Fetch sessions across multiple pages
|
||||
let page = 0;
|
||||
let allSessions: AbsSession[] = [];
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore && page < 20) {
|
||||
const data = await getListeningSessions(100, page);
|
||||
allSessions = allSessions.concat(data.sessions);
|
||||
page++;
|
||||
if (page >= data.numPages) hasMore = false;
|
||||
}
|
||||
|
||||
// Filter to only this user's sessions
|
||||
const userSessions = allSessions.filter((s) =>
|
||||
matchingAbsUserIds.includes(s.userId)
|
||||
);
|
||||
const afterTimestamp = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
console.log(
|
||||
`ABS backfill ${user.plexUsername}: ${allSessions.length} total sessions, ${userSessions.length} for user`
|
||||
);
|
||||
|
||||
if (userSessions.length === 0) return;
|
||||
|
||||
let totalCredits = 0;
|
||||
let totalMinutes = 0;
|
||||
|
||||
for (const session of userSessions) {
|
||||
const sessionId = `abs_${session.id}`;
|
||||
if (await prisma.watchEvent.findUnique({ where: { sessionId } })) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only process sessions from last 30 days
|
||||
if (session.updatedAt && session.updatedAt < afterTimestamp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const listenMinutes = Math.floor(
|
||||
(session.timeListening || 0) / 60
|
||||
);
|
||||
|
||||
if (listenMinutes < audiobookMinListenMinutes) continue;
|
||||
if (listenMinutes > 1440) continue; // cap at 24h
|
||||
|
||||
const creditsEarned = Math.floor(
|
||||
listenMinutes * audiobookCreditsPerMinute
|
||||
);
|
||||
|
||||
console.log(
|
||||
`ABS Credit: ${session.displayTitle} - ${listenMinutes}min, ${creditsEarned}$COOP`
|
||||
);
|
||||
|
||||
const watchEvent = await prisma.watchEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
sessionId,
|
||||
ratingKey: session.id,
|
||||
contentType: "audiobook",
|
||||
title:
|
||||
session.displayTitle ||
|
||||
session.displayTitle ||
|
||||
"Unknown Audiobook",
|
||||
grandparentTitle: session.displayAuthor || null,
|
||||
duration: session.timeListening || 0,
|
||||
percentComplete: Math.min(
|
||||
100,
|
||||
Math.floor(
|
||||
((session.timeListening || 0) /
|
||||
Math.max(session.duration, 1)) *
|
||||
100
|
||||
)
|
||||
),
|
||||
creditsEarned,
|
||||
isProcessed: true,
|
||||
watchedAt: new Date(session.date),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: "EARN",
|
||||
amount: creditsEarned,
|
||||
watchEventId: watchEvent.id,
|
||||
description: `Listened to ${session.displayTitle}`,
|
||||
contentTitle: session.displayTitle,
|
||||
},
|
||||
});
|
||||
|
||||
totalCredits += creditsEarned;
|
||||
totalMinutes += listenMinutes;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`ABS backfill done ${user.plexUsername}: ${totalCredits} credits, ${totalMinutes} min`
|
||||
);
|
||||
|
||||
if (totalCredits > 0) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totalEarned: { increment: totalCredits },
|
||||
watchTimeMinutes: { increment: totalMinutes },
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to backfill ABS history for ${user.plexUsername}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user