-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
212 lines (179 loc) · 7.43 KB
/
Copy pathserver.js
File metadata and controls
212 lines (179 loc) · 7.43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
import "dotenv/config";
import express from "express";
import cors from "cors";
import helmet from "helmet";
import morgan from "morgan";
import rateLimit from "express-rate-limit";
import { LRUCache } from "lru-cache";
import { META, ANIME } from "@consumet/extensions";
const app = express();
const PORT = process.env.PORT || 6969;
// Security headers (CSP disabled so external CDNs/players keep working as before)
app.use(helmet({ contentSecurityPolicy: false }));
// Request logging
app.use(morgan("dev"));
// Enable CORS for frontend local development
app.use(cors());
// Rate limit the API to protect the upstream scraper from abuse
const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60, // 60 requests per minute per IP
standardHeaders: true,
legacyHeaders: false,
message: { error: "Too many requests, please slow down." }
});
app.use("/api", apiLimiter);
// Serve static frontend files from public directory
app.use(express.static("public"));
// In-memory LRU cache for details requests (capped to avoid unbounded growth)
const CACHE_DURATION = 2 * 60 * 60 * 1000; // 2 hours in milliseconds
const infoCache = new LRUCache({ max: 500, ttl: CACHE_DURATION });
// Wrap a provider promise with a timeout and a single retry on failure
const PROVIDER_TIMEOUT = 15000; // 15 seconds
const withTimeout = (promiseFactory, label) => {
const attempt = () =>
Promise.race([
promiseFactory(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`${label} timed out after ${PROVIDER_TIMEOUT}ms`)), PROVIDER_TIMEOUT)
)
]);
return attempt().catch((err) => {
console.warn(`[Provider Retry] ${label} failed (${err.message}). Retrying once...`);
return attempt();
});
};
// Helper function to resolve the Anilist instance with the desired backing provider
const getAnilistInstance = (providerName) => {
const name = String(providerName || "unity").toLowerCase();
if (name === "saturn") {
return new META.Anilist(new ANIME.AnimeSaturn());
}
// Default to AnimeUnity
return new META.Anilist(new ANIME.AnimeUnity());
};
// Search endpoint
app.get("/api/search", async (req, res, next) => {
const query = typeof req.query.q === "string" ? req.query.q.trim() : "";
const provider = req.query.provider;
const page = Number.parseInt(req.query.page, 10);
const safePage = Number.isInteger(page) && page > 0 ? page : 1;
if (!query) {
return res.status(400).json({ error: "Query parameter 'q' is required" });
}
try {
const anilist = getAnilistInstance(provider);
console.log(`[API] Searching for "${query}" using provider: ${provider || "unity"} (page ${safePage})...`);
const results = await withTimeout(() => anilist.search(query, safePage), "search");
res.json(results);
} catch (error) {
next(error);
}
});
// Trending / Popular endpoint
app.get("/api/trending", async (req, res, next) => {
const provider = req.query.provider;
const page = Number.parseInt(req.query.page, 10);
const safePage = Number.isInteger(page) && page > 0 ? page : 1;
try {
const anilist = getAnilistInstance(provider);
console.log(`[API] Fetching trending anime using provider: ${provider || "unity"} (page ${safePage})...`);
// fetchRecentEpisodes or advancedSearch with POPULARITY_DESC
const results = await withTimeout(
() => anilist.advancedSearch(undefined, "ANIME", safePage, 15, undefined, ["POPULARITY_DESC"]),
"trending"
);
res.json(results);
} catch (error) {
next(error);
}
});
// Anime Info endpoint
app.get("/api/info/:id", async (req, res, next) => {
const id = req.params.id;
const provider = req.query.provider;
const cacheKey = `${provider || "unity"}:${id}`;
if (!id) {
return res.status(400).json({ error: "Anime ID is required" });
}
// Check cache first
const cached = infoCache.get(cacheKey);
if (cached) {
console.log(`[Cache Hit] Serving Info for ${cacheKey}`);
return res.json(cached);
}
try {
const anilist = getAnilistInstance(provider);
console.log(`[API Cache Miss] Fetching info for ID: ${id} using provider: ${provider || "unity"}...`);
const info = await withTimeout(() => anilist.fetchAnimeInfo(id), "fetchAnimeInfo");
// If provider is AnimeUnity and there are multiple pages of episodes, fetch and merge them
if (String(provider || "unity").toLowerCase() === "unity" && info.episodes && info.episodes.length > 0) {
const totalEpisodesCount = info.totalEpisodes || info.episodes.length;
const totalPages = Math.ceil(totalEpisodesCount / 120);
if (totalPages > 1) {
try {
const firstEp = info.episodes[0];
const mappedId = firstEp.id.split("/")[0];
console.log(`[API Pagination] AnimeUnity calculated total pages: ${totalPages}. Fetching pages 2 to ${totalPages} for mapped ID "${mappedId}"...`);
// Fetch all remaining pages in parallel
const pagePromises = [];
for (let p = 2; p <= totalPages; p++) {
pagePromises.push(anilist.provider.fetchAnimeInfo(mappedId, p));
}
const pagesResults = await Promise.all(pagePromises);
pagesResults.forEach((pageInfo) => {
if (pageInfo && pageInfo.episodes) {
info.episodes = info.episodes.concat(pageInfo.episodes);
}
});
// Deduplicate and sort episodes numerically to ensure correct ordering
info.episodes = Array.from(new Set(info.episodes.map(e => JSON.stringify(e)))).map(s => JSON.parse(s));
info.episodes.sort((a, b) => a.number - b.number);
console.log(`[API Pagination] Successfully merged all pages. Total episodes: ${info.episodes.length}`);
} catch (paginateError) {
console.error("[API Pagination Error] Failed to fetch additional pages:", paginateError.message);
}
}
}
// Save to cache
infoCache.set(cacheKey, info);
res.json(info);
} catch (error) {
next(error);
}
});
// Streaming Sources endpoint
app.get("/api/sources", async (req, res, next) => {
const episodeId = typeof req.query.episodeId === "string" ? req.query.episodeId.trim() : "";
const provider = req.query.provider;
if (!episodeId) {
return res.status(400).json({ error: "Episode ID query parameter 'episodeId' is required" });
}
try {
const anilist = getAnilistInstance(provider);
console.log(`[API] Fetching sources for Episode ID: ${episodeId} using provider: ${provider || "unity"}...`);
const sources = await withTimeout(() => anilist.fetchEpisodeSources(episodeId), "fetchEpisodeSources");
res.json(sources);
} catch (error) {
next(error);
}
});
// Unknown API routes should return JSON 404, not the SPA shell
app.use("/api", (req, res) => {
res.status(404).json({ error: "API route not found" });
});
// Fallback index.html route for SPA client routing
app.get("*", (req, res) => {
res.sendFile("index.html", { root: "public" });
});
// Centralized error handler
app.use((err, req, res, next) => {
console.error(`[API Error] ${req.method} ${req.originalUrl}:`, err.message);
res.status(500).json({ error: "Request failed", details: err.message });
});
app.listen(PORT, () => {
console.log(`===================================================`);
console.log(` Anime Proxy Server running at http://localhost:${PORT}`);
console.log(` Serving static files and API routes`);
console.log(`===================================================`);
});