diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 1a3a0b3..c425076 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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 };