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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user