chore: sync WIP (admin routes, dashboard components, compose files)

This commit is contained in:
2026-08-03 00:03:38 -04:00
parent a022e0f7b7
commit e1623c3a17
13 changed files with 2555 additions and 92 deletions
+8
View File
@@ -32,6 +32,14 @@ TAUTULLI_URL=http://172.20.1.255:8181
TAUTULLI_API_KEY=""
TAUTULLI_WEBHOOK_SECRET=""
# ==========================================
# Audiobookshelf (Audiobook Listening History)
# Get API token from: sqlite3 absdatabase.sqlite "SELECT token FROM users WHERE username='root';"
# Or create a dedicated API key in Audiobookshelf Settings > Users
# ==========================================
ABS_URL=http://172.20.1.225:13378
ABS_API_TOKEN=""
# ==========================================
# Overseer (Content Requests)
# Backend uses host network mode to reach this
+4
View File
@@ -23,6 +23,10 @@ TAUTULLI_URL=http://172.20.1.255:8181
TAUTULLI_API_KEY=""
TAUTULLI_WEBHOOK_SECRET=""
# Audiobookshelf
ABS_URL=http://172.20.1.225:13378
ABS_API_TOKEN=""
# Overseer
OVERSEER_URL=http://172.20.1.225:5055
OVERSEER_API_KEY=""
+1 -1
View File
@@ -39,7 +39,7 @@
"@types/bcryptjs": "^2.4.6",
"@types/bs58": "^4.0.4",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/express": "^4.17.25",
"@types/jsonwebtoken": "^9.0.5",
"@types/morgan": "^1.9.9",
"@types/node": "^20.10.5",
+26 -3
View File
@@ -9,6 +9,7 @@ import {
import { asyncHandler } from "../middleware/errorHandler";
import { getSmsLogs, sendSms, sendSmsToUsers } from "../services/plivo";
import { backfillUserHistory } from "../services/tautulli";
import { backfillAudiobookHistory } from "../services/audiobookshelf";
import { prisma } from "../utils/prisma";
const router = Router();
@@ -56,7 +57,10 @@ router.put(
const tvRequestCost = Number(req.body.tvRequestCost);
const tvPerSeasonCost = Number(req.body.tvPerSeasonCost);
const newReleaseMultiplier = Number(req.body.newReleaseMultiplier);
const bonusMultiplier = Number(req.body.bonusMultiplier);
const bonuses = Number(req.body.bonusMultiplier);
// Audiobookshelf settings
const audiobookCreditsPerMinute = Number(req.body.audiobookCreditsPerMinute);
const audiobookMinListenMinutes = Number(req.body.audiobookMinListenMinutes);
if (
isNaN(creditsPerMinute) || creditsPerMinute < 0 ||
@@ -66,7 +70,9 @@ router.put(
isNaN(tvRequestCost) || tvRequestCost < 0 ||
isNaN(tvPerSeasonCost) || tvPerSeasonCost < 0 ||
isNaN(newReleaseMultiplier) || newReleaseMultiplier < 0 ||
isNaN(bonusMultiplier) || bonusMultiplier < 0
isNaN(bonuses) || bonuses < 0 ||
isNaN(audiobookCreditsPerMinute) || audiobookCreditsPerMinute < 0 ||
isNaN(audiobookMinListenMinutes) || audiobookMinListenMinutes < 0
) {
return res.status(400).json({ error: "Invalid numeric values in settings" });
}
@@ -82,7 +88,9 @@ router.put(
tvPerSeasonCost,
newReleaseMultiplier,
bonusMultiplierActive: !!req.body.bonusMultiplierActive,
bonusMultiplier,
bonusMultiplier: bonuses,
audiobookCreditsPerMinute,
audiobookMinListenMinutes,
updatedBy: req.user!.id,
},
});
@@ -232,6 +240,21 @@ router.post(
}),
);
router.post(
"/users/:id/backfill-audiobooks",
authenticate,
requireAdmin,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
if (!user) return res.status(404).json({ error: "User not found" });
await backfillAudiobookHistory(user);
const updatedUser = await prisma.user.findUnique({
where: { id: user.id },
});
res.json({ success: true, user: updatedUser });
}),
);
router.get(
"/analytics",
authenticate,
+3
View File
@@ -84,6 +84,9 @@ services:
- TAUTULLI_URL=http://172.20.1.225:8181
- TAUTULLI_API_KEY=${TAUTULLI_API_KEY}
- TAUTULLI_WEBHOOK_SECRET=${TAUTULLI_WEBHOOK_SECRET}
# Audiobookshelf (172.20.1.225:13378)
- ABS_URL=http://172.20.1.225:13378
- ABS_API_TOKEN=${ABS_API_TOKEN}
# Overseer (172.20.1.225:5055)
- OVERSEER_URL=http://172.20.1.225:5055
- OVERSEER_API_KEY=${OVERSEER_API_KEY}
+2
View File
@@ -59,6 +59,8 @@ services:
- TAUTULLI_API_KEY=${TAUTULLI_API_KEY}
- OVERSEER_URL=${OVERSEER_URL}
- OVERSEER_API_KEY=${OVERSEER_API_KEY}
- ABS_URL=${ABS_URL}
- ABS_API_TOKEN=${ABS_API_TOKEN}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- KOFI_VERIFICATION_TOKEN=${KOFI_VERIFICATION_TOKEN}
- PLIVO_AUTH_ID=${PLIVO_AUTH_ID}
+24 -1
View File
@@ -2,6 +2,7 @@
import {
ArrowLeft,
BookOpen,
Coffee,
Database,
Film,
@@ -66,6 +67,9 @@ interface SystemSettings {
tvPerSeasonCost: number;
minWatchPercent: number;
minWatchMinutes: number;
audiobookCreditsPerMinute: number;
audiobookMinListenMinutes: number;
audiobookRequestCost?: number;
}
interface KofiPayment {
@@ -166,6 +170,17 @@ export default function AdminPage() {
}
};
const backfillAudiobooks = async (userId: string) => {
toast.info("Checking Audiobookshelf for listening sessions...");
try {
await adminApi.backfillAudiobooks(userId);
toast.success("Audiobook backfill complete!");
loadData();
} catch {
toast.error("Audiobook backfill failed");
}
};
const toggleAdmin = async (userId: string, isAdmin: boolean) => {
try {
await api.patch(`/admin/users/${userId}`, { isAdmin });
@@ -409,6 +424,14 @@ export default function AdminPage() {
>
BACKFILL
</Button>
<Button
size="sm"
variant="outline"
className="h-10 rounded-full font-black border-2 border-foreground text-xs"
onClick={() => backfillAudiobooks(u.id)}
>
<BookOpen className="h-3 w-3 mr-1" /> ABS
</Button>
<div className="h-10 w-px bg-foreground/10 mx-1 hidden sm:block" />
@@ -667,7 +690,7 @@ export default function AdminPage() {
</div>
<div className="text-sm font-bold text-muted-foreground mt-1">
{r.user?.plexUsername || "Unknown"} •{" "}
{r.mediaType === "movie" ? "Movie" : "TV"} •{" "}
{r.mediaType === "movie" ? "Movie" : r.mediaType === "audiobook" ? "Audiobook" : "TV"} •{" "}
{formatNumber(r.creditsCost)} $COOP
</div>
</div>
@@ -1,16 +1,31 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { transactionApi } from "@/lib/api";
export function ActivityFeed() {
const [items, setItems] = useState<any[]>([]);
useEffect(() => {
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchActivity = () => {
transactionApi
.getRecentActivity()
.then((r) => setItems(r.data.transactions || []));
.then((r) => setItems(r.data.transactions || []))
.catch(() => {});
};
useEffect(() => {
fetchActivity();
// Poll every 30 seconds for new activity
intervalRef.current = setInterval(fetchActivity, 30_000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
return (
<div className="space-y-2">
<div className="text-lg font-semibold">Recent activity</div>
@@ -1,6 +1,6 @@
"use client";
import { Egg, Film, Loader2, PlusCircle, Search, Tv } from "lucide-react";
import { BookOpen, Egg, Film, Headphones, Loader2, PlusCircle, Search, Tv } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -14,6 +14,8 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/lib/api";
type SearchMode = "media" | "audiobooks";
export function SearchRequestModal({
open,
onOpenChange,
@@ -32,6 +34,16 @@ export function SearchRequestModal({
const [query, setQuery] = useState("");
const [results, setResults] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [mode, setMode] = useState<SearchMode>("media");
const getMediaType = (item: any) => {
const t = String(item.mediaType || item.type || item.kind || "").toLowerCase();
if (t.includes("movie") || t === "film") return "movie";
if (t.includes("book") || t.includes("audio")) return "audiobook";
if (t.includes("tv") || t.includes("show") || t.includes("series")) return "tv";
if (item.numberOfEpisodes && item.numberOfEpisodes > 0) return "tv";
return "unknown";
};
const getTVCost = (item: any) => {
const base = costs.tvAdjusted || costs.tv;
@@ -46,11 +58,31 @@ export function SearchRequestModal({
const search = async () => {
if (!query) return;
setLoading(true);
setResults([]);
try {
const res = await api.get(
`/overseer/search?query=${encodeURIComponent(query)}`,
);
setResults(res.data?.results || res.data || []);
if (mode === "audiobooks") {
const res = await api.get(
`/listenarr/search?q=${encodeURIComponent(query)}`,
);
const items = res.data?.results || [];
// Normalize Listenarr results to our format
setResults(
items.map((r: any) => ({
...r,
mediaType: "audiobook",
id: r.asin,
name: r.title,
imageUrl: r.imageUrl,
releaseDate: r.releaseDate,
runtimeLengthMin: r.runtimeLengthMin,
})),
);
} else {
const res = await api.get(
`/overseer/search?query=${encodeURIComponent(query)}`,
);
setResults(res.data?.results || res.data || []);
}
} catch {
toast.error("Chickens lost the trail! Search failed.");
} finally {
@@ -60,15 +92,50 @@ export function SearchRequestModal({
const request = async (item: any, mediaType: string) => {
try {
const cost = mediaType === "movie" ? (costs.movieAdjusted || 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! ${cost} $COOP deducted.`);
if (mediaType === "audiobook") {
const cost = costs.tvAdjusted || costs.tv;
const seriesRaw = item.series;
const seriesName = Array.isArray(seriesRaw)
? (seriesRaw[0]?.name || "")
: typeof seriesRaw === "string"
? seriesRaw
: "";
const seriesNum = Array.isArray(seriesRaw)
? (seriesRaw[0]?.position || seriesRaw[0]?.number || "")
: typeof item.seriesNumber === "string"
? item.seriesNumber
: "";
await api.post("/listenarr/request", {
asin: item.asin,
title: item.title,
subtitle: item.subtitle || "",
authors: item.authors || [],
narrators: item.narrators || [],
imageUrl: item.imageUrl || "",
description: item.description || "",
publisher: item.publisher || "",
language: item.language || "",
releaseDate: item.releaseDate || item.publishedDate || "",
runtimeLengthMin: item.runtimeLengthMin || item.lengthMinutes || 0,
genres: item.genres || [],
series: seriesName,
seriesNumber: seriesNum,
isbn: item.isbn || "",
explicit: item.explicit || false,
abridged: item.bookFormat ? item.bookFormat.toLowerCase().includes("abridged") : false,
});
toast.success(`Golden egg sent! ${cost} $COOP deducted.`);
} else {
const cost = mediaType === "movie" ? (costs.movieAdjusted || 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! ${cost} $COOP deducted.`);
}
onOpenChange(false);
} catch (e: any) {
toast.error(e?.response?.data?.error || "Request flew the coop!");
@@ -93,44 +160,78 @@ export function SearchRequestModal({
<div className="p-6 space-y-6 font-sans">
<div className="flex gap-2">
<div className="relative flex-1">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && search()}
placeholder="Type movie or show name..."
className="rounded-full border-4 border-foreground h-14 pl-6 pr-12 font-bold text-lg focus-visible:ring-primary shadow-sm"
/>
<Search className="absolute right-4 top-1/2 -translate-y-1/2 h-6 w-6 text-muted-foreground" />
<div className="flex flex-col flex-1 gap-3">
{/* Mode toggle */}
<div className="flex gap-2">
<button
type="button"
onClick={() => { setMode("media"); setResults([]); }}
className={`flex items-center gap-2 px-4 py-2 rounded-full border-2 font-black text-xs uppercase tracking-wider transition-all ${
mode === "media"
? "bg-primary text-primary-foreground border-primary"
: "bg-transparent text-muted-foreground border-muted-foreground/30 hover:border-foreground"
}`}
>
<Film className="h-3.5 w-3.5" /> Movies & TV
</button>
<button
type="button"
onClick={() => { setMode("audiobooks"); setResults([]); }}
className={`flex items-center gap-2 px-4 py-2 rounded-full border-2 font-black text-xs uppercase tracking-wider transition-all ${
mode === "audiobooks"
? "bg-primary text-primary-foreground border-primary"
: "bg-transparent text-muted-foreground border-muted-foreground/30 hover:border-foreground"
}`}
>
<Headphones className="h-3.5 w-3.5" /> Audiobooks
</button>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && search()}
placeholder={mode === "audiobooks" ? "Type audiobook name..." : "Type movie or show name..."}
className="rounded-full border-4 border-foreground h-14 pl-6 pr-12 font-bold text-lg focus-visible:ring-primary shadow-sm"
/>
<Search className="absolute right-4 top-1/2 -translate-y-1/2 h-6 w-6 text-muted-foreground" />
</div>
<Button
onClick={search}
disabled={loading || !query}
className="rounded-full h-14 px-8 border-4 border-foreground shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all font-black text-lg"
>
{loading ? <Loader2 className="animate-spin" /> : "SEARCH"}
</Button>
</div>
</div>
<Button
onClick={search}
disabled={loading || !query}
className="rounded-full h-14 px-8 border-4 border-foreground shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all font-black text-lg"
>
{loading ? <Loader2 className="animate-spin" /> : "SEARCH"}
</Button>
</div>
<div className="max-h-[50vh] overflow-y-auto pr-2 space-y-4 scrollbar-hide">
{results.map((r) => {
const itemCost =
r.mediaType === "movie" ? (costs.movieAdjusted || costs.movie) : getTVCost(r);
const mt = getMediaType(r);
const itemCost = mt === "movie" ? (costs.movieAdjusted || costs.movie) : getTVCost(r);
return (
<div
key={r.id || r.tmdbId}
key={r.id || r.tmdbId || r.asin}
className="group flex gap-4 border-4 border-foreground bg-card p-3 rounded-3xl hover:bg-accent/5 transition-all shadow-[4px_4px_0px_0px_rgba(0,0,0,0.1)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,0.1)]"
>
<div className="relative h-28 w-20 flex-shrink-0 overflow-hidden rounded-xl border-2 border-foreground bg-muted shadow-sm">
{r.posterPath ? (
{r.posterPath || r.imageUrl ? (
<img
src={`https://image.tmdb.org/t/p/w200${r.posterPath}`}
src={r.posterPath ? `https://image.tmdb.org/t/p/w200${r.posterPath}` : r.imageUrl}
alt={r.title || r.name}
className="h-full w-full object-cover group-hover:scale-110 transition-transform duration-300"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-secondary/20">
<Film className="h-8 w-8 text-muted-foreground/30" />
{mt === "audiobook" ? (
<BookOpen className="h-8 w-8 text-muted-foreground/30" />
) : (
<Film className="h-8 w-8 text-muted-foreground/30" />
)}
</div>
)}
</div>
@@ -142,12 +243,14 @@ export function SearchRequestModal({
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="inline-flex items-center gap-1 rounded-full bg-secondary/30 px-2 py-0.5 text-[10px] font-black uppercase text-secondary-foreground border border-foreground/10">
{r.mediaType === "movie" ? (
{mt === "movie" ? (
<Film className="h-3 w-3" />
) : mt === "audiobook" ? (
<BookOpen className="h-3 w-3" />
) : (
<Tv className="h-3 w-3" />
)}
{r.mediaType || "unknown"}
{mt === "audiobook" ? "Audiobook" : mt || "unknown"}
</span>
{r.releaseDate && (
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
@@ -159,16 +262,26 @@ export function SearchRequestModal({
{r.numberOfEpisodes} eps
</span>
)}
{r.runtimeLengthMin && (
<span className="text-[10px] font-bold text-muted-foreground">
{Math.floor(r.runtimeLengthMin / 60)}h {r.runtimeLengthMin % 60}m
</span>
)}
</div>
{r.authors && r.authors.length > 0 && (
<div className="text-[11px] font-bold text-muted-foreground/70 mt-0.5 italic">
by {r.authors.map((a: any) => a.name || a).join(", ")}
</div>
)}
</div>
<div className="flex items-center justify-between gap-4 mt-2">
<div className="text-xs font-bold text-muted-foreground italic flex items-center gap-1">
<Egg className="h-3 w-3" /> {itemCost} $COOP
<Egg className="h-3 w-3" /> {mt === "audiobook" ? (costs.tvAdjusted || costs.tv) : itemCost} $COOP
</div>
<Button
size="sm"
onClick={() => request(r, r.mediaType || "movie")}
onClick={() => request(r, mt)}
className="rounded-full border-2 border-foreground font-black text-xs hover:bg-primary hover:text-white transition-colors"
>
REQUEST <PlusCircle className="ml-1 h-3 w-3" />
@@ -1,6 +1,6 @@
"use client";
import { AlertTriangle, Clock, Egg, Film, Tv } from "lucide-react";
import { AlertTriangle, BookOpen, Clock, Egg, Film, Tv } from "lucide-react";
import { useEffect, useState } from "react";
import { userApi } from "@/lib/api";
import { formatNumber } from "@/lib/utils";
@@ -63,6 +63,8 @@ export function WatchHistory() {
<div className="rounded-full bg-secondary/30 p-2 border-2 border-foreground">
{e.contentType === "movie" ? (
<Film className="h-4 w-4" />
) : e.contentType === "audiobook" ? (
<BookOpen className="h-4 w-4" />
) : (
<Tv className="h-4 w-4" />
)}
+1 -1
View File
@@ -36,7 +36,7 @@ export default function DashboardPage() {
const router = useRouter();
const { isAuthenticated, logout } = useStore();
const [wallet, setWallet] = useState<WalletData | null>(null);
const [requestCosts, setRequestCosts] = useState({
const [requestCosts, setRequestCosts] = useState<any>({
movie: 500,
tv: 1000,
movieAdjusted: 500,
+11
View File
@@ -91,6 +91,7 @@ export const adminApi = {
api.get(`/transactions/admin/all?page=${page}&limit=${limit}`),
getSystemStats: () => api.get("/transactions/admin/stats"),
backfillUser: (userId: string) => api.post(`/admin/users/${userId}/backfill`),
backfillAudiobooks: (userId: string) => api.post(`/admin/users/${userId}/backfill-audiobooks`),
getKofiPayments: () => api.get("/admin/kofi-payments"),
getAllRequests: () => api.get("/admin/requests"),
syncAllRequests: () => api.post("/admin/sync-requests"),
@@ -126,3 +127,13 @@ export const tautulliApi = {
getStats: () => api.get("/tautulli/stats"),
getWebhookConfig: () => api.get("/tautulli/webhook-config"),
};
// Audiobookshelf API
export const absApi = {
getStatus: () => api.get("/abs/status"),
getSessions: (page = 0, limit = 50) =>
api.get(`/abs/sessions?page=${page}&limit=${limit}`),
getStats: () => api.get("/abs/stats"),
syncAll: () => api.post("/abs/sync"),
syncPending: () => api.post("/abs/sync-pending"),
};
+2301 -42
View File
File diff suppressed because it is too large Load Diff