diff --git a/backend/src/routes/overseer.ts b/backend/src/routes/overseer.ts index 6e05f1f..898a916 100644 --- a/backend/src/routes/overseer.ts +++ b/backend/src/routes/overseer.ts @@ -13,6 +13,85 @@ const overseerClient = axios.create({ headers: { "X-Api-Key": OVERSEER_API_KEY }, }); +let cachedDiskSpace: { + total: number; + used: number; + free: number; + usedPercent: number; + scarcityMultiplier: number; + fetchedAt: number; +} | null = null; + +async function fetchDiskSpace() { + if (cachedDiskSpace && Date.now() - cachedDiskSpace.fetchedAt < 5 * 60 * 1000) + return cachedDiskSpace; + try { + const radarrRes = await overseerClient.get("/settings/radarr"); + const radarr = radarrRes.data?.[0]; + if (!radarr?.apiKey) throw new Error("No Radarr config"); + + const diskRes = await axios.get( + "http://172.20.1.225:7878/api/v3/diskspace", + { + headers: { "X-Api-Key": radarr.apiKey }, + }, + ); + const drives = diskRes.data || []; + // Pick the largest drive + const main = drives.reduce((a: any, b: any) => + (a.totalSpace || 0) > (b.totalSpace || 0) ? a : b, + ); + if (!main?.totalSpace) throw new Error("No disk data"); + + const total = main.totalSpace; + const free = main.freeSpace; + const used = total - free; + const usedPercent = used / total; + // Scarcity multiplier: 1x at 0% used, up to 3x at 100% used + const scarcityMultiplier = Math.round((1 + usedPercent * 2) * 100) / 100; + + cachedDiskSpace = { + total, + used, + free, + usedPercent, + scarcityMultiplier, + fetchedAt: Date.now(), + }; + return cachedDiskSpace; + } catch (err) { + console.error("Disk space fetch failed:", err); + // Fallback: no scarcity + return { + total: 0, + used: 0, + free: 0, + usedPercent: 0, + scarcityMultiplier: 1, + fetchedAt: Date.now(), + }; + } +} + +function formatBytes(bytes: number): string { + const gb = bytes / (1024 * 1024 * 1024); + return `${Math.round(gb)} GB`; +} + +function getTVEpisodeCost(baseCost: number, episodeCount: number): number { + // Tiered pricing based on episode count + const tiers = [ + { max: 50, mult: 1.0 }, + { max: 100, mult: 1.3 }, + { max: 200, mult: 1.6 }, + { max: 400, mult: 2.0 }, + { max: 99999, mult: 2.5 }, + ]; + const tier = + tiers.find((t) => episodeCount <= t.max) || tiers[tiers.length - 1]; + return Math.round(baseCost * (tier?.mult || 1)); +} + const mapOverseerStatus = (status: any) => { const s = Number(status); if (s >= 5) return "APPROVED"; @@ -20,18 +99,44 @@ const mapOverseerStatus = (status: any) => { return "PENDING"; }; +router.get( + "/diskspace", + authenticate, + asyncHandler(async (_req, res) => { + const disk = await fetchDiskSpace(); + res.json({ + total: formatBytes(disk.total), + totalBytes: disk.total, + used: formatBytes(disk.used), + usedBytes: disk.used, + free: formatBytes(disk.free), + freeBytes: disk.free, + usedPercent: Math.round(disk.usedPercent * 100), + scarcityMultiplier: disk.scarcityMultiplier, + }); + }), +); + router.get( "/costs", authenticate, asyncHandler(async (_req: AuthenticatedRequest, res) => { const settings = await prisma.systemSettings.findFirst(); - res.json({ + const disk = await fetchDiskSpace(); + const base = { movie: settings?.movieRequestCost || 500, tv: settings?.tvRequestCost || 1000, tvPerSeason: settings?.tvPerSeasonCost || 250, + }; + res.json({ + ...base, + scarcityMultiplier: disk.scarcityMultiplier, + movieAdjusted: Math.round(base.movie * disk.scarcityMultiplier), + tvAdjusted: Math.round(base.tv * disk.scarcityMultiplier), }); }), ); + router.get( "/balance", authenticate, @@ -45,19 +150,28 @@ router.get( canRequestTV: false, }); const settings = await prisma.systemSettings.findFirst(); + const disk = await fetchDiskSpace(); const dbBalance = user.totalEarned - user.totalSpent; + const movieCost = Math.round( + (settings?.movieRequestCost || 500) * disk.scarcityMultiplier, + ); + const tvCost = Math.round( + (settings?.tvRequestCost || 1000) * disk.scarcityMultiplier, + ); res.json({ hasWallet: true, balance: dbBalance, - canRequestMovie: dbBalance >= (settings?.movieRequestCost || 500), - canRequestTV: dbBalance >= (settings?.tvRequestCost || 1000), + canRequestMovie: dbBalance >= movieCost, + canRequestTV: dbBalance >= tvCost, costs: { - movie: settings?.movieRequestCost || 500, - tv: settings?.tvRequestCost || 1000, + movie: movieCost, + tv: tvCost, + scarcityMultiplier: disk.scarcityMultiplier, }, }); }), ); + router.get( "/search", authenticate, @@ -67,25 +181,61 @@ router.get( const response = await overseerClient.get( `/search?query=${encodeURIComponent(query as string)}`, ); - res.json(response.data); + const results = response.data?.results || []; + + // Enrich TV results with episode count + const enriched = await Promise.all( + results.map(async (r: any) => { + if (r.mediaType !== "tv") return r; + try { + const detail = await overseerClient.get(`/tv/${r.id}`); + return { + ...r, + numberOfEpisodes: detail.data?.numberOfEpisodes || 0, + numberOfSeasons: detail.data?.numberOfSeasons || 0, + }; + } catch { + return r; + } + }), + ); + + res.json({ results: enriched }); }), ); + router.post( "/request", authenticate, asyncHandler(async (req: AuthenticatedRequest, res) => { - const { mediaType, mediaId, title, seasons } = req.body; + const { mediaType, mediaId, title, seasons, numberOfEpisodes } = req.body; if (!mediaType || !mediaId || !title) return res.status(400).json({ error: "Missing required fields" }); const user = await prisma.user.findUnique({ where: { id: req.user!.id } }); if (!user) return res.status(400).json({ error: "User required" }); const settings = await prisma.systemSettings.findFirst(); - let cost = - mediaType === "movie" - ? settings?.movieRequestCost || 500 - : settings?.tvRequestCost || 1000; - if (mediaType === "tv" && seasons && seasons.length > 1) - cost += (seasons.length - 1) * (settings?.tvPerSeasonCost || 250); + const disk = await fetchDiskSpace(); + + let cost: number; + if (mediaType === "movie") { + cost = Math.round( + (settings?.movieRequestCost || 500) * disk.scarcityMultiplier, + ); + } else { + const baseTvCost = Math.round( + (settings?.tvRequestCost || 1000) * disk.scarcityMultiplier, + ); + const episodeCost = getTVEpisodeCost(baseTvCost, numberOfEpisodes || 0); + cost = episodeCost; + if (seasons && seasons.length > 1) { + cost += + (seasons.length - 1) * + Math.round( + (settings?.tvPerSeasonCost || 250) * disk.scarcityMultiplier, + ); + } + } + const dbBalance = user.totalEarned - user.totalSpent; if (dbBalance < cost) return res.status(400).json({ diff --git a/docker-compose.yml b/docker-compose.yml index 7e381ff..96199c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,6 +60,7 @@ services: - OVERSEER_URL=${OVERSEER_URL} - OVERSEER_API_KEY=${OVERSEER_API_KEY} - ENCRYPTION_KEY=${ENCRYPTION_KEY} + - KOFI_VERIFICATION_TOKEN=${KOFI_VERIFICATION_TOKEN} network_mode: host depends_on: postgres: diff --git a/frontend/src/app/dashboard/components/SearchRequestModal.tsx b/frontend/src/app/dashboard/components/SearchRequestModal.tsx index c1abf37..6fe56ab 100644 --- a/frontend/src/app/dashboard/components/SearchRequestModal.tsx +++ b/frontend/src/app/dashboard/components/SearchRequestModal.tsx @@ -21,12 +21,22 @@ export function SearchRequestModal({ }: { open: boolean; onOpenChange: (open: boolean) => void; - costs: { movie: number; tv: number }; + costs: { movie: number; tv: number; scarcityMultiplier?: number }; }) { const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); + const getTVCost = (item: any) => { + const base = costs.tv; + const eps = item.numberOfEpisodes || 0; + if (eps <= 50) return base; + if (eps <= 100) return Math.round(base * 1.3); + if (eps <= 200) return Math.round(base * 1.6); + if (eps <= 400) return Math.round(base * 2.0); + return Math.round(base * 2.5); + }; + const search = async () => { if (!query) return; setLoading(true); @@ -44,13 +54,15 @@ export function SearchRequestModal({ const request = async (item: any, mediaType: string) => { try { + const cost = mediaType === "movie" ? costs.movie : getTVCost(item); await api.post("/overseer/request", { mediaType, mediaId: item.id || item.tmdbId, title: item.title || item.name, seasons: item.seasons, + numberOfEpisodes: item.numberOfEpisodes || 0, }); - toast.success("Golden egg sent to the farmer!"); + toast.success(`Golden egg sent! ${cost} $COOP deducted.`); onOpenChange(false); } catch (e: any) { toast.error(e?.response?.data?.error || "Request flew the coop!"); @@ -66,7 +78,11 @@ export function SearchRequestModal({ PECK FOR FEED - + + {costs.scarcityMultiplier && costs.scarcityMultiplier > 1 + ? `Market scarcity: x${costs.scarcityMultiplier}` + : ""} +
@@ -91,63 +107,71 @@ export function SearchRequestModal({
- {results.map((r) => ( -
-
- {r.posterPath ? ( - {r.title - ) : ( -
- -
- )} -
+ {results.map((r) => { + const itemCost = + r.mediaType === "movie" ? costs.movie : getTVCost(r); + return ( +
+
+ {r.posterPath ? ( + {r.title + ) : ( +
+ +
+ )} +
-
-
-
- {r.title || r.name} -
-
- - {r.mediaType === "movie" ? ( - - ) : ( - - )} - {r.mediaType || "unknown"} - - {r.releaseDate && ( - - {new Date(r.releaseDate).getFullYear()} +
+
+
+ {r.title || r.name} +
+
+ + {r.mediaType === "movie" ? ( + + ) : ( + + )} + {r.mediaType || "unknown"} - )} + {r.releaseDate && ( + + {new Date(r.releaseDate).getFullYear()} + + )} + {r.numberOfEpisodes > 0 && ( + + {r.numberOfEpisodes} eps + + )} +
-
-
-
- {" "} - {r.mediaType === "movie" ? costs.movie : costs.tv} $COOP +
+
+ {itemCost} $COOP +
+
-
-
- ))} + ); + })} {results.length === 0 && !loading && query && (
diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 2450275..0c00d84 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -4,7 +4,7 @@ import { Activity, Coffee, Egg, - ExternalLink, + HardDrive, History, LogOut, RefreshCw, @@ -36,8 +36,13 @@ export default function DashboardPage() { const router = useRouter(); const { isAuthenticated, logout } = useStore(); const [wallet, setWallet] = useState(null); - const [requestCosts, setRequestCosts] = useState({ movie: 500, tv: 1000 }); - const [pendingRequests, setPendingRequests] = useState([]); + const [requestCosts, setRequestCosts] = useState({ + movie: 500, + tv: 1000, + scarcityMultiplier: 1, + }); + const [totalRequests, setTotalRequests] = useState(0); + const [diskSpace, setDiskSpace] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); const [isBuyModalOpen, setIsBuyModalOpen] = useState(false); @@ -54,30 +59,35 @@ export default function DashboardPage() { const loadWalletAndPending = async () => { try { - const [walletRes, reqRes] = await Promise.all([ + const [walletRes, reqRes, diskRes] = await Promise.all([ api.get("/wallet"), - userApi.getRequests(1, 10, "PENDING"), + userApi.getRequests(1, 100), + api.get("/overseer/diskspace"), ]); setWallet(walletRes.data); - setPendingRequests(reqRes.data.requests || []); + setTotalRequests(reqRes.data.requests?.length || 0); + setDiskSpace(diskRes.data); } catch { toast.error("Chickens escaped! Failed to load."); } finally { setIsLoading(false); } }; + const loadCosts = async () => { try { const costsRes = await overseerApi.getCosts(); setRequestCosts(costsRes.data); } catch {} }; + const syncPending = async () => { try { await api.post("/overseer/sync-pending"); await loadWalletAndPending(); } catch {} }; + const handleLogout = async () => { try { await authApi.logout(); @@ -85,6 +95,7 @@ export default function DashboardPage() { logout(); router.push("/login"); }; + const handleRefresh = () => { loadWalletAndPending(); syncPending(); @@ -101,20 +112,35 @@ export default function DashboardPage() { return (
- {/* Barn Header */} -
-
-
- -
+ {/* Header */} +
+
-

- The Big Coop +

+ THE COOP

- Welcome back to the flock! 🐔 + Welcome back, farmer.

+
+ + +
@@ -140,13 +166,6 @@ export default function DashboardPage() { > -
@@ -164,29 +183,34 @@ export default function DashboardPage() { 🥚
-
- Eggs Cooking + Eggs Ordered
- {pendingRequests.length} Requests + {totalRequests}{" "} + Requests
- 🔥 + 📋
-
Market Price
- {requestCosts.movie} /{" "} + {requestCosts.movie}{" "} + /{" "} {requestCosts.tv}
Movie / TV Series + {requestCosts.scarcityMultiplier > 1 && ( + + (x{requestCosts.scarcityMultiplier}) + + )}
🌽 @@ -194,6 +218,41 @@ export default function DashboardPage() {
+ {/* Disk Space */} + {diskSpace && diskSpace.totalBytes > 0 && ( +
+
+
+ +

+ The Silo +

+
+
+ {diskSpace.used} + / {diskSpace.total} + ({diskSpace.usedPercent}%) +
+
+
+
90 + ? "hsl(var(--destructive))" + : "hsl(var(--primary))", + /> +
+ {diskSpace.scarcityMultiplier > 1 && ( +

+ Silo filling up! Prices x{diskSpace.scarcityMultiplier} +

+ )} +
+ )} + {/* Main Yard */}
@@ -209,36 +268,24 @@ export default function DashboardPage() { value="requests" className="flex items-center justify-center rounded-full py-3 font-black uppercase transition-all data-[state=active]:bg-primary data-[state=active]:text-primary-foreground" > - {" "} - ORDERS + REQUESTS - FLOCK + PECKING ORDER -
- - - - - - - - - -
+ + + + + + + + +