feat(auth): backfill 30 days watch history on signup

New users automatically get credited for their last 30 days of
Plex watch history via Tautulli. Creates wallet if needed,
fetches history, calculates credits with same rules as live
webhooks, mints tokens, and creates watchEvent + transaction
records. Only runs on first-time signup.
This commit is contained in:
2026-04-21 14:40:36 -04:00
parent 2215eb900b
commit 338e2a1fc1
+183
View File
@@ -1,9 +1,29 @@
import axios from "axios";
import crypto from "crypto";
import { Router } from "express";
import jwt from "jsonwebtoken";
import { asyncHandler } from "../middleware/errorHandler";
import { createWallet, mintTokens } from "../services/solana";
import { prisma } from "../utils/prisma";
const ENCRYPTION_KEY =
process.env.ENCRYPTION_KEY || "default-key-32-chars-long!!!!!";
const TAUTULLI_URL = process.env.TAUTULLI_URL || "";
const TAUTULLI_API_KEY = process.env.TAUTULLI_API_KEY || "";
function encrypt(text: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(
"aes-256-gcm",
Buffer.from(ENCRYPTION_KEY),
iv,
);
let encrypted = cipher.update(text, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
return iv.toString("hex") + ":" + authTag.toString("hex") + ":" + encrypted;
}
const router = Router();
const PLEX_CLIENT_ID = process.env.PLEX_CLIENT_ID || "";
@@ -89,6 +109,8 @@ router.post(
where: { plexId: String(plexUser.id) },
});
const isNewUser = !user;
if (!user) {
user = await prisma.user.create({
data: {
@@ -123,6 +145,11 @@ router.post(
},
});
// Backfill 30 days of watch history for new users
if (isNewUser) {
await backfillUserHistory(user);
}
// Generate JWT
const token = jwt.sign(
{
@@ -214,4 +241,160 @@ router.post(
}),
);
async function backfillUserHistory(user: {
id: string;
plexId: string;
plexUsername: string;
walletAddress: string | null;
}) {
if (!TAUTULLI_URL || !TAUTULLI_API_KEY) {
console.log("Tautulli not configured, skipping backfill");
return;
}
try {
// Create wallet if user doesn't have one
if (!user.walletAddress) {
const wallet = createWallet();
await prisma.user.update({
where: { id: user.id },
data: {
walletAddress: wallet.publicKey,
encryptedPrivateKey: encrypt(wallet.secretKey),
},
});
user.walletAddress = wallet.publicKey;
console.log(
`Created wallet for new user ${user.plexUsername}: ${wallet.publicKey}`,
);
}
// Calculate 30 days ago
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const startDate = thirtyDaysAgo.toISOString().split("T")[0];
// Fetch history from Tautulli
const historyResponse = await axios.get(`${TAUTULLI_URL}/api/v2`, {
params: {
apikey: TAUTULLI_API_KEY,
cmd: "get_history",
user_id: user.plexId,
start_date: startDate,
length: 1000,
},
});
const historyData = historyResponse.data?.response?.data?.data || [];
if (!Array.isArray(historyData) || historyData.length === 0) {
console.log(
`No watch history found for user ${user.plexUsername} in last 30 days`,
);
return;
}
// Get system settings for credit calculation
const settings = await prisma.systemSettings.findFirst();
const creditsPerMinute = settings?.creditsPerMinute || 10;
const minWatchPercent = settings?.minWatchPercent || 80;
const minWatchMinutes = settings?.minWatchMinutes || 5;
let totalCredits = 0;
let totalMinutes = 0;
let processedCount = 0;
for (const item of historyData) {
const sessionId = item.reference_id?.toString() || item.id?.toString();
if (!sessionId) continue;
// Skip duplicates
const existing = await prisma.watchEvent.findUnique({
where: { sessionId },
});
if (existing) continue;
const percentComplete = item.percent_complete || 0;
const watchDurationMinutes = Math.floor(
(item.stopped - item.started) / 60,
);
if (
percentComplete < minWatchPercent ||
watchDurationMinutes < minWatchMinutes
) {
continue;
}
let creditsEarned = watchDurationMinutes * creditsPerMinute;
if (settings?.newReleaseMultiplier && item.is_new) {
creditsEarned = Math.floor(
creditsEarned * Number(settings.newReleaseMultiplier),
);
}
// Create watch event
const watchEvent = await prisma.watchEvent.create({
data: {
userId: user.id,
sessionId,
ratingKey: item.rating_key?.toString() || "",
contentType: item.media_type || "movie",
title: item.title || item.full_title || "Unknown",
grandparentTitle: item.grandparent_title || null,
duration: item.stopped - item.started,
percentComplete: Math.floor(percentComplete),
creditsEarned,
isProcessed: true,
watchedAt: new Date(item.stopped * 1000),
},
});
// Mint tokens on Solana
const signature = await mintTokens(user.walletAddress!, creditsEarned, {
sessionId,
contentTitle: item.title || item.full_title || "Unknown",
watchDurationMinutes,
});
if (signature) {
// Create transaction record
await prisma.transaction.create({
data: {
userId: user.id,
type: "EARN",
amount: creditsEarned,
watchEventId: watchEvent.id,
solanaSignature: signature,
description: `Watched ${item.title || item.full_title || "Unknown"}`,
contentTitle: item.title || item.full_title || "Unknown",
},
});
totalCredits += creditsEarned;
totalMinutes += watchDurationMinutes;
processedCount++;
}
}
// Update user stats
if (totalCredits > 0) {
await prisma.user.update({
where: { id: user.id },
data: {
totalEarned: { increment: totalCredits },
watchTimeMinutes: { increment: totalMinutes },
},
});
console.log(
`Backfilled ${processedCount} watch events for ${user.plexUsername}: ${totalCredits} $COOP, ${totalMinutes} minutes`,
);
}
} catch (error) {
console.error(
`Failed to backfill history for ${user.plexUsername}:`,
error,
);
}
}
export { router as authRouter };