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 ? (

) : (
-
+ {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