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_API_KEY=""
|
||||||
TAUTULLI_WEBHOOK_SECRET=""
|
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)
|
# Overseer (Content Requests)
|
||||||
# Backend uses host network mode to reach this
|
# Backend uses host network mode to reach this
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ TAUTULLI_URL=http://172.20.1.255:8181
|
|||||||
TAUTULLI_API_KEY=""
|
TAUTULLI_API_KEY=""
|
||||||
TAUTULLI_WEBHOOK_SECRET=""
|
TAUTULLI_WEBHOOK_SECRET=""
|
||||||
|
|
||||||
|
# Audiobookshelf
|
||||||
|
ABS_URL=http://172.20.1.225:13378
|
||||||
|
ABS_API_TOKEN=""
|
||||||
|
|
||||||
# Overseer
|
# Overseer
|
||||||
OVERSEER_URL=http://172.20.1.225:5055
|
OVERSEER_URL=http://172.20.1.225:5055
|
||||||
OVERSEER_API_KEY=""
|
OVERSEER_API_KEY=""
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/bs58": "^4.0.4",
|
"@types/bs58": "^4.0.4",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.25",
|
||||||
"@types/jsonwebtoken": "^9.0.5",
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
"@types/morgan": "^1.9.9",
|
"@types/morgan": "^1.9.9",
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { asyncHandler } from "../middleware/errorHandler";
|
import { asyncHandler } from "../middleware/errorHandler";
|
||||||
import { getSmsLogs, sendSms, sendSmsToUsers } from "../services/plivo";
|
import { getSmsLogs, sendSms, sendSmsToUsers } from "../services/plivo";
|
||||||
import { backfillUserHistory } from "../services/tautulli";
|
import { backfillUserHistory } from "../services/tautulli";
|
||||||
|
import { backfillAudiobookHistory } from "../services/audiobookshelf";
|
||||||
import { prisma } from "../utils/prisma";
|
import { prisma } from "../utils/prisma";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -56,7 +57,10 @@ router.put(
|
|||||||
const tvRequestCost = Number(req.body.tvRequestCost);
|
const tvRequestCost = Number(req.body.tvRequestCost);
|
||||||
const tvPerSeasonCost = Number(req.body.tvPerSeasonCost);
|
const tvPerSeasonCost = Number(req.body.tvPerSeasonCost);
|
||||||
const newReleaseMultiplier = Number(req.body.newReleaseMultiplier);
|
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 (
|
if (
|
||||||
isNaN(creditsPerMinute) || creditsPerMinute < 0 ||
|
isNaN(creditsPerMinute) || creditsPerMinute < 0 ||
|
||||||
@@ -66,7 +70,9 @@ router.put(
|
|||||||
isNaN(tvRequestCost) || tvRequestCost < 0 ||
|
isNaN(tvRequestCost) || tvRequestCost < 0 ||
|
||||||
isNaN(tvPerSeasonCost) || tvPerSeasonCost < 0 ||
|
isNaN(tvPerSeasonCost) || tvPerSeasonCost < 0 ||
|
||||||
isNaN(newReleaseMultiplier) || newReleaseMultiplier < 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" });
|
return res.status(400).json({ error: "Invalid numeric values in settings" });
|
||||||
}
|
}
|
||||||
@@ -82,7 +88,9 @@ router.put(
|
|||||||
tvPerSeasonCost,
|
tvPerSeasonCost,
|
||||||
newReleaseMultiplier,
|
newReleaseMultiplier,
|
||||||
bonusMultiplierActive: !!req.body.bonusMultiplierActive,
|
bonusMultiplierActive: !!req.body.bonusMultiplierActive,
|
||||||
bonusMultiplier,
|
bonusMultiplier: bonuses,
|
||||||
|
audiobookCreditsPerMinute,
|
||||||
|
audiobookMinListenMinutes,
|
||||||
updatedBy: req.user!.id,
|
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(
|
router.get(
|
||||||
"/analytics",
|
"/analytics",
|
||||||
authenticate,
|
authenticate,
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ services:
|
|||||||
- TAUTULLI_URL=http://172.20.1.225:8181
|
- TAUTULLI_URL=http://172.20.1.225:8181
|
||||||
- TAUTULLI_API_KEY=${TAUTULLI_API_KEY}
|
- TAUTULLI_API_KEY=${TAUTULLI_API_KEY}
|
||||||
- TAUTULLI_WEBHOOK_SECRET=${TAUTULLI_WEBHOOK_SECRET}
|
- 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 (172.20.1.225:5055)
|
||||||
- OVERSEER_URL=http://172.20.1.225:5055
|
- OVERSEER_URL=http://172.20.1.225:5055
|
||||||
- OVERSEER_API_KEY=${OVERSEER_API_KEY}
|
- OVERSEER_API_KEY=${OVERSEER_API_KEY}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ services:
|
|||||||
- TAUTULLI_API_KEY=${TAUTULLI_API_KEY}
|
- TAUTULLI_API_KEY=${TAUTULLI_API_KEY}
|
||||||
- OVERSEER_URL=${OVERSEER_URL}
|
- OVERSEER_URL=${OVERSEER_URL}
|
||||||
- OVERSEER_API_KEY=${OVERSEER_API_KEY}
|
- OVERSEER_API_KEY=${OVERSEER_API_KEY}
|
||||||
|
- ABS_URL=${ABS_URL}
|
||||||
|
- ABS_API_TOKEN=${ABS_API_TOKEN}
|
||||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
- KOFI_VERIFICATION_TOKEN=${KOFI_VERIFICATION_TOKEN}
|
- KOFI_VERIFICATION_TOKEN=${KOFI_VERIFICATION_TOKEN}
|
||||||
- PLIVO_AUTH_ID=${PLIVO_AUTH_ID}
|
- PLIVO_AUTH_ID=${PLIVO_AUTH_ID}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
BookOpen,
|
||||||
Coffee,
|
Coffee,
|
||||||
Database,
|
Database,
|
||||||
Film,
|
Film,
|
||||||
@@ -66,6 +67,9 @@ interface SystemSettings {
|
|||||||
tvPerSeasonCost: number;
|
tvPerSeasonCost: number;
|
||||||
minWatchPercent: number;
|
minWatchPercent: number;
|
||||||
minWatchMinutes: number;
|
minWatchMinutes: number;
|
||||||
|
audiobookCreditsPerMinute: number;
|
||||||
|
audiobookMinListenMinutes: number;
|
||||||
|
audiobookRequestCost?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface KofiPayment {
|
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) => {
|
const toggleAdmin = async (userId: string, isAdmin: boolean) => {
|
||||||
try {
|
try {
|
||||||
await api.patch(`/admin/users/${userId}`, { isAdmin });
|
await api.patch(`/admin/users/${userId}`, { isAdmin });
|
||||||
@@ -409,6 +424,14 @@ export default function AdminPage() {
|
|||||||
>
|
>
|
||||||
BACKFILL
|
BACKFILL
|
||||||
</Button>
|
</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" />
|
<div className="h-10 w-px bg-foreground/10 mx-1 hidden sm:block" />
|
||||||
|
|
||||||
@@ -667,7 +690,7 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-bold text-muted-foreground mt-1">
|
<div className="text-sm font-bold text-muted-foreground mt-1">
|
||||||
{r.user?.plexUsername || "Unknown"} •{" "}
|
{r.user?.plexUsername || "Unknown"} •{" "}
|
||||||
{r.mediaType === "movie" ? "Movie" : "TV"} •{" "}
|
{r.mediaType === "movie" ? "Movie" : r.mediaType === "audiobook" ? "Audiobook" : "TV"} •{" "}
|
||||||
{formatNumber(r.creditsCost)} $COOP
|
{formatNumber(r.creditsCost)} $COOP
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { transactionApi } from "@/lib/api";
|
import { transactionApi } from "@/lib/api";
|
||||||
|
|
||||||
export function ActivityFeed() {
|
export function ActivityFeed() {
|
||||||
const [items, setItems] = useState<any[]>([]);
|
const [items, setItems] = useState<any[]>([]);
|
||||||
useEffect(() => {
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const fetchActivity = () => {
|
||||||
transactionApi
|
transactionApi
|
||||||
.getRecentActivity()
|
.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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-lg font-semibold">Recent activity</div>
|
<div className="text-lg font-semibold">Recent activity</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
type SearchMode = "media" | "audiobooks";
|
||||||
|
|
||||||
export function SearchRequestModal({
|
export function SearchRequestModal({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -32,6 +34,16 @@ export function SearchRequestModal({
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<any[]>([]);
|
const [results, setResults] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
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 getTVCost = (item: any) => {
|
||||||
const base = costs.tvAdjusted || costs.tv;
|
const base = costs.tvAdjusted || costs.tv;
|
||||||
@@ -46,11 +58,31 @@ export function SearchRequestModal({
|
|||||||
const search = async () => {
|
const search = async () => {
|
||||||
if (!query) return;
|
if (!query) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setResults([]);
|
||||||
try {
|
try {
|
||||||
const res = await api.get(
|
if (mode === "audiobooks") {
|
||||||
`/overseer/search?query=${encodeURIComponent(query)}`,
|
const res = await api.get(
|
||||||
);
|
`/listenarr/search?q=${encodeURIComponent(query)}`,
|
||||||
setResults(res.data?.results || res.data || []);
|
);
|
||||||
|
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 {
|
} catch {
|
||||||
toast.error("Chickens lost the trail! Search failed.");
|
toast.error("Chickens lost the trail! Search failed.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -60,15 +92,50 @@ export function SearchRequestModal({
|
|||||||
|
|
||||||
const request = async (item: any, mediaType: string) => {
|
const request = async (item: any, mediaType: string) => {
|
||||||
try {
|
try {
|
||||||
const cost = mediaType === "movie" ? (costs.movieAdjusted || costs.movie) : getTVCost(item);
|
if (mediaType === "audiobook") {
|
||||||
await api.post("/overseer/request", {
|
const cost = costs.tvAdjusted || costs.tv;
|
||||||
mediaType,
|
const seriesRaw = item.series;
|
||||||
mediaId: item.id || item.tmdbId,
|
const seriesName = Array.isArray(seriesRaw)
|
||||||
title: item.title || item.name,
|
? (seriesRaw[0]?.name || "")
|
||||||
seasons: item.seasons,
|
: typeof seriesRaw === "string"
|
||||||
numberOfEpisodes: item.numberOfEpisodes || 0,
|
? seriesRaw
|
||||||
});
|
: "";
|
||||||
toast.success(`Golden egg sent! ${cost} $COOP deducted.`);
|
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);
|
onOpenChange(false);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e?.response?.data?.error || "Request flew the coop!");
|
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="p-6 space-y-6 font-sans">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="flex flex-col flex-1 gap-3">
|
||||||
<Input
|
{/* Mode toggle */}
|
||||||
value={query}
|
<div className="flex gap-2">
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
<button
|
||||||
onKeyDown={(e) => e.key === "Enter" && search()}
|
type="button"
|
||||||
placeholder="Type movie or show name..."
|
onClick={() => { setMode("media"); setResults([]); }}
|
||||||
className="rounded-full border-4 border-foreground h-14 pl-6 pr-12 font-bold text-lg focus-visible:ring-primary shadow-sm"
|
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"
|
||||||
<Search className="absolute right-4 top-1/2 -translate-y-1/2 h-6 w-6 text-muted-foreground" />
|
? "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>
|
</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>
|
||||||
|
|
||||||
<div className="max-h-[50vh] overflow-y-auto pr-2 space-y-4 scrollbar-hide">
|
<div className="max-h-[50vh] overflow-y-auto pr-2 space-y-4 scrollbar-hide">
|
||||||
{results.map((r) => {
|
{results.map((r) => {
|
||||||
const itemCost =
|
const mt = getMediaType(r);
|
||||||
r.mediaType === "movie" ? (costs.movieAdjusted || costs.movie) : getTVCost(r);
|
const itemCost = mt === "movie" ? (costs.movieAdjusted || costs.movie) : getTVCost(r);
|
||||||
return (
|
return (
|
||||||
<div
|
<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)]"
|
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">
|
<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
|
<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}
|
alt={r.title || r.name}
|
||||||
className="h-full w-full object-cover group-hover:scale-110 transition-transform duration-300"
|
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">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -142,12 +243,14 @@ export function SearchRequestModal({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
<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">
|
<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" />
|
<Film className="h-3 w-3" />
|
||||||
|
) : mt === "audiobook" ? (
|
||||||
|
<BookOpen className="h-3 w-3" />
|
||||||
) : (
|
) : (
|
||||||
<Tv className="h-3 w-3" />
|
<Tv className="h-3 w-3" />
|
||||||
)}
|
)}
|
||||||
{r.mediaType || "unknown"}
|
{mt === "audiobook" ? "Audiobook" : mt || "unknown"}
|
||||||
</span>
|
</span>
|
||||||
{r.releaseDate && (
|
{r.releaseDate && (
|
||||||
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
|
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
|
||||||
@@ -159,16 +262,26 @@ export function SearchRequestModal({
|
|||||||
{r.numberOfEpisodes} eps
|
{r.numberOfEpisodes} eps
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{r.runtimeLengthMin && (
|
||||||
|
<span className="text-[10px] font-bold text-muted-foreground">
|
||||||
|
{Math.floor(r.runtimeLengthMin / 60)}h {r.runtimeLengthMin % 60}m
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-4 mt-2">
|
<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">
|
<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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
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"
|
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" />
|
REQUEST <PlusCircle className="ml-1 h-3 w-3" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { useEffect, useState } from "react";
|
||||||
import { userApi } from "@/lib/api";
|
import { userApi } from "@/lib/api";
|
||||||
import { formatNumber } from "@/lib/utils";
|
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">
|
<div className="rounded-full bg-secondary/30 p-2 border-2 border-foreground">
|
||||||
{e.contentType === "movie" ? (
|
{e.contentType === "movie" ? (
|
||||||
<Film className="h-4 w-4" />
|
<Film className="h-4 w-4" />
|
||||||
|
) : e.contentType === "audiobook" ? (
|
||||||
|
<BookOpen className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Tv className="h-4 w-4" />
|
<Tv className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function DashboardPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isAuthenticated, logout } = useStore();
|
const { isAuthenticated, logout } = useStore();
|
||||||
const [wallet, setWallet] = useState<WalletData | null>(null);
|
const [wallet, setWallet] = useState<WalletData | null>(null);
|
||||||
const [requestCosts, setRequestCosts] = useState({
|
const [requestCosts, setRequestCosts] = useState<any>({
|
||||||
movie: 500,
|
movie: 500,
|
||||||
tv: 1000,
|
tv: 1000,
|
||||||
movieAdjusted: 500,
|
movieAdjusted: 500,
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export const adminApi = {
|
|||||||
api.get(`/transactions/admin/all?page=${page}&limit=${limit}`),
|
api.get(`/transactions/admin/all?page=${page}&limit=${limit}`),
|
||||||
getSystemStats: () => api.get("/transactions/admin/stats"),
|
getSystemStats: () => api.get("/transactions/admin/stats"),
|
||||||
backfillUser: (userId: string) => api.post(`/admin/users/${userId}/backfill`),
|
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"),
|
getKofiPayments: () => api.get("/admin/kofi-payments"),
|
||||||
getAllRequests: () => api.get("/admin/requests"),
|
getAllRequests: () => api.get("/admin/requests"),
|
||||||
syncAllRequests: () => api.post("/admin/sync-requests"),
|
syncAllRequests: () => api.post("/admin/sync-requests"),
|
||||||
@@ -126,3 +127,13 @@ export const tautulliApi = {
|
|||||||
getStats: () => api.get("/tautulli/stats"),
|
getStats: () => api.get("/tautulli/stats"),
|
||||||
getWebhookConfig: () => api.get("/tautulli/webhook-config"),
|
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