From e1623c3a179175639189459eb435967dd2995400 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Mon, 3 Aug 2026 00:03:38 -0400 Subject: [PATCH] chore: sync WIP (admin routes, dashboard components, compose files) --- .env.example | 8 + backend/.env.example | 4 + backend/package.json | 2 +- backend/src/routes/admin.ts | 29 +- docker-compose.prod.yml | 3 + docker-compose.yml | 2 + frontend/src/app/admin/page.tsx | 25 +- .../app/dashboard/components/ActivityFeed.tsx | 21 +- .../components/SearchRequestModal.tsx | 193 +- .../app/dashboard/components/WatchHistory.tsx | 4 +- frontend/src/app/dashboard/page.tsx | 2 +- frontend/src/lib/api.ts | 11 + package-lock.json | 2343 ++++++++++++++++- 13 files changed, 2555 insertions(+), 92 deletions(-) diff --git a/.env.example b/.env.example index 7bc8fd4..1c263b8 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/.env.example b/backend/.env.example index 59e0651..59a9605 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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="" diff --git a/backend/package.json b/backend/package.json index 2c5f441..1c1872e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 4afdaad..5c24c99 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -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, diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c6680eb..0029eb8 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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} diff --git a/docker-compose.yml b/docker-compose.yml index 2ebf132..723cda5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 42d11f2..aca99bc 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -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 +
@@ -667,7 +690,7 @@ export default function AdminPage() {
{r.user?.plexUsername || "Unknown"} •{" "} - {r.mediaType === "movie" ? "Movie" : "TV"} •{" "} + {r.mediaType === "movie" ? "Movie" : r.mediaType === "audiobook" ? "Audiobook" : "TV"} •{" "} {formatNumber(r.creditsCost)} $COOP
diff --git a/frontend/src/app/dashboard/components/ActivityFeed.tsx b/frontend/src/app/dashboard/components/ActivityFeed.tsx index 07ebbc2..9332100 100644 --- a/frontend/src/app/dashboard/components/ActivityFeed.tsx +++ b/frontend/src/app/dashboard/components/ActivityFeed.tsx @@ -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([]); - useEffect(() => { + const intervalRef = useRef | 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 (
Recent activity
diff --git a/frontend/src/app/dashboard/components/SearchRequestModal.tsx b/frontend/src/app/dashboard/components/SearchRequestModal.tsx index 7b3c8b8..c744f7b 100644 --- a/frontend/src/app/dashboard/components/SearchRequestModal.tsx +++ b/frontend/src/app/dashboard/components/SearchRequestModal.tsx @@ -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([]); const [loading, setLoading] = useState(false); + const [mode, setMode] = useState("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({
-
- 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" - /> - +
+ {/* Mode toggle */} +
+ + +
+ +
+
+ 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" + /> + +
+ +
-
{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 (
- {r.posterPath ? ( + {r.posterPath || r.imageUrl ? ( {r.title ) : (
- + {mt === "audiobook" ? ( + + ) : ( + + )}
)}
@@ -142,12 +243,14 @@ export function SearchRequestModal({
- {r.mediaType === "movie" ? ( + {mt === "movie" ? ( + ) : mt === "audiobook" ? ( + ) : ( )} - {r.mediaType || "unknown"} + {mt === "audiobook" ? "Audiobook" : mt || "unknown"} {r.releaseDate && ( @@ -159,16 +262,26 @@ export function SearchRequestModal({ {r.numberOfEpisodes} eps )} + {r.runtimeLengthMin && ( + + {Math.floor(r.runtimeLengthMin / 60)}h {r.runtimeLengthMin % 60}m + + )}
+ {r.authors && r.authors.length > 0 && ( +
+ by {r.authors.map((a: any) => a.name || a).join(", ")} +
+ )}
- {itemCost} $COOP + {mt === "audiobook" ? (costs.tvAdjusted || costs.tv) : itemCost} $COOP