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 key = crypto.createHash("sha256").update(ENCRYPTION_KEY).digest(); const cipher = crypto.createCipheriv("aes-256-gcm", 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 || ""; const PLEX_REDIRECT_URI = process.env.PLEX_REDIRECT_URI || ""; const JWT_SECRET = process.env.JWT_SECRET || "secret"; if (!PLEX_CLIENT_ID) { console.error("ERROR: PLEX_CLIENT_ID not set"); } // Step 1: Generate Plex PIN and return auth URL router.get( "/plex/url", asyncHandler(async (_req, res) => { if (!PLEX_CLIENT_ID) { return res.status(500).json({ error: "Plex not configured" }); } const pinResponse = await axios.post( "https://plex.tv/api/v2/pins?strong=true", null, { headers: { Accept: "application/json", "X-Plex-Client-Identifier": PLEX_CLIENT_ID, "X-Plex-Product": "CoopCoins", "X-Plex-Version": "1.0.0", "X-Plex-Device": "Web Browser", "X-Plex-Platform": "Web", }, }, ); const pin = pinResponse.data; const authUrl = `https://app.plex.tv/auth#?clientID=${encodeURIComponent(PLEX_CLIENT_ID)}&code=${pin.code}&forwardUrl=${encodeURIComponent(PLEX_REDIRECT_URI)}`; res.json({ authUrl, pinId: pin.id }); }), ); // Step 2: Exchange PIN for Plex token and create user session router.post( "/plex/callback", asyncHandler(async (req, res) => { const { pinId } = req.body; if (!pinId) { return res.status(400).json({ error: "PIN ID required" }); } // Check PIN status to get auth token const pinResponse = await axios.get( `https://plex.tv/api/v2/pins/${pinId}`, { headers: { Accept: "application/json", "X-Plex-Client-Identifier": PLEX_CLIENT_ID, }, }, ); const pin = pinResponse.data; if (!pin.authToken) { return res.status(400).json({ error: "Authentication not completed" }); } const plexToken = pin.authToken; // Get user info from Plex const userResponse = await axios.get("https://plex.tv/api/v2/user", { headers: { "X-Plex-Token": plexToken, "X-Plex-Client-Identifier": PLEX_CLIENT_ID, Accept: "application/json", }, }); const plexUser = userResponse.data; // Find or create user let user = await prisma.user.findUnique({ where: { plexId: String(plexUser.id) }, }); const isNewUser = !user; if (!user) { user = await prisma.user.create({ data: { plexId: String(plexUser.id), plexUsername: plexUser.username || plexUser.email, email: plexUser.email, isAdmin: false, }, }); } else { user = await prisma.user.update({ where: { id: user.id }, data: { plexUsername: plexUser.username || plexUser.email, email: plexUser.email, }, }); } // Delete old sessions and create new one await prisma.session.deleteMany({ where: { userId: user.id } }); const sessionToken = jwt.sign( { userId: user.id, nonce: Date.now() }, JWT_SECRET, { expiresIn: "7d" }, ); const session = await prisma.session.create({ data: { userId: user.id, token: sessionToken, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }, }); // Backfill 30 days of watch history for new users if (isNewUser) { await backfillUserHistory(user); } // Generate JWT const token = jwt.sign( { userId: user.id, plexId: user.plexId, isAdmin: user.isAdmin, }, JWT_SECRET, { expiresIn: "7d" }, ); res.json({ token, sessionToken: session.token, user: { id: user.id, plexId: user.plexId, plexUsername: user.plexUsername, email: user.email, isAdmin: user.isAdmin, walletAddress: user.walletAddress, totalEarned: user.totalEarned, totalSpent: user.totalSpent, }, }); }), ); router.get( "/verify", asyncHandler(async (req, res) => { const authHeader = req.headers.authorization; if (!authHeader?.startsWith("Bearer ")) { return res.status(401).json({ error: "No token provided" }); } const token = authHeader.substring(7); try { const decoded = jwt.verify(token, JWT_SECRET) as any; const user = await prisma.user.findUnique({ where: { id: decoded.userId }, }); if (!user || !user.isActive) { return res.status(401).json({ error: "User not found or inactive" }); } res.json({ user: { id: user.id, plexId: user.plexId, plexUsername: user.plexUsername, email: user.email, isAdmin: user.isAdmin, walletAddress: user.walletAddress, totalEarned: user.totalEarned, totalSpent: user.totalSpent, }, }); } catch (error) { res.status(401).json({ error: "Invalid token" }); } }), ); router.post( "/logout", asyncHandler(async (req, res) => { const authHeader = req.headers.authorization; if (authHeader?.startsWith("Bearer ")) { const token = authHeader.substring(7); try { const decoded = jwt.verify(token, JWT_SECRET) as any; await prisma.session.deleteMany({ where: { userId: decoded.userId }, }); } catch { // Ignore invalid tokens on logout } } res.json({ message: "Logged out successfully" }); }), ); 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}`, ); } // Map Plex username to Tautulli user ID const usersResponse = await axios.get(`${TAUTULLI_URL}/api/v2`, { params: { apikey: TAUTULLI_API_KEY, cmd: "get_users", }, }); const tautulliUsers = usersResponse.data?.response?.data || []; const tautulliUser = tautulliUsers.find( (u: any) => u.username?.toLowerCase() === user.plexUsername.toLowerCase() || u.email?.toLowerCase() === user.plexUsername.toLowerCase() || u.friendly_name?.toLowerCase() === user.plexUsername.toLowerCase(), ); if (!tautulliUser) { console.log( `Tautulli user not found for Plex username ${user.plexUsername}, skipping backfill`, ); return; } const tautulliUserId = tautulliUser.user_id; console.log( `Mapped ${user.plexUsername} to Tautulli user ID ${tautulliUserId}`, ); // 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: tautulliUserId, 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 || 2; 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 };