feat: dynamic pricing by disk space + episode count, total requests stat, disk bar graph

This commit is contained in:
2026-04-23 12:36:50 -04:00
parent ad85beef6b
commit 2d897e45ed
4 changed files with 339 additions and 117 deletions
+163 -13
View File
@@ -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({