chore: sync WIP (admin routes, dashboard components, compose files)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
Generated
+2301
-42
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user