Compare commits
34 Commits
a1d70193fa
...
c380d20ef0
| Author | SHA1 | Date | |
|---|---|---|---|
| c380d20ef0 | |||
| 39ce24de91 | |||
| f4eb96c4ec | |||
| 6387d4a373 | |||
| c225a9f48d | |||
| 82ddc6d5e9 | |||
| 8296e8d7da | |||
| ba05e08e71 | |||
| 6c9f021e8d | |||
| 5fd2341e8e | |||
| 5b5cedcc98 | |||
| 5401a5e302 | |||
| 4a711accad | |||
| a9fc4b0ece | |||
| e2345aa20a | |||
| 5cf034200e | |||
| 6e51bf8568 | |||
| 4bf23980c2 | |||
| 5fe0e319b9 | |||
| d9c1ba3d27 | |||
| cde09c6ffa | |||
| c65a2e1a5b | |||
| c5a96d59ff | |||
| 148beb6c5a | |||
| 57acf10f38 | |||
| 3fe39bd6df | |||
| 136914ba4a | |||
| 8d4d099c6a | |||
| 40197b6476 | |||
| f72fff982c | |||
| 942cab2f25 | |||
| 5c4f4cf3b6 | |||
| 59fdadd288 | |||
| f5cfa29b64 |
54
desktop/package-lock.json
generated
54
desktop/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@xhayper/discord-rpc": "^1.3.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"bcryptjs": "^3.0.3",
|
||||
@@ -24,13 +25,15 @@
|
||||
"node-addon-api": "^8.5.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"playwright-chromium": "^1.57.0",
|
||||
"sqlite3": "^5.1.7"
|
||||
"sqlite3": "^5.1.7",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "^39.2.5",
|
||||
"electron-builder": "^26.0.12",
|
||||
"node-gyp": "^12.1.0",
|
||||
@@ -1193,6 +1196,27 @@
|
||||
"glob": "^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/websocket": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz",
|
||||
"integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"duplexify": "^4.1.3",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"ws": "^8.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@gar/promisify": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
|
||||
@@ -1640,6 +1664,16 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||
@@ -3398,6 +3432,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexify": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
|
||||
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.4.1",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1",
|
||||
"stream-shift": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
@@ -7645,6 +7691,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stream-shift": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
|
||||
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@xhayper/discord-rpc": "^1.3.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"bcryptjs": "^3.0.3",
|
||||
@@ -27,13 +28,15 @@
|
||||
"node-addon-api": "^8.5.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"playwright-chromium": "^1.57.0",
|
||||
"sqlite3": "^5.1.7"
|
||||
"sqlite3": "^5.1.7",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "^39.2.5",
|
||||
"electron-builder": "^26.0.12",
|
||||
"node-gyp": "^12.1.0",
|
||||
|
||||
@@ -32,19 +32,95 @@ const listRoutes = require("./electron/api/list/list.routes");
|
||||
const anilistRoute = require("./electron/api/anilist/anilist");
|
||||
const localRoutes = require("./electron/api/local/local.routes");
|
||||
const configRoutes = require("./electron/api/config/config.routes");
|
||||
const roomRoutes = require("./electron/api/rooms/rooms.routes");
|
||||
const { setupRoomWebSocket } = require("./electron/api/rooms/rooms.websocket");
|
||||
|
||||
fastify.addHook("preHandler", async (request) => {
|
||||
const { getConfig } = require('./electron/shared/config');
|
||||
const { values } = getConfig();
|
||||
const jwtSecret = values.server?.jwt_secret;
|
||||
|
||||
fastify.addHook("preHandler", async (request, reply) => {
|
||||
const auth = request.headers.authorization;
|
||||
if (!auth) return;
|
||||
|
||||
try {
|
||||
const token = auth.replace("Bearer ", "");
|
||||
request.user = jwt.verify(token, process.env.JWT_SECRET);
|
||||
request.user = jwt.verify(token, jwtSecret);
|
||||
} catch (e) {
|
||||
return reply.code(401).send({ error: "Invalid token" });
|
||||
}
|
||||
});
|
||||
|
||||
const roomService = require('./electron/api/rooms/rooms.service');
|
||||
|
||||
fastify.addHook('onRequest', async (req, reply) => {
|
||||
const isTunnel =
|
||||
!!req.headers['cf-connecting-ip'] ||
|
||||
!!req.headers['cf-ray'];
|
||||
|
||||
if (!isTunnel) return;
|
||||
|
||||
if (req.url.startsWith('/public/') ||
|
||||
req.url.startsWith('/views/') ||
|
||||
req.url.startsWith('/src/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url.startsWith('/room')) {
|
||||
const urlParams = new URLSearchParams(req.url.split('?')[1]);
|
||||
const roomId = urlParams.get('id');
|
||||
|
||||
if (!roomId) {
|
||||
return reply.code(404).send({ error: 'Room ID required' });
|
||||
}
|
||||
|
||||
const room = roomService.getRoom(roomId);
|
||||
if (!room || room.exposed !== true) {
|
||||
return reply.code(404).send({ error: 'Room not found' });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const wsMatch = req.url.match(/^\/ws\/room\/([a-f0-9]+)/);
|
||||
if (wsMatch) {
|
||||
const roomId = wsMatch[1];
|
||||
const room = roomService.getRoom(roomId);
|
||||
|
||||
if (!room || room.exposed !== true) {
|
||||
return reply.code(404).send({ error: 'Room not found' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const apiMatch = req.url.match(/^\/api\/rooms\/([a-f0-9]+)/);
|
||||
if (apiMatch) {
|
||||
const roomId = apiMatch[1];
|
||||
const room = roomService.getRoom(roomId);
|
||||
|
||||
if (!room || room.exposed !== true) {
|
||||
return reply.code(404).send({ error: 'Room not found' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedEndpoints = [
|
||||
'/api/watch/stream',
|
||||
'/api/proxy',
|
||||
'/api/extensions',
|
||||
'/api/search'
|
||||
];
|
||||
|
||||
for (const endpoint of allowedEndpoints) {
|
||||
if (req.url.startsWith(endpoint)) {
|
||||
console.log('[Tunnel] ✓ Allowing utility endpoint:', endpoint);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return reply.code(404).send({ error: 'Not found' });
|
||||
});
|
||||
|
||||
fastify.register(require("@fastify/static"), {
|
||||
root: path.join(__dirname, "public"),
|
||||
prefix: "/public/",
|
||||
@@ -75,11 +151,13 @@ fastify.register(anilistRoute, { prefix: "/api" });
|
||||
fastify.register(listRoutes, { prefix: "/api" });
|
||||
fastify.register(localRoutes, { prefix: "/api" });
|
||||
fastify.register(configRoutes, { prefix: "/api" });
|
||||
fastify.register(roomRoutes, { prefix: "/api" });
|
||||
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
await fastify.register(require('@fastify/websocket'));
|
||||
ensureConfigFile()
|
||||
initDatabase("anilist");
|
||||
initDatabase("favorites");
|
||||
@@ -87,7 +165,7 @@ const start = async () => {
|
||||
initDatabase("userdata");
|
||||
initDatabase("local_library");
|
||||
init();
|
||||
|
||||
setupRoomWebSocket(fastify);
|
||||
|
||||
const refreshAll = async () => {
|
||||
await refreshTrendingAnime();
|
||||
@@ -99,7 +177,7 @@ const start = async () => {
|
||||
await refreshPopularBooks();
|
||||
};
|
||||
|
||||
cron.schedule("*/30 * * * *", async () => {
|
||||
const job = cron.schedule("*/30 * * * *", async () => {
|
||||
try {
|
||||
await refreshAll();
|
||||
console.log("cache refreshed");
|
||||
@@ -116,6 +194,15 @@ const start = async () => {
|
||||
console.error("initial refresh failed", e)
|
||||
);
|
||||
console.log(`Server running at http://localhost:54322`);
|
||||
|
||||
const shutdown = async () => {
|
||||
job.stop();
|
||||
await fastify.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -2,58 +2,49 @@ import { FastifyInstance } from "fastify";
|
||||
import { run } from "../../shared/database";
|
||||
|
||||
async function anilist(fastify: FastifyInstance) {
|
||||
fastify.get("/anilist", async (request, reply) => {
|
||||
fastify.post("/anilist/store", async (request, reply) => {
|
||||
try {
|
||||
const { code, state } = request.query as { code?: string; state?: string };
|
||||
const {
|
||||
userId,
|
||||
accessToken,
|
||||
tokenType = "Bearer",
|
||||
expiresIn
|
||||
} = request.body as {
|
||||
userId: number;
|
||||
accessToken: string;
|
||||
tokenType?: string;
|
||||
expiresIn?: number;
|
||||
};
|
||||
|
||||
if (!code) return reply.status(400).send("No code");
|
||||
if (!state) return reply.status(400).send("No user state");
|
||||
|
||||
const userId = Number(state);
|
||||
if (!userId || isNaN(userId)) {
|
||||
return reply.status(400).send("Invalid user id");
|
||||
}
|
||||
|
||||
const tokenRes = await fetch("https://anilist.co/api/v2/oauth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
client_id: process.env.ANILIST_CLIENT_ID,
|
||||
client_secret: process.env.ANILIST_CLIENT_SECRET,
|
||||
redirect_uri: "http://localhost:54322/api/anilist",
|
||||
code
|
||||
})
|
||||
});
|
||||
|
||||
const tokenData = await tokenRes.json();
|
||||
|
||||
if (!tokenData.access_token) {
|
||||
console.error("AniList token error:", tokenData);
|
||||
return reply.status(500).send("Failed to get AniList token");
|
||||
if (!userId || !accessToken) {
|
||||
return reply.status(400).send({ error: "Faltan datos (User ID o Token)" });
|
||||
}
|
||||
|
||||
// 1. Verificar que el token es válido consultando a AniList
|
||||
const userRes = await fetch("https://graphql.anilist.co", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `${tokenData.token_type} ${tokenData.access_token}`
|
||||
Authorization: `${tokenType} ${accessToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: `query { Viewer { id } }`
|
||||
})
|
||||
});
|
||||
|
||||
if (!userRes.ok) {
|
||||
return reply.status(401).send({ error: "Token de AniList inválido o expirado" });
|
||||
}
|
||||
|
||||
const userData = await userRes.json();
|
||||
const anilistUserId = userData?.data?.Viewer?.id;
|
||||
|
||||
if (!anilistUserId) {
|
||||
console.error("AniList Viewer error:", userData);
|
||||
return reply.status(500).send("Failed to fetch AniList user");
|
||||
return reply.status(500).send({ error: "No se pudo obtener el ID de usuario de AniList" });
|
||||
}
|
||||
|
||||
const expiresAt = new Date(
|
||||
Date.now() + tokenData.expires_in * 1000
|
||||
Date.now() + 365 * 24 * 60 * 60 * 1000
|
||||
).toISOString();
|
||||
|
||||
await run(
|
||||
@@ -71,19 +62,19 @@ async function anilist(fastify: FastifyInstance) {
|
||||
[
|
||||
userId,
|
||||
"AniList",
|
||||
tokenData.access_token,
|
||||
tokenData.refresh_token,
|
||||
tokenData.token_type,
|
||||
accessToken,
|
||||
"", // <- aquí
|
||||
tokenType,
|
||||
anilistUserId,
|
||||
expiresAt
|
||||
],
|
||||
"userdata"
|
||||
);
|
||||
|
||||
return reply.redirect("http://localhost:54322/?anilist=success");
|
||||
return reply.send({ ok: true, anilistUserId });
|
||||
} catch (e) {
|
||||
console.error("AniList error:", e);
|
||||
return reply.redirect("http://localhost:54322/?anilist=error");
|
||||
return reply.status(500).send({ error: "Error interno del servidor al guardar AniList" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ export async function searchInExtension(req: any, reply: FastifyReply) {
|
||||
|
||||
export async function getWatchStream(req: WatchStreamRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { animeId, episode, server, category, ext, source } = req.query;
|
||||
const { animeId, episode, server, category, ext, source, extensionAnimeId } = req.query;
|
||||
|
||||
const extension = getExtension(ext);
|
||||
if (!extension) return { error: "Extension not found" };
|
||||
@@ -107,7 +107,8 @@ export async function getWatchStream(req: WatchStreamRequest, reply: FastifyRepl
|
||||
animeId,
|
||||
source,
|
||||
server,
|
||||
category
|
||||
category,
|
||||
extensionAnimeId
|
||||
);
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
@@ -117,27 +118,42 @@ export async function getWatchStream(req: WatchStreamRequest, reply: FastifyRepl
|
||||
|
||||
export async function openInMPV(req: any, reply: any) {
|
||||
try {
|
||||
const { title, video, subtitles = [], chapters = [], animeId, episode, entrySource, token } = req.body;
|
||||
const { title, video, subtitles = [], chapters = [], animeId, episode, entrySource } = req.body;
|
||||
|
||||
if (!video?.url) return { error: 'Missing video url' };
|
||||
|
||||
const isLocalPath = (p: string) =>
|
||||
p.startsWith('file://') ||
|
||||
p.startsWith('/') ||
|
||||
/^[a-zA-Z]:\\/.test(p);
|
||||
|
||||
const toFileUrl = (p: string) =>
|
||||
p.startsWith('file://')
|
||||
? p
|
||||
: `file://${p.replace(/\\/g, '/')}`;
|
||||
|
||||
const PORT = 54322;
|
||||
const proxyBase = `http://localhost:${PORT}/api/proxy`;
|
||||
const mediaTitle = title || 'Anime';
|
||||
|
||||
const proxyVideo =
|
||||
`${proxyBase}?url=${encodeURIComponent(video.url)}` +
|
||||
const videoUrl = isLocalPath(video.url)
|
||||
? toFileUrl(video.url)
|
||||
: `${proxyBase}?url=${encodeURIComponent(video.url)}` +
|
||||
`&referer=${encodeURIComponent(video.headers?.Referer ?? '')}` +
|
||||
`&origin=${encodeURIComponent(video.headers?.Origin ?? '')}` +
|
||||
`&userAgent=${encodeURIComponent(video.headers?.['User-Agent'] ?? '')}`;
|
||||
|
||||
const proxySubs = subtitles.map((s: any) =>
|
||||
`${proxyBase}?url=${encodeURIComponent(s.src)}` +
|
||||
`&referer=${encodeURIComponent(video.headers?.Referer ?? '')}` +
|
||||
`&origin=${encodeURIComponent(video.headers?.Origin ?? '')}` +
|
||||
`&userAgent=${encodeURIComponent(video.headers?.['User-Agent'] ?? '')}`
|
||||
|
||||
const subsUrls = subtitles.map((s: any) =>
|
||||
isLocalPath(s.src)
|
||||
? toFileUrl(s.src)
|
||||
: `${proxyBase}?url=${encodeURIComponent(s.src)}` +
|
||||
`&referer=${encodeURIComponent(video.headers?.Referer ?? '')}` +
|
||||
`&origin=${encodeURIComponent(video.headers?.Origin ?? '')}` +
|
||||
`&userAgent=${encodeURIComponent(video.headers?.['User-Agent'] ?? '')}`
|
||||
);
|
||||
|
||||
|
||||
const pipe = `\\\\.\\pipe\\mpv-${Date.now()}`;
|
||||
const { values } = loadConfig();
|
||||
|
||||
@@ -234,10 +250,9 @@ export async function openInMPV(req: any, reply: any) {
|
||||
};
|
||||
|
||||
const updateProgress = async () => {
|
||||
if (!token || progressUpdated) return;
|
||||
if (!req.user || progressUpdated) return;
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as any;
|
||||
const userId = decoded.id;
|
||||
const userId = req.user.id;
|
||||
await upsertListEntry({
|
||||
user_id: userId,
|
||||
entry_id: animeId,
|
||||
@@ -247,6 +262,7 @@ export async function openInMPV(req: any, reply: any) {
|
||||
progress: episode
|
||||
});
|
||||
progressUpdated = true;
|
||||
progressUpdated = true;
|
||||
} catch (e) { console.error("[MPV] Progress update failed", e); }
|
||||
};
|
||||
|
||||
@@ -302,7 +318,7 @@ export async function openInMPV(req: any, reply: any) {
|
||||
{ command: ['observe_property', 1, 'duration'] },
|
||||
{ command: ['observe_property', 2, 'time-pos'] },
|
||||
{ command: ['observe_property', 3, 'pause'] },
|
||||
{ command: ['loadfile', proxyVideo, 'replace'] }
|
||||
{ command: ['loadfile', videoUrl, 'replace'] }
|
||||
];
|
||||
|
||||
commands.forEach(cmd => socket.write(JSON.stringify(cmd) + '\n'));
|
||||
@@ -311,7 +327,7 @@ export async function openInMPV(req: any, reply: any) {
|
||||
socket.write(JSON.stringify({
|
||||
command: [
|
||||
'sub-add',
|
||||
proxySubs[i],
|
||||
subsUrls[i],
|
||||
'auto',
|
||||
s.label || 'Subtitle',
|
||||
s.srclang || ''
|
||||
|
||||
@@ -276,7 +276,6 @@ export async function getAnimeInfoExtension(ext: Extension | null, id: string):
|
||||
if (!ext) return { error: "not found" };
|
||||
|
||||
const extName = ext.constructor.name;
|
||||
|
||||
const cached = await getCachedExtension(extName, id);
|
||||
if (cached) {
|
||||
try {
|
||||
@@ -341,6 +340,7 @@ export async function searchAnimeInExtension(ext: Extension | null, name: string
|
||||
averageScore: m.rating || m.score || null,
|
||||
format: 'ANIME',
|
||||
seasonYear: null,
|
||||
url: m.url,
|
||||
isExtensionResult: true,
|
||||
}));
|
||||
}
|
||||
@@ -361,15 +361,36 @@ export async function searchEpisodesInExtension(ext: Extension | null, name: str
|
||||
if (cached) {
|
||||
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
|
||||
|
||||
if (!isExpired) {
|
||||
console.log(`[${name}] Episodes cache hit for: ${query}`);
|
||||
try {
|
||||
return JSON.parse(cached.result) as Episode[];
|
||||
} catch (e) {
|
||||
console.error(`[${name}] Error parsing cached episodes:`, e);
|
||||
try {
|
||||
const parsed = JSON.parse(cached.result) as {
|
||||
mediaId?: string;
|
||||
episodes: Episode[];
|
||||
};
|
||||
|
||||
if (!isExpired) {
|
||||
console.log(`[${name}] Episodes cache hit for: ${query}`);
|
||||
return parsed.episodes;
|
||||
}
|
||||
} else {
|
||||
console.log(`[${name}] Episodes cache expired for: ${query}`);
|
||||
|
||||
// Si el caché expiró pero tenemos mediaId, refrescamos directamente
|
||||
if (parsed.mediaId && ext.type === "anime-board" && ext.search && typeof ext.findEpisodes === "function") {
|
||||
console.log(`[${name}] Episodes cache expired but mediaId found, refreshing...`);
|
||||
const chapterList = await ext.findEpisodes(parsed.mediaId);
|
||||
|
||||
if (!Array.isArray(chapterList)) return [];
|
||||
|
||||
const result: Episode[] = chapterList.map(ep => ({
|
||||
id: ep.id,
|
||||
number: ep.number,
|
||||
url: ep.url,
|
||||
title: ep.title
|
||||
}));
|
||||
|
||||
await setCache(cacheKey, { mediaId: parsed.mediaId, episodes: result }, CACHE_TTL_MS);
|
||||
return result;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[${name}] Error parsing cached episodes:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,20 +408,40 @@ export async function searchEpisodesInExtension(ext: Extension | null, name: str
|
||||
startDate: { year: 0, month: 0, day: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
if (!matches || matches.length === 0) return [];
|
||||
|
||||
const res = matches[0];
|
||||
const normalizedQuery = normalize(query);
|
||||
const scored = matches.map(match => {
|
||||
const normalizedTitle = normalize(match.title);
|
||||
const score = similarity(normalizedQuery, normalizedTitle);
|
||||
|
||||
let bonus = 0;
|
||||
if (normalizedTitle === normalizedQuery) {
|
||||
bonus = 0.5;
|
||||
} else if (normalizedTitle.toLowerCase().includes(normalizedQuery.toLowerCase())) {
|
||||
bonus = 0.5;
|
||||
}
|
||||
|
||||
const finalScore = score + bonus;
|
||||
|
||||
return {
|
||||
match,
|
||||
score: finalScore
|
||||
};
|
||||
});
|
||||
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const bestMatches = scored.filter(s => s.score > 0.4);
|
||||
|
||||
if (bestMatches.length === 0) return [];
|
||||
const res = bestMatches[0].match;
|
||||
if (!res?.id) return [];
|
||||
|
||||
mediaId = res.id;
|
||||
|
||||
} else {
|
||||
mediaId = query;
|
||||
}
|
||||
|
||||
const chapterList = await ext.findEpisodes(mediaId);
|
||||
|
||||
if (!Array.isArray(chapterList)) return [];
|
||||
|
||||
const result: Episode[] = chapterList.map(ep => ({
|
||||
@@ -410,7 +451,8 @@ export async function searchEpisodesInExtension(ext: Extension | null, name: str
|
||||
title: ep.title
|
||||
}));
|
||||
|
||||
await setCache(cacheKey, result, CACHE_TTL_MS);
|
||||
// Cachear tanto el mediaId como los episodios
|
||||
await setCache(cacheKey, { mediaId, episodes: result }, CACHE_TTL_MS);
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
@@ -421,25 +463,27 @@ export async function searchEpisodesInExtension(ext: Extension | null, name: str
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function getStreamData(extension: Extension, episode: string, id: string, source: string, server?: string, category?: string): Promise<StreamData> {
|
||||
export async function getStreamData(extension: Extension, episode: string, id: string, source: string, server?: string, category?: string, extensionAnimeId?: string): Promise<StreamData> {
|
||||
const providerName = extension.constructor.name;
|
||||
|
||||
const cacheKey = `anime:stream:${providerName}:${id}:${episode}:${server || 'default'}:${category || 'sub'}`;
|
||||
const finalCategory = category ?? "sub";
|
||||
|
||||
const cached = await getCache(cacheKey);
|
||||
const cacheKey =
|
||||
`anime:stream:${providerName}:${id}:${episode}:${server}:${finalCategory}`;
|
||||
if (!extensionAnimeId) {
|
||||
const cached = await getCache(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
|
||||
if (cached) {
|
||||
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
|
||||
|
||||
if (!isExpired) {
|
||||
console.log(`[${providerName}] Stream data cache hit for episode ${episode}`);
|
||||
try {
|
||||
return JSON.parse(cached.result) as StreamData;
|
||||
} catch (e) {
|
||||
console.error(`[${providerName}] Error parsing cached stream data:`, e);
|
||||
if (!isExpired) {
|
||||
console.log(`[${providerName}] Stream data cache hit for episode ${episode}`);
|
||||
try {
|
||||
return JSON.parse(cached.result) as StreamData;
|
||||
} catch (e) {
|
||||
console.error(`[${providerName}] Error parsing cached stream data:`, e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[${providerName}] Stream data cache expired for episode ${episode}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,23 +491,85 @@ export async function getStreamData(extension: Extension, episode: string, id: s
|
||||
throw new Error("Extension doesn't support required methods");
|
||||
}
|
||||
let episodes;
|
||||
let animeTitle: string | undefined;
|
||||
|
||||
if (source === "anilist"){
|
||||
const anime: any = await getAnimeById(id)
|
||||
if (source === "anilist" && !extensionAnimeId) {
|
||||
const anime: any = await getAnimeById(id);
|
||||
episodes = await searchEpisodesInExtension(extension, extension.constructor.name, anime.title.romaji);
|
||||
} else {
|
||||
const targetId = extensionAnimeId ?? id;
|
||||
episodes = await extension.findEpisodes(targetId);
|
||||
|
||||
if (extensionAnimeId) {
|
||||
const anime: any = await getAnimeById(id);
|
||||
animeTitle = anime.title.romaji;
|
||||
|
||||
const episodesCacheKey = `anime:episodes:${providerName}:${animeTitle}`;
|
||||
const episodesResult: Episode[] = episodes.map((ep: any) => ({
|
||||
id: ep.id,
|
||||
number: ep.number,
|
||||
url: ep.url,
|
||||
title: ep.title
|
||||
}));
|
||||
|
||||
await setCache(episodesCacheKey, {
|
||||
mediaId: extensionAnimeId,
|
||||
episodes: episodesResult
|
||||
}, CACHE_TTL_MS);
|
||||
}
|
||||
}
|
||||
else{
|
||||
episodes = await extension.findEpisodes(id);
|
||||
}
|
||||
const targetEp = episodes.find(e => e.number === parseInt(episode));
|
||||
|
||||
const targetEp = episodes.find((e: any) => e.number === parseInt(episode));
|
||||
|
||||
if (!targetEp) {
|
||||
throw new Error("Episode not found");
|
||||
}
|
||||
|
||||
const serverName = server || "default";
|
||||
const streamData = await extension.findEpisodeServer(targetEp, serverName);
|
||||
const streamData = await extension.findEpisodeServer(targetEp, server, category);
|
||||
|
||||
await setCache(cacheKey, streamData, CACHE_TTL_MS);
|
||||
return streamData;
|
||||
}
|
||||
|
||||
function similarity(s1: string, s2: string): number {
|
||||
const str1 = normalize(s1);
|
||||
const str2 = normalize(s2);
|
||||
|
||||
const longer = str1.length > str2.length ? str1 : str2;
|
||||
const shorter = str1.length > str2.length ? str2 : str1;
|
||||
|
||||
if (longer.length === 0) return 1.0;
|
||||
|
||||
const editDistance = levenshteinDistance(longer, shorter);
|
||||
return (longer.length - editDistance) / longer.length;
|
||||
}
|
||||
|
||||
function levenshteinDistance(s1: string, s2: string): number {
|
||||
const costs: number[] = [];
|
||||
for (let i = 0; i <= s1.length; i++) {
|
||||
let lastValue = i;
|
||||
for (let j = 0; j <= s2.length; j++) {
|
||||
if (i === 0) {
|
||||
costs[j] = j;
|
||||
} else if (j > 0) {
|
||||
let newValue = costs[j - 1];
|
||||
if (s1.charAt(i - 1) !== s2.charAt(j - 1)) {
|
||||
newValue = Math.min(Math.min(newValue, lastValue), costs[j]) + 1;
|
||||
}
|
||||
costs[j - 1] = lastValue;
|
||||
lastValue = newValue;
|
||||
}
|
||||
}
|
||||
if (i > 0) costs[s2.length] = lastValue;
|
||||
}
|
||||
return costs[s2.length];
|
||||
}
|
||||
|
||||
function normalize(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/'/g, "'") // decodificar entidades HTML
|
||||
.replace(/[^\w\s]/g, ' ') // convertir puntuación a espacios
|
||||
.replace(/\s+/g, ' ') // normalizar espacios
|
||||
.trim();
|
||||
}
|
||||
@@ -88,9 +88,10 @@ export async function getChapters(req: any, reply: FastifyReply) {
|
||||
const { id } = req.params;
|
||||
const source = req.query.source || 'anilist';
|
||||
const provider = req.query.provider;
|
||||
const extensionBookId = req.query.extensionBookId;
|
||||
|
||||
const isExternal = source !== 'anilist';
|
||||
return await booksService.getChaptersForBook(id, isExternal, provider);
|
||||
return await booksService.getChaptersForBook(id, isExternal, provider, extensionBookId);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return { chapters: [] };
|
||||
|
||||
@@ -326,7 +326,8 @@ export async function searchBooksInExtension(ext: Extension | null, name: string
|
||||
averageScore: m.rating || m.score || null,
|
||||
format: m.format,
|
||||
seasonYear: null,
|
||||
isExtensionResult: true
|
||||
isExtensionResult: true,
|
||||
url: m.url,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -361,47 +362,82 @@ async function fetchBookMetadata(id: string): Promise<Book | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function searchChaptersInExtension(ext: Extension, name: string, searchTitle: string, search: boolean, origin: string): Promise<ChapterWithProvider[]> {
|
||||
const cacheKey = `chapters:${name}:${origin}:${search ? "search" : "id"}:${searchTitle}`;
|
||||
const cached = await getCache(cacheKey);
|
||||
async function searchChaptersInExtension(ext: Extension, name: string, lookupId: string, cacheId: string, search: boolean, origin: string, disableCache = false): Promise<ChapterWithProvider[]> {
|
||||
const cacheKey = `chapters:${name}:${origin}:id:${cacheId}`;
|
||||
if (!disableCache) {
|
||||
const cached = await getCache(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
|
||||
if (cached) {
|
||||
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
|
||||
|
||||
if (!isExpired) {
|
||||
console.log(`[${name}] Chapters cache hit for: ${searchTitle}`);
|
||||
try {
|
||||
return JSON.parse(cached.result) as ChapterWithProvider[];
|
||||
const parsed = JSON.parse(cached.result) as {
|
||||
mediaId?: string;
|
||||
chapters: ChapterWithProvider[];
|
||||
};
|
||||
|
||||
if (!isExpired) {
|
||||
console.log(`[${name}] Chapters cache hit for: ${lookupId}`);
|
||||
return parsed.chapters;
|
||||
}
|
||||
|
||||
if (parsed.mediaId) {
|
||||
const chaps = await ext.findChapters!(parsed.mediaId);
|
||||
|
||||
const result = chaps.map(ch => ({
|
||||
id: ch.id,
|
||||
number: parseFloat(ch.number.toString()),
|
||||
title: ch.title,
|
||||
date: ch.releaseDate,
|
||||
provider: name,
|
||||
index: ch.index,
|
||||
language: ch.language ?? null,
|
||||
}));
|
||||
|
||||
await setCache(cacheKey, { mediaId: parsed.mediaId, chapters: result }, CACHE_TTL_MS);
|
||||
return result;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[${name}] Error parsing cached chapters:`, e);
|
||||
}
|
||||
} else {
|
||||
console.log(`[${name}] Chapters cache expired for: ${searchTitle}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[${name}] Searching chapters for: ${searchTitle}`);
|
||||
console.log(`[${name}] Searching chapters for: ${lookupId}`);
|
||||
|
||||
let mediaId: string;
|
||||
if (search) {
|
||||
const matches = await ext.search!({
|
||||
query: searchTitle,
|
||||
query: lookupId,
|
||||
media: {
|
||||
romajiTitle: searchTitle,
|
||||
englishTitle: searchTitle,
|
||||
romajiTitle: lookupId,
|
||||
englishTitle: lookupId,
|
||||
startDate: { year: 0, month: 0, day: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
const best = matches?.[0];
|
||||
if (!matches?.length) return [];
|
||||
|
||||
if (!best) { return [] }
|
||||
const nq = normalize(lookupId);
|
||||
|
||||
mediaId = best.id;
|
||||
const scored = matches.map(m => {
|
||||
const nt = normalize(m.title);
|
||||
let score = similarity(nq, nt);
|
||||
|
||||
if (nt === nq || nt.includes(nq)) score += 0.5;
|
||||
|
||||
return { m, score };
|
||||
});
|
||||
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
|
||||
if (scored[0].score < 0.4) return [];
|
||||
|
||||
mediaId = scored[0].m.id;
|
||||
|
||||
} else {
|
||||
const match = await ext.getMetadata(searchTitle);
|
||||
const match = await ext.getMetadata(lookupId);
|
||||
mediaId = match.id;
|
||||
}
|
||||
|
||||
@@ -422,7 +458,10 @@ async function searchChaptersInExtension(ext: Extension, name: string, searchTit
|
||||
language: ch.language ?? null,
|
||||
}));
|
||||
|
||||
await setCache(cacheKey, result, CACHE_TTL_MS);
|
||||
await setCache(cacheKey, {
|
||||
mediaId,
|
||||
chapters: result
|
||||
}, CACHE_TTL_MS);
|
||||
return result;
|
||||
|
||||
} catch (e) {
|
||||
@@ -432,7 +471,7 @@ async function searchChaptersInExtension(ext: Extension, name: string, searchTit
|
||||
}
|
||||
}
|
||||
|
||||
export async function getChaptersForBook(id: string, ext: Boolean, onlyProvider?: string): Promise<{ chapters: ChapterWithProvider[] }> {
|
||||
export async function getChaptersForBook(id: string, ext: Boolean, onlyProvider?: string, extensionBookId?: string): Promise<{ chapters: ChapterWithProvider[] }> {
|
||||
let bookData: Book | null = null;
|
||||
let searchTitle: string = "";
|
||||
|
||||
@@ -462,11 +501,30 @@ export async function getChaptersForBook(id: string, ext: Boolean, onlyProvider?
|
||||
|
||||
for (const [name, ext] of bookExtensions) {
|
||||
if (onlyProvider && name !== onlyProvider) continue;
|
||||
if (name == extension) {
|
||||
const chapters = await searchChaptersInExtension(ext, name, id, false, exts);
|
||||
if (extensionBookId && name === onlyProvider) {
|
||||
const targetId = extensionBookId ?? id;
|
||||
|
||||
const chapters = await searchChaptersInExtension(
|
||||
ext,
|
||||
name,
|
||||
targetId, // lookup
|
||||
id, // cache siempre con el id normal
|
||||
false,
|
||||
exts,
|
||||
Boolean(extensionBookId)
|
||||
);
|
||||
|
||||
allChapters.push(...chapters);
|
||||
} else {
|
||||
const chapters = await searchChaptersInExtension(ext, name, searchTitle, true, exts);
|
||||
const chapters = await searchChaptersInExtension(
|
||||
ext,
|
||||
name,
|
||||
searchTitle,
|
||||
id, // cache con id normal
|
||||
true,
|
||||
exts
|
||||
);
|
||||
|
||||
allChapters.push(...chapters);
|
||||
}
|
||||
}
|
||||
@@ -548,4 +606,47 @@ export async function getChapterContent(bookId: string, chapterId: string, provi
|
||||
console.error(`[Chapter] Error loading from ${providerName}:`, error.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function similarity(s1: string, s2: string): number {
|
||||
const str1 = normalize(s1);
|
||||
const str2 = normalize(s2);
|
||||
|
||||
const longer = str1.length > str2.length ? str1 : str2;
|
||||
const shorter = str1.length > str2.length ? str2 : str1;
|
||||
|
||||
if (longer.length === 0) return 1.0;
|
||||
|
||||
const editDistance = levenshteinDistance(longer, shorter);
|
||||
return (longer.length - editDistance) / longer.length;
|
||||
}
|
||||
|
||||
function levenshteinDistance(s1: string, s2: string): number {
|
||||
const costs: number[] = [];
|
||||
for (let i = 0; i <= s1.length; i++) {
|
||||
let lastValue = i;
|
||||
for (let j = 0; j <= s2.length; j++) {
|
||||
if (i === 0) {
|
||||
costs[j] = j;
|
||||
} else if (j > 0) {
|
||||
let newValue = costs[j - 1];
|
||||
if (s1.charAt(i - 1) !== s2.charAt(j - 1)) {
|
||||
newValue = Math.min(Math.min(newValue, lastValue), costs[j]) + 1;
|
||||
}
|
||||
costs[j - 1] = lastValue;
|
||||
lastValue = newValue;
|
||||
}
|
||||
}
|
||||
if (i > 0) costs[s2.length] = lastValue;
|
||||
}
|
||||
return costs[s2.length];
|
||||
}
|
||||
|
||||
function normalize(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/'/g, "'") // decodificar entidades HTML
|
||||
.replace(/[^\w\s]/g, ' ') // convertir puntuación a espacios
|
||||
.replace(/\s+/g, ' ') // normalizar espacios
|
||||
.trim();
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { getConfig, setConfig } from '../../shared/config';
|
||||
|
||||
function hideSecrets(values: any) {
|
||||
const copy = structuredClone(values);
|
||||
if (copy.server?.jwt_secret) delete copy.server.jwt_secret;
|
||||
return copy;
|
||||
}
|
||||
|
||||
export async function getFullConfig(req: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { values, schema } = getConfig();
|
||||
return { values, schema };
|
||||
return { values: hideSecrets(values), schema };
|
||||
} catch {
|
||||
return { error: "Error loading config" };
|
||||
}
|
||||
@@ -22,7 +28,7 @@ export async function getConfigSection(
|
||||
return { error: "Section not found" };
|
||||
}
|
||||
|
||||
return { [section]: values[section] };
|
||||
return { [section]: hideSecrets(values)[section] };
|
||||
} catch {
|
||||
return { error: "Error loading config section" };
|
||||
}
|
||||
|
||||
@@ -9,8 +9,40 @@ import AdmZip from 'adm-zip';
|
||||
import { spawn } from 'child_process';
|
||||
const { values } = loadConfig();
|
||||
|
||||
const FFMPEG_PATH =
|
||||
values.paths?.ffmpeg || 'ffmpeg';
|
||||
const FFMPEG_PATH = values.paths?.ffmpeg || 'ffmpeg';
|
||||
|
||||
type DownloadStatus = {
|
||||
id: string;
|
||||
type: 'anime' | 'manga' | 'novel';
|
||||
anilistId: number;
|
||||
unitNumber: number;
|
||||
status: 'pending' | 'downloading' | 'completed' | 'failed';
|
||||
progress: number;
|
||||
speed?: string;
|
||||
timeElapsed?: string;
|
||||
error?: string;
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
folderName?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
const activeDownloads = new Map<string, DownloadStatus>();
|
||||
|
||||
export function getActiveDownloads(): DownloadStatus[] {
|
||||
return Array.from(activeDownloads.values());
|
||||
}
|
||||
|
||||
export function getDownloadById(id: string): DownloadStatus | undefined {
|
||||
return activeDownloads.get(id);
|
||||
}
|
||||
|
||||
function updateDownloadProgress(id: string, updates: Partial<DownloadStatus>) {
|
||||
const current = activeDownloads.get(id);
|
||||
if (current) {
|
||||
activeDownloads.set(id, { ...current, ...updates });
|
||||
}
|
||||
}
|
||||
|
||||
type AnimeDownloadParams = {
|
||||
anilistId: number;
|
||||
@@ -20,6 +52,7 @@ type AnimeDownloadParams = {
|
||||
quality?: string;
|
||||
subtitles?: Array<{ language: string; url: string }>;
|
||||
chapters?: Array<{ title: string; start_time: number; end_time: number }>;
|
||||
totalDuration?: number;
|
||||
};
|
||||
|
||||
type BookDownloadParams = {
|
||||
@@ -107,29 +140,48 @@ async function getOrCreateEntry(
|
||||
}
|
||||
|
||||
export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
const { anilistId, episodeNumber, streamUrl, subtitles, chapters } = params;
|
||||
const { anilistId, episodeNumber, streamUrl, subtitles, chapters, totalDuration } = params;
|
||||
|
||||
const entry = await getOrCreateEntry(anilistId, 'anime');
|
||||
const entry: any = await getOrCreateEntry(anilistId, 'anime');
|
||||
const fileName = `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`;
|
||||
|
||||
const downloadId = crypto.randomUUID();
|
||||
activeDownloads.set(downloadId, {
|
||||
id: downloadId,
|
||||
type: 'anime',
|
||||
anilistId,
|
||||
unitNumber: episodeNumber,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
startedAt: Date.now(),
|
||||
folderName: entry.folderName,
|
||||
fileName: fileName
|
||||
});
|
||||
|
||||
const exists = await queryOne(
|
||||
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
|
||||
[entry.id, episodeNumber],
|
||||
'local_library'
|
||||
);
|
||||
if (exists) return { status: 'ALREADY_EXISTS', entry_id: entry.id, episode: episodeNumber };
|
||||
|
||||
if (exists) {
|
||||
activeDownloads.delete(downloadId);
|
||||
return { status: 'ALREADY_EXISTS', entry_id: entry.id, episode: episodeNumber };
|
||||
}
|
||||
|
||||
const outputPath = path.join(entry.path, `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`);
|
||||
const tempDir = path.join(entry.path, '.temp');
|
||||
await ensureDirectory(tempDir);
|
||||
|
||||
try {
|
||||
updateDownloadProgress(downloadId, { status: 'downloading' });
|
||||
|
||||
let videoInput = streamUrl;
|
||||
let audioInputs: string[] = [];
|
||||
|
||||
const isMaster = (params as any).is_master === true;
|
||||
|
||||
if (isMaster) {
|
||||
|
||||
const variant = (params as any).variant;
|
||||
const audios = (params as any).audio;
|
||||
|
||||
@@ -178,13 +230,11 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
|
||||
if (chapters?.length) {
|
||||
const meta = path.join(tempDir, 'chapters.txt');
|
||||
|
||||
const sorted = [...chapters].sort((a, b) => a.start_time - b.start_time);
|
||||
const lines: string[] = [';FFMETADATA1'];
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const c = sorted[i];
|
||||
|
||||
const start = Math.floor(c.start_time * 1000);
|
||||
const end = Math.floor(c.end_time * 1000);
|
||||
const title = (c.title || 'chapter').toUpperCase();
|
||||
@@ -220,18 +270,14 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
|
||||
fs.writeFileSync(meta, lines.join('\n'));
|
||||
args.push('-i', meta);
|
||||
|
||||
// índice correcto del metadata input
|
||||
chaptersInputIndex = 1 + audioInputs.length + subFiles.length;
|
||||
}
|
||||
|
||||
args.push('-map', '0:v:0');
|
||||
|
||||
if (audioInputs.length > 0) {
|
||||
|
||||
audioInputs.forEach((_, i) => {
|
||||
args.push('-map', `${i + 1}:a:0`);
|
||||
|
||||
const audioInfo = (params as any).audio?.[i];
|
||||
if (audioInfo) {
|
||||
const audioStreamIndex = i;
|
||||
@@ -244,7 +290,6 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
||||
args.push('-map', '0:a:0?');
|
||||
}
|
||||
|
||||
@@ -258,68 +303,43 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
args.push('-map_metadata', `${chaptersInputIndex}`);
|
||||
}
|
||||
|
||||
args.push('-c:v', 'copy');
|
||||
|
||||
args.push('-c:a', 'copy');
|
||||
|
||||
if (subFiles.length) {
|
||||
args.push('-c:s', 'srt');
|
||||
|
||||
}
|
||||
|
||||
args.push('-y');
|
||||
|
||||
args.push(outputPath);
|
||||
args.push('-c:v', 'copy', '-c:a', 'copy');
|
||||
if (subFiles.length) args.push('-c:s', 'srt');
|
||||
args.push('-y', outputPath);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
console.log('🎬 Iniciando descarga con FFmpeg...');
|
||||
console.log('📹 Video:', videoInput);
|
||||
if (audioInputs.length > 0) {
|
||||
console.log('🔊 Audio tracks:', audioInputs.length);
|
||||
}
|
||||
console.log('💾 Output:', outputPath);
|
||||
console.log('Args:', args.join(' '));
|
||||
|
||||
const ff = spawn(FFMPEG_PATH, args, {
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let lastProgress = '';
|
||||
|
||||
ff.stdout.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
console.log('[stdout]', text);
|
||||
});
|
||||
|
||||
ff.stderr.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
const timeMatch = text.match(/time=(\S+)/);
|
||||
const speedMatch = text.match(/speed=(\S+)/);
|
||||
|
||||
if (text.includes('time=') || text.includes('speed=')) {
|
||||
const timeMatch = text.match(/time=(\S+)/);
|
||||
const speedMatch = text.match(/speed=(\S+)/);
|
||||
if (timeMatch || speedMatch) {
|
||||
lastProgress = `⏱️ Time: ${timeMatch?.[1] || 'N/A'} | Speed: ${speedMatch?.[1] || 'N/A'}`;
|
||||
console.log(lastProgress);
|
||||
if (timeMatch || speedMatch) {
|
||||
const updates: any = {};
|
||||
|
||||
if (timeMatch) updates.timeElapsed = timeMatch[1];
|
||||
if (speedMatch) updates.speed = speedMatch[1];
|
||||
|
||||
if (timeMatch && totalDuration && totalDuration > 0) {
|
||||
const elapsedSeconds = parseFFmpegTime(timeMatch[1]);
|
||||
updates.progress = Math.min(
|
||||
99,
|
||||
Math.round((elapsedSeconds / totalDuration) * 100)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log('[ffmpeg]', text);
|
||||
updateDownloadProgress(downloadId, updates);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
ff.on('error', (error) => {
|
||||
console.error('❌ Error al iniciar FFmpeg:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
ff.on('error', (error) => reject(error));
|
||||
ff.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
console.log('✅ Descarga completada exitosamente');
|
||||
resolve(true);
|
||||
} else {
|
||||
console.error(`❌ FFmpeg terminó con código: ${code}`);
|
||||
reject(new Error(`FFmpeg exited with code ${code}`));
|
||||
}
|
||||
if (code === 0) resolve(true);
|
||||
else reject(new Error(`FFmpeg exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -339,8 +359,17 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
'local_library'
|
||||
);
|
||||
|
||||
updateDownloadProgress(downloadId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
completedAt: Date.now()
|
||||
});
|
||||
|
||||
setTimeout(() => activeDownloads.delete(downloadId), 30000);
|
||||
|
||||
return {
|
||||
status: 'SUCCESS',
|
||||
download_id: downloadId,
|
||||
entry_id: entry.id,
|
||||
file_id: fileId,
|
||||
episode: episodeNumber,
|
||||
@@ -350,6 +379,14 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
} catch (e: any) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
||||
|
||||
updateDownloadProgress(downloadId, {
|
||||
status: 'failed',
|
||||
error: e.message
|
||||
});
|
||||
|
||||
setTimeout(() => activeDownloads.delete(downloadId), 60000);
|
||||
|
||||
const err = new Error('DOWNLOAD_FAILED');
|
||||
(err as any).details = e.message;
|
||||
throw err;
|
||||
@@ -362,6 +399,23 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
const type = format === 'manga' ? 'manga' : 'novels';
|
||||
const entry = await getOrCreateEntry(anilistId, type);
|
||||
|
||||
const ext = format === 'manga' ? 'cbz' : 'epub';
|
||||
const fileName = `Chapter_${chapterNumber.toString().padStart(3, '0')}.${ext}`;
|
||||
|
||||
const downloadId = crypto.randomUUID();
|
||||
|
||||
activeDownloads.set(downloadId, {
|
||||
id: downloadId,
|
||||
type: format === 'manga' ? 'manga' : 'novel',
|
||||
anilistId,
|
||||
unitNumber: chapterNumber,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
startedAt: Date.now(),
|
||||
folderName: entry.folderName,
|
||||
fileName: fileName
|
||||
});
|
||||
|
||||
const existingFile = await queryOne(
|
||||
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
|
||||
[entry.id, chapterNumber],
|
||||
@@ -369,6 +423,7 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
);
|
||||
|
||||
if (existingFile) {
|
||||
activeDownloads.delete(downloadId);
|
||||
return {
|
||||
status: 'ALREADY_EXISTS',
|
||||
message: `Chapter ${chapterNumber} already exists`,
|
||||
@@ -378,6 +433,8 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
}
|
||||
|
||||
try {
|
||||
updateDownloadProgress(downloadId, { status: 'downloading' });
|
||||
|
||||
let outputPath: string;
|
||||
let fileId: string;
|
||||
|
||||
@@ -388,7 +445,8 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
const zip = new AdmZip();
|
||||
const sortedImages = images!.sort((a, b) => a.index - b.index);
|
||||
|
||||
for (const img of sortedImages) {
|
||||
for (let i = 0; i < sortedImages.length; i++) {
|
||||
const img = sortedImages[i];
|
||||
const res = await fetch(img.url);
|
||||
if (!res.ok) throw new Error(`HTTP_${res.status}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
@@ -396,6 +454,10 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
const ext = path.extname(new URL(img.url).pathname) || '.jpg';
|
||||
const filename = `${img.index.toString().padStart(4, '0')}${ext}`;
|
||||
zip.addFile(filename, buf);
|
||||
|
||||
updateDownloadProgress(downloadId, {
|
||||
progress: Math.floor((i / sortedImages.length) * 100)
|
||||
});
|
||||
}
|
||||
|
||||
zip.writeZip(outputPath);
|
||||
@@ -405,7 +467,6 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
outputPath = path.join(entry.path, chapterName);
|
||||
|
||||
const zip = new AdmZip();
|
||||
|
||||
zip.addFile('mimetype', Buffer.from('application/epub+zip'), '', 0);
|
||||
|
||||
const containerXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -443,7 +504,6 @@ ${content}
|
||||
</body>
|
||||
</html>`;
|
||||
zip.addFile('OEBPS/chapter.xhtml', Buffer.from(chapterXhtml));
|
||||
|
||||
zip.writeZip(outputPath);
|
||||
}
|
||||
|
||||
@@ -461,8 +521,17 @@ ${content}
|
||||
'local_library'
|
||||
);
|
||||
|
||||
updateDownloadProgress(downloadId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
completedAt: Date.now()
|
||||
});
|
||||
|
||||
setTimeout(() => activeDownloads.delete(downloadId), 30000);
|
||||
|
||||
return {
|
||||
status: 'SUCCESS',
|
||||
download_id: downloadId,
|
||||
entry_id: entry.id,
|
||||
file_id: fileId,
|
||||
chapter: chapterNumber,
|
||||
@@ -471,8 +540,26 @@ ${content}
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
updateDownloadProgress(downloadId, {
|
||||
status: 'failed',
|
||||
error: error.message
|
||||
});
|
||||
|
||||
setTimeout(() => activeDownloads.delete(downloadId), 60000);
|
||||
|
||||
const err = new Error('DOWNLOAD_FAILED');
|
||||
(err as any).details = error.message;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function parseFFmpegTime(timeStr: string): number {
|
||||
const parts = timeStr.split(':');
|
||||
if (parts.length < 3) return 0;
|
||||
|
||||
const h = parseFloat(parts[0]) || 0;
|
||||
const m = parseFloat(parts[1]) || 0;
|
||||
const s = parseFloat(parts[2]) || 0;
|
||||
|
||||
return (h * 3600) + (m * 60) + s;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {FastifyReply, FastifyRequest} from 'fastify';
|
||||
import fs from 'fs';
|
||||
import * as service from './local.service';
|
||||
import * as downloadService from './download.service';
|
||||
import * as streamingService from './streaming.service';
|
||||
|
||||
type ScanQuery = {
|
||||
mode?: 'full' | 'incremental';
|
||||
@@ -21,12 +22,13 @@ type DownloadAnimeBody =
|
||||
| {
|
||||
anilist_id: number;
|
||||
episode_number: number;
|
||||
stream_url: string; // media playlist FINAL
|
||||
stream_url: string;
|
||||
is_master?: false;
|
||||
subtitles?: {
|
||||
language: string;
|
||||
url: string;
|
||||
}[];
|
||||
duration?: number;
|
||||
chapters?: {
|
||||
title: string;
|
||||
start_time: number;
|
||||
@@ -36,28 +38,25 @@ type DownloadAnimeBody =
|
||||
| {
|
||||
anilist_id: number;
|
||||
episode_number: number;
|
||||
stream_url: string; // master.m3u8
|
||||
stream_url: string;
|
||||
duration?: number;
|
||||
is_master: true;
|
||||
|
||||
variant: {
|
||||
resolution: string;
|
||||
bandwidth?: number;
|
||||
codecs?: string;
|
||||
playlist_url: string;
|
||||
};
|
||||
|
||||
audio?: {
|
||||
group?: string;
|
||||
language?: string;
|
||||
name?: string;
|
||||
playlist_url: string;
|
||||
}[];
|
||||
|
||||
subtitles?: {
|
||||
language: string;
|
||||
url: string;
|
||||
}[];
|
||||
|
||||
chapters?: {
|
||||
title: string;
|
||||
start_time: number;
|
||||
@@ -91,8 +90,7 @@ export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQue
|
||||
export async function listEntries(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { type } = request.params;
|
||||
const entries = await service.getEntriesByType(type);
|
||||
return entries;
|
||||
return await service.getEntriesByType(type);
|
||||
} catch {
|
||||
return reply.status(500).send({ error: 'FAILED_TO_LIST_ENTRIES' });
|
||||
}
|
||||
@@ -260,6 +258,7 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
||||
anilist_id,
|
||||
episode_number,
|
||||
stream_url,
|
||||
duration,
|
||||
is_master,
|
||||
subtitles,
|
||||
chapters
|
||||
@@ -267,7 +266,6 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
||||
|
||||
const clientHeaders = (request.body as any).headers || {};
|
||||
|
||||
// Validación básica
|
||||
if (!anilist_id || !episode_number || !stream_url) {
|
||||
return reply.status(400).send({
|
||||
error: 'MISSING_REQUIRED_FIELDS',
|
||||
@@ -275,26 +273,23 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
||||
});
|
||||
}
|
||||
|
||||
// Proxy del stream URL principal
|
||||
const proxyUrl = buildProxyUrl(stream_url, clientHeaders);
|
||||
console.log('Stream URL:', proxyUrl);
|
||||
|
||||
// Proxy de subtítulos
|
||||
const proxiedSubs = subtitles?.map(sub => ({
|
||||
...sub,
|
||||
url: buildProxyUrl(sub.url, clientHeaders)
|
||||
}));
|
||||
|
||||
// Preparar parámetros base
|
||||
const downloadParams: any = {
|
||||
anilistId: anilist_id,
|
||||
episodeNumber: episode_number,
|
||||
streamUrl: proxyUrl,
|
||||
subtitles: proxiedSubs,
|
||||
chapters
|
||||
chapters,
|
||||
totalDuration: duration
|
||||
};
|
||||
|
||||
// Si es master playlist, agregar campos adicionales
|
||||
if (is_master === true) {
|
||||
const { variant, audio } = request.body as any;
|
||||
|
||||
@@ -306,14 +301,11 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
||||
}
|
||||
|
||||
downloadParams.is_master = true;
|
||||
|
||||
// Proxy del variant playlist
|
||||
downloadParams.variant = {
|
||||
...variant,
|
||||
playlist_url: buildProxyUrl(variant.playlist_url, clientHeaders)
|
||||
};
|
||||
|
||||
// Proxy de audio tracks si existen
|
||||
if (audio && audio.length > 0) {
|
||||
downloadParams.audio = audio.map((a: any) => ({
|
||||
...a,
|
||||
@@ -409,4 +401,85 @@ export async function downloadBook(request: FastifyRequest<{ Body: DownloadBookB
|
||||
|
||||
return reply.status(500).send({ error: 'FAILED_TO_DOWNLOAD_BOOK' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDownloadStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const downloads = downloadService.getActiveDownloads();
|
||||
const streams = streamingService.getActiveStreamsStatus();
|
||||
|
||||
return {
|
||||
downloads: {
|
||||
total: downloads.length,
|
||||
active: downloads.filter(d => d.status === 'downloading').length,
|
||||
completed: downloads.filter(d => d.status === 'completed').length,
|
||||
failed: downloads.filter(d => d.status === 'failed').length,
|
||||
list: downloads
|
||||
},
|
||||
streams: {
|
||||
total: streams.length,
|
||||
active: streams.filter(s => !s.isComplete).length,
|
||||
completed: streams.filter(s => s.isComplete).length,
|
||||
list: streams
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error getting download status:', err);
|
||||
return reply.status(500).send({ error: 'FAILED_TO_GET_DOWNLOAD_STATUS' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAnimeStreamManifest(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { type, id, unit } = request.params as any;
|
||||
|
||||
if (type !== 'anime') {
|
||||
return reply.status(400).send({ error: 'ONLY_ANIME_SUPPORTED' });
|
||||
}
|
||||
|
||||
const fileInfo = await service.getFileForStreaming(id, unit);
|
||||
|
||||
if (!fileInfo) {
|
||||
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
|
||||
}
|
||||
|
||||
const manifest = await streamingService.getStreamingManifest(fileInfo.filePath);
|
||||
|
||||
if (!manifest) {
|
||||
return reply.status(500).send({ error: 'FAILED_TO_GENERATE_MANIFEST' });
|
||||
}
|
||||
|
||||
return manifest;
|
||||
} catch (err: any) {
|
||||
console.error('Error getting stream manifest:', err);
|
||||
return reply.status(500).send({ error: 'FAILED_TO_GET_STREAM_MANIFEST' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function serveHLSFile(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { hash, filename } = request.params as any;
|
||||
|
||||
const file = await streamingService.getHLSFile(hash, filename);
|
||||
|
||||
if (!file) {
|
||||
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
|
||||
}
|
||||
|
||||
const contentType = filename.endsWith('.m3u8')
|
||||
? 'application/vnd.apple.mpegurl'
|
||||
: filename.endsWith('.vtt')
|
||||
? 'text/vtt'
|
||||
: 'video/mp2t';
|
||||
|
||||
reply
|
||||
.header('Content-Type', contentType)
|
||||
.header('Content-Length', file.stat.size)
|
||||
.header('Access-Control-Allow-Origin', '*');
|
||||
|
||||
return fs.createReadStream(file.path);
|
||||
} catch (err) {
|
||||
console.error('Error serving HLS file:', err);
|
||||
return reply.status(500).send({ error: 'FAILED_TO_SERVE_HLS_FILE' });
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ async function localRoutes(fastify: FastifyInstance) {
|
||||
fastify.post('/library/scan', controller.scanLibrary);
|
||||
fastify.get('/library/:type', controller.listEntries);
|
||||
fastify.get('/library/:type/:id', controller.getEntry);
|
||||
|
||||
// Streaming básico (legacy)
|
||||
fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit);
|
||||
fastify.post('/library/:type/:id/match', controller.matchEntry);
|
||||
fastify.get('/library/:id/units', controller.getUnits);
|
||||
@@ -12,6 +14,9 @@ async function localRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/library/:unitId/resource/:resId', controller.getPage);
|
||||
fastify.post('/library/download/anime', controller.downloadAnime);
|
||||
fastify.post('/library/download/book', controller.downloadBook);
|
||||
fastify.get('/library/downloads/status', controller.getDownloadStatus);
|
||||
fastify.get('/library/stream/:type/:id/:unit/manifest', controller.getAnimeStreamManifest);
|
||||
fastify.get('/library/hls/:hash/:filename', controller.serveHLSFile);
|
||||
}
|
||||
|
||||
export default localRoutes;
|
||||
@@ -7,6 +7,7 @@ import path from "path";
|
||||
import { getAnimeById, searchAnimeLocal } from "../anime/anime.service";
|
||||
import { getBookById, searchBooksAniList } from "../books/books.service";
|
||||
import AdmZip from 'adm-zip';
|
||||
import { getStreamHash, getSubtitleFileStream } from './streaming.service';
|
||||
|
||||
const MANGA_IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp'];
|
||||
const MANGA_ARCHIVES = ['.cbz', '.cbr', '.zip'];
|
||||
@@ -186,8 +187,22 @@ export async function performLibraryScan(mode: 'full' | 'incremental' = 'increme
|
||||
}
|
||||
|
||||
export async function getEntriesByType(type: string) {
|
||||
const entries = await queryAll(`SELECT * FROM local_entries WHERE type = ?`, [type], 'local_library');
|
||||
return await Promise.all(entries.map((entry: any) => resolveEntryMetadata(entry, type)));
|
||||
const sql = `
|
||||
SELECT local_entries.*, COUNT(local_files.id) as file_count
|
||||
FROM local_entries
|
||||
LEFT JOIN local_files ON local_entries.id = local_files.entry_id
|
||||
WHERE local_entries.type = ?
|
||||
GROUP BY local_entries.id
|
||||
`;
|
||||
const entries = await queryAll(sql, [type], 'local_library');
|
||||
return await Promise.all(entries.map(async (entry: any) => {
|
||||
const metadata = await resolveEntryMetadata(entry, type);
|
||||
return {
|
||||
...metadata,
|
||||
path: entry.path,
|
||||
files: entry.file_count
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getEntryDetails(type: string, id: string) {
|
||||
|
||||
622
desktop/src/api/local/streaming.service.ts
Normal file
622
desktop/src/api/local/streaming.service.ts
Normal file
@@ -0,0 +1,622 @@
|
||||
import { getConfig as loadConfig } from '../../shared/config';
|
||||
import { queryOne } from '../../shared/database';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const { values } = loadConfig();
|
||||
const FFMPEG_PATH = values.paths?.ffmpeg || 'ffmpeg';
|
||||
const FFPROBE_PATH = values.paths?.ffprobe || 'ffprobe';
|
||||
|
||||
const STREAM_TTL = 2 * 60 * 60 * 1000;
|
||||
|
||||
type VideoStreamInfo = {
|
||||
index: number;
|
||||
codec: string;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
};
|
||||
|
||||
type AudioStreamInfo = {
|
||||
index: number;
|
||||
codec: string;
|
||||
language?: string;
|
||||
title?: string;
|
||||
channels: number;
|
||||
sampleRate: number;
|
||||
};
|
||||
|
||||
type SubtitleStreamInfo = {
|
||||
index: number;
|
||||
codec: string;
|
||||
language?: string;
|
||||
title?: string;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
type ChapterInfo = {
|
||||
id: number;
|
||||
start: number;
|
||||
end: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type MediaInfo = {
|
||||
video: VideoStreamInfo[];
|
||||
audio: AudioStreamInfo[];
|
||||
subtitles: SubtitleStreamInfo[];
|
||||
chapters: ChapterInfo[];
|
||||
duration: number;
|
||||
};
|
||||
|
||||
type ActiveStream = {
|
||||
hash: string;
|
||||
filePath: string;
|
||||
hlsDir: string;
|
||||
info: MediaInfo;
|
||||
process?: any;
|
||||
startedAt: number;
|
||||
lastAccessed: number;
|
||||
isComplete: boolean;
|
||||
};
|
||||
|
||||
const activeStreams = new Map<string, ActiveStream>();
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [hash, stream] of activeStreams.entries()) {
|
||||
const age = now - stream.lastAccessed;
|
||||
|
||||
if (age > STREAM_TTL) {
|
||||
console.log(`🗑️ Limpiando stream antiguo: ${hash}`);
|
||||
|
||||
if (stream.process && !stream.process.killed) {
|
||||
stream.process.kill('SIGKILL');
|
||||
}
|
||||
|
||||
if (fs.existsSync(stream.hlsDir)) {
|
||||
fs.rmSync(stream.hlsDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
activeStreams.delete(hash);
|
||||
}
|
||||
}
|
||||
}, 60 * 1000);
|
||||
|
||||
export function getStreamHash(filePath: string): string {
|
||||
const stat = fs.statSync(filePath);
|
||||
return crypto
|
||||
.createHash('md5')
|
||||
.update(`${filePath}-${stat.mtime.getTime()}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function ensureHLSDirectory(hash: string): string {
|
||||
const tempDir = path.join(require('os').tmpdir(), 'hls-streams', hash);
|
||||
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
function buildStreamMap(info: MediaInfo): string {
|
||||
const maps: string[] = [];
|
||||
|
||||
maps.push(`v:0,a:0`);
|
||||
|
||||
return maps.join(' ');
|
||||
}
|
||||
|
||||
function writeMasterPlaylist(info: MediaInfo, hlsDir: string) {
|
||||
const lines: string[] = ['#EXTM3U'];
|
||||
|
||||
info.audio.forEach((a, i) => {
|
||||
lines.push(
|
||||
`#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="${a.title || `Audio ${i+1}`}",LANGUAGE="${a.language || 'und'}",AUTOSELECT=YES,DEFAULT=${i===0?'YES':'NO'},URI="audio_${i}.m3u8"`
|
||||
);
|
||||
});
|
||||
|
||||
info.subtitles.forEach((s, i) => {
|
||||
lines.push(
|
||||
`#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="${s.title || `Sub ${i+1}`}",LANGUAGE="${s.language || 'und'}",AUTOSELECT=YES,DEFAULT=NO,URI="subs_${i}.m3u8"`
|
||||
);
|
||||
});
|
||||
|
||||
const v = info.video[0];
|
||||
const bandwidth = v.width * v.height * v.fps * 0.07 | 0;
|
||||
|
||||
lines.push(
|
||||
`#EXT-X-STREAM-INF:BANDWIDTH=${bandwidth},RESOLUTION=${v.width}x${v.height},AUDIO="audio"${info.subtitles.length ? ',SUBTITLES="subs"' : ''}`
|
||||
);
|
||||
lines.push('video.m3u8');
|
||||
|
||||
fs.writeFileSync(path.join(hlsDir, 'master.m3u8'), lines.join('\n'));
|
||||
}
|
||||
|
||||
function startHLSConversion(filePath: string, info: MediaInfo, hash: string): ActiveStream {
|
||||
const hlsDir = ensureHLSDirectory(hash);
|
||||
|
||||
const stream: ActiveStream = {
|
||||
hash,
|
||||
filePath,
|
||||
hlsDir,
|
||||
info,
|
||||
process: null,
|
||||
startedAt: Date.now(),
|
||||
lastAccessed: Date.now(),
|
||||
isComplete: false
|
||||
};
|
||||
|
||||
activeStreams.set(hash, stream);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
|
||||
await extractSubtitles(filePath, info, hlsDir);
|
||||
|
||||
writeMasterPlaylist(info, hlsDir);
|
||||
|
||||
startVideoTranscoding(stream, filePath, info, hlsDir);
|
||||
|
||||
} catch (err) {
|
||||
console.error('❌ Error en el flujo de inicio:', err);
|
||||
}
|
||||
})();
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
function startVideoTranscoding(stream: ActiveStream, filePath: string, info: MediaInfo, hlsDir: string) {
|
||||
const args: string[] = ['-i', filePath];
|
||||
|
||||
args.push(
|
||||
'-map', '0:v:0',
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'veryfast',
|
||||
'-profile:v', 'main',
|
||||
'-g', '48',
|
||||
'-keyint_min', '48',
|
||||
'-sc_threshold', '0',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-f', 'hls',
|
||||
'-hls_time', '4',
|
||||
'-hls_playlist_type', 'event',
|
||||
|
||||
'-hls_flags', 'independent_segments',
|
||||
'-hls_segment_filename', path.join(hlsDir, 'v_%05d.ts'),
|
||||
path.join(hlsDir, 'video.m3u8')
|
||||
);
|
||||
|
||||
info.audio.forEach((a, i) => {
|
||||
args.push(
|
||||
'-map', `0:${a.index}`,
|
||||
`-c:a:${i}`, 'aac',
|
||||
`-b:a:${i}`, '128k',
|
||||
'-f', 'hls',
|
||||
'-hls_time', '4',
|
||||
'-hls_playlist_type', 'event',
|
||||
'-hls_flags', 'independent_segments',
|
||||
'-hls_segment_filename', path.join(hlsDir, `a${i}_%05d.ts`),
|
||||
path.join(hlsDir, `audio_${i}.m3u8`)
|
||||
);
|
||||
});
|
||||
|
||||
console.log('🎬 Starting Video/Audio transcoding:', args.join(' '));
|
||||
|
||||
const ffmpeg = spawn(FFMPEG_PATH, args, {
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
stream.process = ffmpeg;
|
||||
|
||||
ffmpeg.stderr.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
|
||||
if (text.includes('time=')) {
|
||||
const timeMatch = text.match(/time=(\S+)/);
|
||||
if (timeMatch) console.log(`⏱️ Converting ${stream.hash.substr(0,6)}: ${timeMatch[1]}`);
|
||||
}
|
||||
});
|
||||
|
||||
ffmpeg.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
console.log('✅ Video transcoding complete');
|
||||
stream.isComplete = true;
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const SUBTITLE_EXTENSIONS: Record<string, string> = {
|
||||
'ass': 'ass',
|
||||
'ssa': 'ass',
|
||||
'subrip': 'srt',
|
||||
'webvtt': 'vtt',
|
||||
'hdmv_pgs_subtitle': 'sup',
|
||||
'mov_text': 'srt',
|
||||
'dvd_subtitle': 'sub',
|
||||
'text': 'srt'
|
||||
};
|
||||
|
||||
async function extractSubtitles(filePath: string, info: MediaInfo, hlsDir: string): Promise<void> {
|
||||
if (info.subtitles.length === 0) return;
|
||||
|
||||
console.log('📝 Extrayendo subtítulos...');
|
||||
|
||||
const promises = info.subtitles.map((s, i) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const codec = s.codec.toLowerCase();
|
||||
const ext = codec === 'subrip' ? 'srt'
|
||||
: codec === 'ass' || codec === 'ssa' ? 'ass'
|
||||
: codec === 'webvtt' ? 'vtt'
|
||||
: 'sub';
|
||||
|
||||
const outputFilename = `s${i}_full.${ext}`;
|
||||
const outputPath = path.join(hlsDir, outputFilename);
|
||||
const playlistPath = path.join(hlsDir, `subs_${i}.m3u8`);
|
||||
|
||||
if (s.duration === 0) {
|
||||
console.log(`⚠️ Sub vacío, skip: ${i}`);
|
||||
createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration);
|
||||
fs.writeFileSync(outputPath, '');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` Track ${i}: ${s.title || s.language || 'Unknown'} [${codec}] -> ${ext}`);
|
||||
|
||||
const args = [
|
||||
'-i', filePath,
|
||||
'-map', `0:s:${i}`
|
||||
];
|
||||
|
||||
args.push('-c:s', 'copy');
|
||||
args.push('-y', outputPath);
|
||||
|
||||
console.log(` Comando: ffmpeg ${args.join(' ')}`);
|
||||
|
||||
const p = spawn(FFMPEG_PATH, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let errorOutput = '';
|
||||
|
||||
p.stderr.on('data', (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
p.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
try {
|
||||
|
||||
if (!fs.existsSync(outputPath)) {
|
||||
console.error(` ❌ Archivo no creado: ${outputFilename}`);
|
||||
|
||||
fs.writeFileSync(outputPath, '');
|
||||
} else {
|
||||
const stat = fs.statSync(outputPath);
|
||||
if (stat.size === 0) {
|
||||
console.error(` ⚠️ Subtítulo ${i} tiene 0 bytes`);
|
||||
console.error(` FFmpeg stderr:`, errorOutput.slice(-500));
|
||||
} else {
|
||||
console.log(` ✅ Subtítulo ${i} extraído: ${stat.size} bytes`);
|
||||
}
|
||||
}
|
||||
|
||||
createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration);
|
||||
resolve();
|
||||
} catch (e) {
|
||||
console.error(` Error procesando subtítulo ${i}:`, e);
|
||||
resolve();
|
||||
}
|
||||
} else {
|
||||
console.error(` ❌ Error extrayendo subtítulo ${i}. Exit code: ${code}`);
|
||||
console.error(` FFmpeg stderr:`, errorOutput.slice(-500));
|
||||
|
||||
fs.writeFileSync(outputPath, '');
|
||||
createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
p.on('error', (err) => {
|
||||
console.error(` Error spawn ffmpeg subs ${i}:`, err);
|
||||
fs.writeFileSync(outputPath, '');
|
||||
createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
console.log('✅ Proceso de subtítulos finalizado.');
|
||||
}
|
||||
|
||||
function createDummySubtitlePlaylist(playlistPath: string, subtitleFilename: string, duration: number) {
|
||||
const content = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:3',
|
||||
`#EXT-X-TARGETDURATION:${Math.ceil(duration)}`,
|
||||
'#EXT-X-MEDIA-SEQUENCE:0',
|
||||
'#EXT-X-PLAYLIST-TYPE:VOD',
|
||||
`#EXTINF:${duration.toFixed(6)},`,
|
||||
subtitleFilename,
|
||||
'#EXT-X-ENDLIST'
|
||||
].join('\n');
|
||||
|
||||
fs.writeFileSync(playlistPath, content);
|
||||
}
|
||||
|
||||
async function probeMediaFile(filePath: string): Promise<MediaInfo> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
'-v', 'quiet',
|
||||
'-print_format', 'json',
|
||||
'-show_streams',
|
||||
'-show_chapters',
|
||||
'-show_format',
|
||||
filePath
|
||||
];
|
||||
|
||||
const ffprobe = spawn(FFPROBE_PATH, args);
|
||||
let output = '';
|
||||
|
||||
ffprobe.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
ffprobe.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
return reject(new Error(`FFprobe failed with code ${code}`));
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(output);
|
||||
|
||||
const video: VideoStreamInfo[] = data.streams
|
||||
.filter((s: any) => s.codec_type === 'video')
|
||||
.map((s: any) => ({
|
||||
index: s.index,
|
||||
codec: s.codec_name,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
fps: eval(s.r_frame_rate) || 24
|
||||
}));
|
||||
|
||||
const audio: AudioStreamInfo[] = data.streams
|
||||
.filter((s: any) => s.codec_type === 'audio')
|
||||
.map((s: any) => ({
|
||||
index: s.index,
|
||||
codec: s.codec_name,
|
||||
language: s.tags?.language,
|
||||
title: s.tags?.title,
|
||||
channels: s.channels || 2,
|
||||
sampleRate: parseInt(s.sample_rate) || 48000
|
||||
}));
|
||||
|
||||
const subtitles: SubtitleStreamInfo[] = data.streams
|
||||
.filter((s: any) => s.codec_type === 'subtitle')
|
||||
.map((s: any) => ({
|
||||
index: s.index,
|
||||
codec: s.codec_name,
|
||||
language: s.tags?.language,
|
||||
title: s.tags?.title,
|
||||
duration: parseFloat(s.duration) || 0
|
||||
}));
|
||||
|
||||
const chapters: ChapterInfo[] = (data.chapters || []).map((c: any) => ({
|
||||
id: c.id,
|
||||
start: parseFloat(c.start_time),
|
||||
end: parseFloat(c.end_time),
|
||||
title: c.tags?.title || `Chapter ${c.id + 1}`
|
||||
}));
|
||||
|
||||
const duration = parseFloat(data.format?.duration) || 0;
|
||||
|
||||
console.log(`📊 Media info: ${video.length} video, ${audio.length} audio, ${subtitles.length} subs`);
|
||||
|
||||
resolve({ video, audio, subtitles, chapters, duration });
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
ffprobe.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getStreamingManifest(filePath: string) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hash = getStreamHash(filePath);
|
||||
|
||||
const formatSubtitles = (subs: SubtitleStreamInfo[]) => {
|
||||
return subs.map((s, i) => {
|
||||
const ext = SUBTITLE_EXTENSIONS[s.codec] || 'vtt';
|
||||
return {
|
||||
index: s.index,
|
||||
codec: s.codec,
|
||||
language: s.language || 'und',
|
||||
title: s.title || `Subtitle ${s.index}`,
|
||||
|
||||
url: `/api/library/hls/${hash}/s${i}_full.${ext}`
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const existing = activeStreams.get(hash);
|
||||
if (existing) {
|
||||
existing.lastAccessed = Date.now();
|
||||
|
||||
return {
|
||||
type: 'hls',
|
||||
hash,
|
||||
masterPlaylist: `/api/library/hls/${hash}/master.m3u8`,
|
||||
duration: existing.info.duration,
|
||||
isComplete: existing.isComplete,
|
||||
video: existing.info.video.map(v => ({
|
||||
index: v.index,
|
||||
codec: v.codec,
|
||||
resolution: `${v.width}x${v.height}`,
|
||||
fps: v.fps
|
||||
})),
|
||||
audio: existing.info.audio.map(a => ({
|
||||
index: a.index,
|
||||
codec: a.codec,
|
||||
language: a.language || 'und',
|
||||
title: a.title || `Audio ${a.index}`,
|
||||
channels: a.channels
|
||||
})),
|
||||
|
||||
subtitles: formatSubtitles(existing.info.subtitles),
|
||||
chapters: existing.info.chapters.map(c => ({
|
||||
id: c.id,
|
||||
start: c.start,
|
||||
end: c.end,
|
||||
title: c.title
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
const duplicateCheck = activeStreams.get(hash);
|
||||
|
||||
if (duplicateCheck) {
|
||||
duplicateCheck.lastAccessed = Date.now();
|
||||
return {
|
||||
type: 'hls',
|
||||
hash,
|
||||
masterPlaylist: `/api/library/hls/${hash}/master.m3u8`,
|
||||
duration: duplicateCheck.info.duration,
|
||||
isComplete: duplicateCheck.isComplete,
|
||||
video: duplicateCheck.info.video.map(v => ({
|
||||
index: v.index,
|
||||
codec: v.codec,
|
||||
resolution: `${v.width}x${v.height}`,
|
||||
fps: v.fps
|
||||
})),
|
||||
audio: duplicateCheck.info.audio.map(a => ({
|
||||
index: a.index,
|
||||
codec: a.codec,
|
||||
language: a.language || 'und',
|
||||
title: a.title || `Audio ${a.index}`,
|
||||
channels: a.channels
|
||||
})),
|
||||
subtitles: formatSubtitles(duplicateCheck.info.subtitles),
|
||||
chapters: duplicateCheck.info.chapters.map(c => ({
|
||||
id: c.id,
|
||||
start: c.start,
|
||||
end: c.end,
|
||||
title: c.title
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
const info = await probeMediaFile(filePath);
|
||||
return {
|
||||
type: 'hls',
|
||||
hash,
|
||||
masterPlaylist: `/api/library/hls/${hash}/master.m3u8`,
|
||||
duration: info.duration,
|
||||
isComplete: false,
|
||||
generating: true,
|
||||
video: info.video.map(v => ({
|
||||
index: v.index,
|
||||
codec: v.codec,
|
||||
resolution: `${v.width}x${v.height}`,
|
||||
fps: v.fps
|
||||
})),
|
||||
audio: info.audio.map(a => ({
|
||||
index: a.index,
|
||||
codec: a.codec,
|
||||
language: a.language || 'und',
|
||||
title: a.title || `Audio ${a.index}`,
|
||||
channels: a.channels
|
||||
})),
|
||||
subtitles: formatSubtitles(info.subtitles),
|
||||
chapters: info.chapters.map(c => ({
|
||||
id: c.id,
|
||||
start: c.start,
|
||||
end: c.end,
|
||||
title: c.title
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export function getSubtitleFileStream(hash: string, trackIndex: number) {
|
||||
const stream = activeStreams.get(hash);
|
||||
|
||||
if (!stream) {
|
||||
|
||||
const tempDir = path.join(require('os').tmpdir(), 'hls-streams', hash);
|
||||
if(fs.existsSync(tempDir)) {
|
||||
|
||||
const files = fs.readdirSync(tempDir);
|
||||
const subFile = files.find(f => f.startsWith(`s${trackIndex}_full.`));
|
||||
if (subFile) return fs.createReadStream(path.join(tempDir, subFile));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(stream.hlsDir)) return null;
|
||||
|
||||
const files = fs.readdirSync(stream.hlsDir);
|
||||
const subtitleFile = files.find(f => f.startsWith(`s${trackIndex}_full.`));
|
||||
|
||||
if (!subtitleFile) return null;
|
||||
|
||||
return fs.createReadStream(path.join(stream.hlsDir, subtitleFile));
|
||||
}
|
||||
|
||||
export async function getHLSFile(hash: string, filename: string) {
|
||||
const stream = activeStreams.get(hash);
|
||||
|
||||
if (!stream) {
|
||||
return null;
|
||||
}
|
||||
|
||||
stream.lastAccessed = Date.now();
|
||||
|
||||
const filePath = path.join(stream.hlsDir, filename);
|
||||
|
||||
const maxWait = 30000;
|
||||
|
||||
const startWait = Date.now();
|
||||
|
||||
while (!fs.existsSync(filePath)) {
|
||||
if (Date.now() - startWait > maxWait) {
|
||||
console.error(`⏱️ Timeout esperando archivo: ${filename}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stream.isComplete && !fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
stat: fs.statSync(filePath)
|
||||
};
|
||||
}
|
||||
|
||||
export function getActiveStreamsStatus() {
|
||||
return Array.from(activeStreams.values()).map(stream => ({
|
||||
hash: stream.hash,
|
||||
filePath: stream.filePath,
|
||||
isComplete: stream.isComplete,
|
||||
startedAt: stream.startedAt,
|
||||
lastAccessed: stream.lastAccessed,
|
||||
age: Date.now() - stream.startedAt,
|
||||
idle: Date.now() - stream.lastAccessed
|
||||
}));
|
||||
}
|
||||
158
desktop/src/api/rooms/rooms.controller.ts
Normal file
158
desktop/src/api/rooms/rooms.controller.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import * as roomService from './rooms.service';
|
||||
import { getUserById } from '../user/user.service';
|
||||
import { openTunnel } from "./tunnel.manager";
|
||||
|
||||
interface CreateRoomBody {
|
||||
name: string;
|
||||
password?: string;
|
||||
expose?: boolean;
|
||||
}
|
||||
|
||||
export async function createRoom(req: any, reply: FastifyReply) {
|
||||
try {
|
||||
const { name, password, expose } = req.body as CreateRoomBody;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: "Authentication required to create room" });
|
||||
}
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
return reply.code(400).send({ error: "Room name is required" });
|
||||
}
|
||||
|
||||
const user = await getUserById(userId);
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: "User not found" });
|
||||
}
|
||||
|
||||
const host = {
|
||||
id: `user_${userId}`,
|
||||
username: user.username,
|
||||
avatar: user.profile_picture_url || undefined,
|
||||
isHost: true,
|
||||
isGuest: false,
|
||||
userId
|
||||
};
|
||||
|
||||
let publicUrl: string | undefined;
|
||||
|
||||
if (expose) {
|
||||
publicUrl = await openTunnel();
|
||||
}
|
||||
|
||||
const room = roomService.createRoom(
|
||||
name,
|
||||
host,
|
||||
password,
|
||||
!!expose,
|
||||
publicUrl
|
||||
);
|
||||
|
||||
if (expose && publicUrl) {
|
||||
room.publicUrl = `${publicUrl}/room?id=${room.id}`;
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
room: {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
hasPassword: !!room.password,
|
||||
userCount: room.users.size,
|
||||
exposed: room.exposed,
|
||||
publicUrl: room.publicUrl
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Create Room Error:", err);
|
||||
return reply.code(500).send({ error: "Failed to create room" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRooms(req: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const rooms = roomService.getAllRooms();
|
||||
|
||||
const roomList = rooms.map((room) => ({
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
host: room.host.username,
|
||||
userCount: room.users.size,
|
||||
hasPassword: !!room.password,
|
||||
currentlyWatching: room.currentVideo ? {
|
||||
animeId: room.currentVideo.animeId,
|
||||
episode: room.currentVideo.episode
|
||||
} : null
|
||||
}));
|
||||
|
||||
return reply.send({ rooms: roomList });
|
||||
} catch (err) {
|
||||
console.error("Get Rooms Error:", err);
|
||||
return reply.code(500).send({ error: "Failed to retrieve rooms" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRoom(req: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { id } = req.params as { id: string };
|
||||
const room = roomService.getRoom(id);
|
||||
|
||||
if (!room) {
|
||||
return reply.code(404).send({ error: "Room not found" });
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
room: {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
host: {
|
||||
username: room.host.username,
|
||||
avatar: room.host.avatar
|
||||
},
|
||||
users: Array.from(room.users.values()).map(u => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
avatar: u.avatar,
|
||||
isHost: u.isHost,
|
||||
isGuest: u.isGuest
|
||||
})),
|
||||
hasPassword: !!room.password,
|
||||
currentVideo: room.currentVideo,
|
||||
exposed: room.exposed,
|
||||
publicUrl: room.publicUrl
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Get Room Error:", err);
|
||||
return reply.code(500).send({ error: "Failed to retrieve room" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRoom(req: any, reply: FastifyReply) {
|
||||
try {
|
||||
const { id } = req.params as { id: string };
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
const room = roomService.getRoom(id);
|
||||
if (!room) {
|
||||
return reply.code(404).send({ error: "Room not found" });
|
||||
}
|
||||
|
||||
if (room.host.userId !== userId) {
|
||||
return reply.code(403).send({ error: "Only the host can delete the room" });
|
||||
}
|
||||
|
||||
roomService.deleteRoom(id);
|
||||
|
||||
return reply.send({ success: true });
|
||||
} catch (err) {
|
||||
console.error("Delete Room Error:", err);
|
||||
return reply.code(500).send({ error: "Failed to delete room" });
|
||||
}
|
||||
}
|
||||
11
desktop/src/api/rooms/rooms.routes.ts
Normal file
11
desktop/src/api/rooms/rooms.routes.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import * as controller from './rooms.controller';
|
||||
|
||||
async function roomRoutes(fastify: FastifyInstance) {
|
||||
fastify.post('/rooms', controller.createRoom);
|
||||
fastify.get('/rooms', controller.getRooms);
|
||||
fastify.get('/rooms/:id', controller.getRoom);
|
||||
fastify.delete('/rooms/:id', controller.deleteRoom);
|
||||
}
|
||||
|
||||
export default roomRoutes;
|
||||
296
desktop/src/api/rooms/rooms.service.ts
Normal file
296
desktop/src/api/rooms/rooms.service.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import crypto from 'crypto';
|
||||
import { closeTunnelIfUnused } from "./tunnel.manager";
|
||||
|
||||
interface RoomPermissions {
|
||||
canControl: boolean;
|
||||
canManageQueue: boolean;
|
||||
}
|
||||
|
||||
interface RoomUser {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar?: string;
|
||||
isHost: boolean;
|
||||
isGuest: boolean;
|
||||
userId?: number;
|
||||
permissions?: RoomPermissions;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
interface SourceContext {
|
||||
animeId: string;
|
||||
episode: string | number;
|
||||
source: string;
|
||||
extension: string;
|
||||
server: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
uid: string;
|
||||
metadata: RoomMetadata;
|
||||
videoData: any;
|
||||
addedBy: string;
|
||||
}
|
||||
|
||||
interface RoomMetadata {
|
||||
id: string;
|
||||
title: string;
|
||||
episode: number;
|
||||
image?: string;
|
||||
source?: string;
|
||||
malId?: number;
|
||||
}
|
||||
|
||||
interface RoomData {
|
||||
id: string;
|
||||
name: string;
|
||||
host: RoomUser;
|
||||
users: Map<string, RoomUser>;
|
||||
createdAt: number;
|
||||
currentVideo: {
|
||||
animeId?: number;
|
||||
episode?: number;
|
||||
source?: string;
|
||||
videoData?: any;
|
||||
currentTime: number;
|
||||
isPlaying: boolean;
|
||||
context?: SourceContext;
|
||||
} | null;
|
||||
password?: string;
|
||||
metadata?: RoomMetadata | null;
|
||||
exposed: boolean;
|
||||
publicUrl?: string;
|
||||
queue: QueueItem[];
|
||||
bannedIPs: Set<string>;
|
||||
}
|
||||
|
||||
export const DEFAULT_GUEST_PERMISSIONS: RoomPermissions = {
|
||||
canControl: false,
|
||||
canManageQueue: false
|
||||
};
|
||||
|
||||
const rooms = new Map<string, RoomData>();
|
||||
|
||||
export function generateRoomId(): string {
|
||||
return crypto.randomBytes(8).toString('hex');
|
||||
}
|
||||
|
||||
export function createRoom(name: string, host: RoomUser, password?: string, exposed = false, publicUrl?: string): RoomData {
|
||||
const roomId = generateRoomId();
|
||||
|
||||
const room: RoomData = {
|
||||
id: roomId,
|
||||
name,
|
||||
host,
|
||||
users: new Map([[host.id, host]]),
|
||||
createdAt: Date.now(),
|
||||
currentVideo: null,
|
||||
password: password || undefined,
|
||||
metadata: null,
|
||||
exposed,
|
||||
publicUrl,
|
||||
queue: [],
|
||||
bannedIPs: new Set() // NUEVO
|
||||
};
|
||||
|
||||
rooms.set(roomId, room);
|
||||
return room;
|
||||
}
|
||||
|
||||
export function updateUserPermissions(roomId: string, userId: string, permissions: Partial<RoomPermissions>): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
const user: any = room.users.get(userId);
|
||||
if (!user) return false;
|
||||
|
||||
if (user.isHost) return false;
|
||||
|
||||
user.permissions = {
|
||||
...user.permissions,
|
||||
...permissions
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function banUserIP(roomId: string, ipAddress: string): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
room.bannedIPs.add(ipAddress);
|
||||
|
||||
// Remover a todos los usuarios con esa IP
|
||||
Array.from(room.users.values()).forEach(user => {
|
||||
if (user.ipAddress === ipAddress) {
|
||||
removeUserFromRoom(roomId, user.id);
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function unbanUserIP(roomId: string, ipAddress: string): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
return room.bannedIPs.delete(ipAddress);
|
||||
}
|
||||
|
||||
export function isIPBanned(roomId: string, ipAddress: string): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
return room.bannedIPs.has(ipAddress);
|
||||
}
|
||||
|
||||
export function getBannedIPs(roomId: string): string[] {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return [];
|
||||
return Array.from(room.bannedIPs);
|
||||
}
|
||||
|
||||
export function hasPermission(roomId: string, userId: string, permission: keyof RoomPermissions): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
const user = room.users.get(userId);
|
||||
if (!user) return false;
|
||||
|
||||
// El host siempre tiene todos los permisos
|
||||
if (user.isHost) return true;
|
||||
|
||||
// Si no tiene permisos definidos, usar defaults
|
||||
const userPerms = user.permissions || DEFAULT_GUEST_PERMISSIONS;
|
||||
return userPerms[permission] || false;
|
||||
}
|
||||
|
||||
export function getRoom(roomId: string): RoomData | null {
|
||||
return rooms.get(roomId) || null;
|
||||
}
|
||||
|
||||
export function getAllRooms(): RoomData[] {
|
||||
return Array.from(rooms.values()).map(room => ({
|
||||
...room,
|
||||
users: room.users
|
||||
}));
|
||||
}
|
||||
|
||||
export function addQueueItem(roomId: string, item: QueueItem): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
room.queue.push(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeQueueItem(roomId: string, itemUid: string): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
room.queue = room.queue.filter(i => i.uid !== itemUid);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getNextQueueItem(roomId: string): QueueItem | undefined {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room || room.queue.length === 0) return undefined;
|
||||
return room.queue.shift(); // Saca el primero y lo retorna
|
||||
}
|
||||
|
||||
export function getAndRemoveQueueItem(roomId: string, itemUid: string): QueueItem | undefined {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return undefined;
|
||||
|
||||
const index = room.queue.findIndex(i => i.uid === itemUid);
|
||||
if (index === -1) return undefined;
|
||||
|
||||
const [item] = room.queue.splice(index, 1);
|
||||
return item;
|
||||
}
|
||||
|
||||
export function moveQueueItem(roomId: string, itemUid: string, direction: 'up' | 'down'): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
const index = room.queue.findIndex(i => i.uid === itemUid);
|
||||
if (index === -1) return false;
|
||||
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
|
||||
if (newIndex < 0 || newIndex >= room.queue.length) return false;
|
||||
|
||||
const temp = room.queue[newIndex];
|
||||
room.queue[newIndex] = room.queue[index];
|
||||
room.queue[index] = temp;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function addUserToRoom(roomId: string, user: RoomUser): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
room.users.set(user.id, user);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeUserFromRoom(roomId: string, userId: string): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
room.users.delete(userId);
|
||||
|
||||
if (room.users.size === 0) {
|
||||
if (room.exposed) {
|
||||
closeTunnelIfUnused();
|
||||
}
|
||||
rooms.delete(roomId);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (room.host.id === userId && room.users.size > 0) {
|
||||
const newHost = Array.from(room.users.values())[0];
|
||||
newHost.isHost = true;
|
||||
room.host = newHost;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function updateRoomVideo(roomId: string, videoData: any): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
room.currentVideo = {
|
||||
...room.currentVideo,
|
||||
...videoData
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
export function deleteRoom(roomId: string): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
if (room.exposed) {
|
||||
closeTunnelIfUnused();
|
||||
}
|
||||
|
||||
return rooms.delete(roomId);
|
||||
}
|
||||
|
||||
export function verifyRoomPassword(roomId: string, password?: string): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
if (!room.password) return true;
|
||||
if (!password) return false;
|
||||
|
||||
return room.password === password;
|
||||
}
|
||||
|
||||
export function updateRoomMetadata(roomId: string, metadata: any): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
room.metadata = metadata;
|
||||
return true;
|
||||
}
|
||||
588
desktop/src/api/rooms/rooms.websocket.ts
Normal file
588
desktop/src/api/rooms/rooms.websocket.ts
Normal file
@@ -0,0 +1,588 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import * as roomService from './rooms.service';
|
||||
import { getUserById } from '../user/user.service';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import {getConfig} from '../../shared/config';
|
||||
const { values } = getConfig();
|
||||
const jwtSecret = values.server?.jwt_secret;
|
||||
|
||||
interface WSClient {
|
||||
socket: any;
|
||||
userId: string;
|
||||
username: string;
|
||||
roomId: string;
|
||||
isGuest: boolean;
|
||||
}
|
||||
|
||||
const clients = new Map<string, WSClient>();
|
||||
|
||||
|
||||
function getClientIP(req: any): string {
|
||||
return req.headers['x-forwarded-for']?.split(',')[0].trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection?.remoteAddress ||
|
||||
req.socket?.remoteAddress ||
|
||||
'unknown';
|
||||
}
|
||||
|
||||
export function setupRoomWebSocket(fastify: FastifyInstance) {
|
||||
// @ts-ignore
|
||||
fastify.get('/ws/room/:roomId', { websocket: true }, (connection: any, req: any) => {
|
||||
handleWebSocketConnection(connection, req).catch(err => {
|
||||
console.error('WebSocket error:', err);
|
||||
try {
|
||||
connection.socket.close();
|
||||
} catch (e) {
|
||||
// Socket already closed
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleWebSocketConnection(connection: any, req: any) {
|
||||
const socket = connection.socket || connection;
|
||||
const roomId = req.params.roomId;
|
||||
const token = req.query.token;
|
||||
const guestName = req.query.guestName;
|
||||
const password = req.query.password;
|
||||
const clientIP = getClientIP(req);
|
||||
|
||||
let userId: string;
|
||||
let username: string;
|
||||
let avatar: string | undefined;
|
||||
let isGuest = false;
|
||||
let realUserId: any;
|
||||
|
||||
const room = roomService.getRoom(roomId);
|
||||
|
||||
// 1. Validaciones básicas de existencia y Ban
|
||||
if (!room) {
|
||||
socket.send(JSON.stringify({ type: 'error', message: 'Room not found' }));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (roomService.isIPBanned(roomId, clientIP)) {
|
||||
socket.send(JSON.stringify({ type: 'error', message: 'You have been banned from this room' }));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. MOVIDO ARRIBA: Autenticar usuario PRIMERO para saber quién es
|
||||
if (token) {
|
||||
try {
|
||||
const decoded: any = jwt.verify(token, jwtSecret);
|
||||
realUserId = decoded.id;
|
||||
const user = await getUserById(realUserId);
|
||||
|
||||
if (user) {
|
||||
userId = `user_${realUserId}`;
|
||||
username = user.username;
|
||||
avatar = user.profile_picture_url || undefined;
|
||||
isGuest = false;
|
||||
} else {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
} catch (err) {
|
||||
socket.send(JSON.stringify({ type: 'error', message: 'Invalid token' }));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
} else if (guestName && guestName.trim()) {
|
||||
// ... (Lógica de Guest se mantiene igual) ...
|
||||
const nameToCheck = guestName.trim();
|
||||
const isNameTaken = Array.from(room.users.values()).some(
|
||||
u => u.username.toLowerCase() === nameToCheck.toLowerCase()
|
||||
);
|
||||
if (isNameTaken) {
|
||||
socket.send(JSON.stringify({ type: 'error', message: 'Username is already taken' }));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
userId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
username = nameToCheck;
|
||||
isGuest = true;
|
||||
} else {
|
||||
socket.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Determinar si es Host
|
||||
const isHost = room.host.userId === realUserId || room.host.id === userId;
|
||||
|
||||
// 4. MOVIDO ABAJO: Validar contraseña SOLO SI NO ES HOST
|
||||
if (room.password && !isHost) {
|
||||
if (!password || !roomService.verifyRoomPassword(roomId, password)) {
|
||||
socket.send(JSON.stringify({ type: 'error', message: 'Invalid password' }));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const userInRoom = {
|
||||
id: userId,
|
||||
username,
|
||||
avatar,
|
||||
isHost: isHost,
|
||||
isGuest,
|
||||
userId: realUserId,
|
||||
ipAddress: clientIP, // NUEVO
|
||||
permissions: isHost ? undefined : { ...roomService.DEFAULT_GUEST_PERMISSIONS }
|
||||
};
|
||||
|
||||
roomService.addUserToRoom(roomId, userInRoom);
|
||||
|
||||
// Registrar cliente
|
||||
clients.set(userId, {
|
||||
socket: socket,
|
||||
userId,
|
||||
username,
|
||||
roomId,
|
||||
isGuest
|
||||
});
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
type: 'init',
|
||||
userId,
|
||||
username,
|
||||
isGuest,
|
||||
isHost, // NUEVO: Enviar explícitamente
|
||||
room: {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
users: Array.from(room.users.values()).map(u => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
avatar: u.avatar,
|
||||
isHost: u.isHost,
|
||||
isGuest: u.isGuest,
|
||||
permissions: u.permissions // NUEVO
|
||||
})),
|
||||
currentVideo: room.currentVideo,
|
||||
queue: room.queue || []
|
||||
}
|
||||
}));
|
||||
|
||||
// Notificar a otros usuarios
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'user_joined',
|
||||
user: {
|
||||
id: userId,
|
||||
username,
|
||||
avatar,
|
||||
isGuest
|
||||
}
|
||||
}, userId);
|
||||
|
||||
// Manejar mensajes
|
||||
socket.on('message', (message: Buffer) => {
|
||||
try {
|
||||
const data = JSON.parse(message.toString());
|
||||
handleMessage(roomId, userId, data);
|
||||
} catch (err) {
|
||||
console.error('WebSocket message error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// Manejar desconexión
|
||||
socket.on('close', () => {
|
||||
clients.delete(userId);
|
||||
roomService.removeUserFromRoom(roomId, userId);
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'user_left',
|
||||
user: { userId, username }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleMessage(roomId: string, userId: string, data: any) {
|
||||
const room = roomService.getRoom(roomId);
|
||||
if (!room) return;
|
||||
|
||||
const user = room.users.get(userId);
|
||||
if (!user) return;
|
||||
|
||||
console.log('Handling message:', data.type, 'from user:', userId, 'isHost:', room.host.id === userId);
|
||||
|
||||
switch (data.type) {
|
||||
case 'chat':
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'chat',
|
||||
userId,
|
||||
username: room.users.get(userId)?.username || 'Unknown',
|
||||
avatar: room.users.get(userId)?.avatar,
|
||||
message: data.message,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
break;
|
||||
|
||||
case 'update_permissions':
|
||||
if (!user.isHost) {
|
||||
console.warn('Non-host attempted to update permissions');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = roomService.updateUserPermissions(
|
||||
roomId,
|
||||
data.targetUserId,
|
||||
data.permissions
|
||||
);
|
||||
|
||||
if (success) {
|
||||
const updatedRoom = roomService.getRoom(roomId);
|
||||
if (updatedRoom) {
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'permissions_updated',
|
||||
users: Array.from(updatedRoom.users.values()).map(u => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
avatar: u.avatar,
|
||||
isHost: u.isHost,
|
||||
isGuest: u.isGuest,
|
||||
permissions: u.permissions
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
// NUEVO: Baneo de usuarios (solo host)
|
||||
case 'ban_user':
|
||||
if (!user.isHost) {
|
||||
console.warn('Non-host attempted to ban user');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUser = room.users.get(data.targetUserId);
|
||||
if (targetUser && targetUser.ipAddress) {
|
||||
roomService.banUserIP(roomId, targetUser.ipAddress);
|
||||
|
||||
// Cerrar conexión del usuario baneado
|
||||
const targetClient = clients.get(data.targetUserId);
|
||||
if (targetClient && targetClient.socket) {
|
||||
targetClient.socket.send(JSON.stringify({
|
||||
type: 'banned',
|
||||
message: 'You have been banned from this room'
|
||||
}));
|
||||
targetClient.socket.close();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
case 'queue_play_item':
|
||||
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
|
||||
console.warn('User lacks permission for queue management');
|
||||
return;
|
||||
}
|
||||
|
||||
const itemToPlay = roomService.getAndRemoveQueueItem(roomId, data.itemUid);
|
||||
if (itemToPlay) {
|
||||
const videoPayload = {
|
||||
videoData: itemToPlay.videoData.videoData,
|
||||
subtitles: itemToPlay.videoData.subtitles,
|
||||
currentTime: 0,
|
||||
isPlaying: true
|
||||
};
|
||||
|
||||
roomService.updateRoomVideo(roomId, videoPayload);
|
||||
roomService.updateRoomMetadata(roomId, itemToPlay.metadata);
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'video_update',
|
||||
video: videoPayload,
|
||||
metadata: itemToPlay.metadata
|
||||
});
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'queue_update',
|
||||
queue: room.queue
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'queue_move':
|
||||
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const moved = roomService.moveQueueItem(roomId, data.itemUid, data.direction);
|
||||
if (moved) {
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'queue_update',
|
||||
queue: room.queue
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'request_sync':
|
||||
// Cualquier usuario puede pedir sync
|
||||
const host = clients.get(room.host.id);
|
||||
if (host && host.socket && host.socket.readyState === 1) {
|
||||
console.log(`[Sync Request] User ${userId} requested sync from host`);
|
||||
|
||||
host.socket.send(JSON.stringify({
|
||||
type: 'sync_requested',
|
||||
requestedBy: userId,
|
||||
username: room.users.get(userId)?.username
|
||||
}));
|
||||
} else {
|
||||
console.warn(`[Sync Request] Host not available for user ${userId}`);
|
||||
|
||||
if (room.currentVideo) {
|
||||
const client = clients.get(userId);
|
||||
if (client && client.socket && client.socket.readyState === 1) {
|
||||
console.log(`[Sync Request] Sending cached video state to ${userId}`);
|
||||
client.socket.send(JSON.stringify({
|
||||
type: 'sync',
|
||||
currentTime: room.currentVideo.currentTime || 0,
|
||||
isPlaying: room.currentVideo.isPlaying || false
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'video_update':
|
||||
const canUpdateVideo = user.isHost ||
|
||||
roomService.hasPermission(roomId, userId, 'canControl') ||
|
||||
roomService.hasPermission(roomId, userId, 'canManageQueue');
|
||||
|
||||
if (!canUpdateVideo) {
|
||||
console.warn('User lacks permissions to update video');
|
||||
return;
|
||||
}
|
||||
|
||||
roomService.updateRoomVideo(roomId, data.video);
|
||||
roomService.updateRoomMetadata(roomId, data.metadata);
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'video_update',
|
||||
video: data.video,
|
||||
metadata: data.metadata
|
||||
});
|
||||
break;
|
||||
|
||||
case 'queue_add_batch':
|
||||
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(data.items)) {
|
||||
data.items.forEach((item: any, i: number) => {
|
||||
const newItem = {
|
||||
uid: `q_${Date.now()}_${i}_${Math.random().toString(36).substr(2, 5)}`,
|
||||
metadata: item.metadata,
|
||||
videoData: item.video,
|
||||
addedBy: room.users.get(userId)?.username || 'Unknown'
|
||||
};
|
||||
roomService.addQueueItem(roomId, newItem);
|
||||
});
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'queue_update',
|
||||
queue: room.queue
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'sync':
|
||||
if (room.host.id !== userId) return;
|
||||
|
||||
if (room.currentVideo) {
|
||||
room.currentVideo.currentTime = data.currentTime;
|
||||
room.currentVideo.isPlaying = data.isPlaying;
|
||||
}
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'sync',
|
||||
currentTime: data.currentTime,
|
||||
isPlaying: data.isPlaying
|
||||
}, userId);
|
||||
break;
|
||||
|
||||
case 'request_users':
|
||||
const currentRoom = roomService.getRoom(roomId);
|
||||
if (currentRoom) {
|
||||
const client = clients.get(userId);
|
||||
if (client && client.socket && client.socket.readyState === 1) {
|
||||
client.socket.send(JSON.stringify({
|
||||
type: 'users_update', // Nuevo tipo de respuesta
|
||||
users: Array.from(currentRoom.users.values()).map(u => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
avatar: u.avatar,
|
||||
isHost: u.isHost,
|
||||
isGuest: u.isGuest
|
||||
}))
|
||||
}));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'play':
|
||||
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canControl')) {
|
||||
console.warn('User lacks control permissions');
|
||||
return;
|
||||
}
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'play',
|
||||
currentTime: data.currentTime,
|
||||
timestamp: Date.now()
|
||||
}, userId);
|
||||
break;
|
||||
|
||||
case 'pause':
|
||||
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canControl')) {
|
||||
console.warn('User lacks control permissions for pause');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Broadcasting pause event to room:', roomId);
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'pause',
|
||||
currentTime: data.currentTime,
|
||||
timestamp: Date.now()
|
||||
}, userId);
|
||||
break;
|
||||
|
||||
case 'seek':
|
||||
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canControl')) {
|
||||
console.warn('User lacks control permissions for seek');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Broadcasting seek event to room:', roomId);
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'seek',
|
||||
currentTime: data.currentTime,
|
||||
timestamp: Date.now()
|
||||
}, userId);
|
||||
break;
|
||||
|
||||
case 'request_state':
|
||||
const client = clients.get(userId);
|
||||
if (client && client.socket && client.socket.readyState === 1) {
|
||||
const updatedRoom = roomService.getRoom(roomId);
|
||||
if (updatedRoom) {
|
||||
client.socket.send(JSON.stringify({
|
||||
type: 'init',
|
||||
userId,
|
||||
username: client.username,
|
||||
isGuest: client.isGuest,
|
||||
room: {
|
||||
id: updatedRoom.id,
|
||||
name: updatedRoom.name,
|
||||
users: Array.from(updatedRoom.users.values()).map(u => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
avatar: u.avatar,
|
||||
isHost: u.isHost,
|
||||
isGuest: u.isGuest
|
||||
})),
|
||||
currentVideo: room.currentVideo,
|
||||
metadata: room.metadata
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'queue_add':
|
||||
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newItem = {
|
||||
uid: `q_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
|
||||
metadata: data.metadata,
|
||||
videoData: data.video,
|
||||
addedBy: room.users.get(userId)?.username || 'Unknown'
|
||||
};
|
||||
|
||||
roomService.addQueueItem(roomId, newItem);
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'queue_update',
|
||||
queue: room.queue
|
||||
});
|
||||
break;
|
||||
|
||||
case 'queue_remove':
|
||||
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
|
||||
return;
|
||||
}
|
||||
|
||||
roomService.removeQueueItem(roomId, data.itemUid);
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'queue_update',
|
||||
queue: room.queue
|
||||
});
|
||||
break;
|
||||
|
||||
case 'play_next':
|
||||
if (room.host.id !== userId) return;
|
||||
|
||||
const nextItem = roomService.getNextQueueItem(roomId);
|
||||
if (nextItem) {
|
||||
const videoPayload = {
|
||||
videoData: nextItem.videoData.videoData,
|
||||
subtitles: nextItem.videoData.subtitles,
|
||||
currentTime: 0,
|
||||
isPlaying: true
|
||||
};
|
||||
|
||||
roomService.updateRoomVideo(roomId, videoPayload);
|
||||
roomService.updateRoomMetadata(roomId, nextItem.metadata);
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'video_update',
|
||||
video: videoPayload,
|
||||
metadata: nextItem.metadata
|
||||
});
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'queue_update',
|
||||
queue: room.queue
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Unknown message type:', data.type);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastToRoom(roomId: string, message: any, excludeUserId?: string) {
|
||||
const room = roomService.getRoom(roomId);
|
||||
if (!room) return;
|
||||
|
||||
const messageStr = JSON.stringify(message);
|
||||
|
||||
console.log('Broadcasting to room:', roomId, 'message type:', message.type, 'excluding:', excludeUserId);
|
||||
|
||||
let sentCount = 0;
|
||||
room.users.forEach((user) => {
|
||||
if (user.id !== excludeUserId) {
|
||||
const client = clients.get(user.id);
|
||||
if (client && client.socket && client.socket.readyState === 1) {
|
||||
try {
|
||||
client.socket.send(messageStr);
|
||||
sentCount++;
|
||||
console.log('Sent to user:', user.id, user.username);
|
||||
} catch (err) {
|
||||
console.error('Error sending message to user:', user.id, err);
|
||||
}
|
||||
} else {
|
||||
console.warn('User socket not ready:', user.id, 'readyState:', client?.socket?.readyState);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Broadcast complete: sent to ${sentCount} users`);
|
||||
}
|
||||
111
desktop/src/api/rooms/tunnel.manager.ts
Normal file
111
desktop/src/api/rooms/tunnel.manager.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
import { getConfig as loadConfig } from '../../shared/config';
|
||||
const { values } = loadConfig();
|
||||
const CLOUDFLARED_PATH = values.paths?.cloudflared || 'cloudflared';
|
||||
|
||||
let tunnelProcess: ChildProcess | null = null;
|
||||
let exposedRooms = 0;
|
||||
let publicUrl: string | null = null;
|
||||
let tunnelPromise: Promise<string> | null = null;
|
||||
|
||||
export function openTunnel(): Promise<string> {
|
||||
if (tunnelProcess && publicUrl) {
|
||||
exposedRooms++;
|
||||
return Promise.resolve(publicUrl);
|
||||
}
|
||||
|
||||
if (tunnelPromise) {
|
||||
return tunnelPromise;
|
||||
}
|
||||
|
||||
tunnelPromise = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Timeout esperando URL del túnel (30s)"));
|
||||
}, 30000);
|
||||
|
||||
tunnelProcess = spawn(CLOUDFLARED_PATH, [
|
||||
"tunnel",
|
||||
"--url",
|
||||
"http://localhost:54322",
|
||||
"--no-autoupdate"
|
||||
]);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
tunnelPromise = null;
|
||||
};
|
||||
|
||||
let outputBuffer = "";
|
||||
|
||||
const processOutput = (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
outputBuffer += text;
|
||||
|
||||
const match = outputBuffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
||||
if (match && !publicUrl) {
|
||||
publicUrl = match[0];
|
||||
exposedRooms = 1;
|
||||
cleanup();
|
||||
resolve(publicUrl);
|
||||
}
|
||||
};
|
||||
|
||||
tunnelProcess.stdout?.on("data", (data) => {
|
||||
processOutput(data);
|
||||
});
|
||||
|
||||
tunnelProcess.stderr?.on("data", (data) => {
|
||||
processOutput(data);
|
||||
});
|
||||
|
||||
tunnelProcess.on("error", (error) => {
|
||||
console.error("[Cloudflared Process Error]", error);
|
||||
cleanup();
|
||||
tunnelProcess = null;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
tunnelProcess.on("exit", (code, signal) => {
|
||||
tunnelProcess = null;
|
||||
publicUrl = null;
|
||||
exposedRooms = 0;
|
||||
|
||||
if (!publicUrl) {
|
||||
cleanup();
|
||||
reject(new Error(`Proceso cloudflared terminó antes de obtener URL (código: ${code})`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return tunnelPromise;
|
||||
}
|
||||
|
||||
export function closeTunnelIfUnused() {
|
||||
exposedRooms--;
|
||||
console.log(`[Tunnel Manager] Rooms expuestas: ${exposedRooms}`);
|
||||
|
||||
if (exposedRooms <= 0 && tunnelProcess) {
|
||||
console.log("[Tunnel Manager] Cerrando túnel...");
|
||||
tunnelProcess.kill();
|
||||
tunnelProcess = null;
|
||||
publicUrl = null;
|
||||
exposedRooms = 0;
|
||||
tunnelPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTunnelUrl(): string | null {
|
||||
return publicUrl;
|
||||
}
|
||||
|
||||
export function forceTunnelClose() {
|
||||
if (tunnelProcess) {
|
||||
console.log("[Tunnel Manager] Forzando cierre del túnel...");
|
||||
tunnelProcess.kill();
|
||||
tunnelProcess = null;
|
||||
publicUrl = null;
|
||||
exposedRooms = 0;
|
||||
tunnelPromise = null;
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ export interface ExtensionSearchOptions {
|
||||
}
|
||||
|
||||
export interface ExtensionSearchResult {
|
||||
url: string;
|
||||
format: string;
|
||||
headers: any;
|
||||
id: string;
|
||||
@@ -98,7 +99,7 @@ export interface Extension {
|
||||
mediaType?: 'manga' | 'ln';
|
||||
search?: (options: ExtensionSearchOptions) => Promise<ExtensionSearchResult[]>;
|
||||
findEpisodes?: (id: string) => Promise<Episode[]>;
|
||||
findEpisodeServer?: (episode: Episode, server: string) => Promise<any>;
|
||||
findEpisodeServer?: (s: any, server1: string | undefined, category: string | undefined) => Promise<any>;
|
||||
findChapters?: (id: string) => Promise<Chapter[]>;
|
||||
findChapterPages?: (chapterId: string) => Promise<any>;
|
||||
getSettings?: () => ExtensionSettings;
|
||||
@@ -158,6 +159,7 @@ export interface WatchStreamQuery {
|
||||
server?: string;
|
||||
category?: string;
|
||||
ext: string;
|
||||
extensionAnimeId?: string;
|
||||
}
|
||||
|
||||
export interface BookParams {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import * as userService from './user.service';
|
||||
import {queryOne} from '../../shared/database';
|
||||
import jwt from "jsonwebtoken";
|
||||
import { getConfig } from '../../shared/config';
|
||||
const { values } = getConfig();
|
||||
const jwtSecret = values.server?.jwt_secret;
|
||||
|
||||
|
||||
interface UserIdParams { id: string; }
|
||||
interface CreateUserBody {
|
||||
@@ -75,7 +79,7 @@ export async function login(req: FastifyRequest, reply: FastifyReply) {
|
||||
|
||||
const token = jwt.sign(
|
||||
{ id: userId },
|
||||
process.env.JWT_SECRET!,
|
||||
jwtSecret,
|
||||
{ expiresIn: "7d" }
|
||||
);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
152
desktop/src/scripts/anime/subtitle-renderer.js
Normal file
152
desktop/src/scripts/anime/subtitle-renderer.js
Normal file
@@ -0,0 +1,152 @@
|
||||
const BASE_PATH = '/src/scripts/jassub/';
|
||||
class SubtitleRenderer {
|
||||
constructor(video, canvas) {
|
||||
this.video = video;
|
||||
this.canvas = canvas;
|
||||
this.instance = null;
|
||||
this.currentUrl = null;
|
||||
}
|
||||
async init(subtitleUrl) {
|
||||
if (!this.video || !this.canvas) return;
|
||||
this.dispose();
|
||||
const finalUrl = subtitleUrl.includes('/api/proxy')
|
||||
? subtitleUrl
|
||||
: `/api/proxy?url=${encodeURIComponent(subtitleUrl)}`;
|
||||
this.currentUrl = finalUrl;
|
||||
try {
|
||||
this.instance = new JASSUB({
|
||||
video: this.video,
|
||||
canvas: this.canvas,
|
||||
subUrl: finalUrl,
|
||||
workerUrl: `${BASE_PATH}jassub-worker.js`,
|
||||
wasmUrl: `${BASE_PATH}jassub-worker.wasm`,
|
||||
modernWasmUrl: `${BASE_PATH}jassub-worker-modern.wasm`,
|
||||
blendMode: 'js',
|
||||
asyncRender: true,
|
||||
onDemand: true,
|
||||
targetFps: 60,
|
||||
debug: false
|
||||
});
|
||||
console.log('JASSUB initialized for:', finalUrl);
|
||||
} catch (e) {
|
||||
console.error("JASSUB Init Error:", e);
|
||||
}
|
||||
}
|
||||
resize() {
|
||||
if (this.instance && this.instance.resize) {
|
||||
this.instance.resize();
|
||||
}
|
||||
}
|
||||
setTrack(url) {
|
||||
const finalUrl = url.includes('/api/proxy')
|
||||
? url
|
||||
: `/api/proxy?url=${encodeURIComponent(url)}`;
|
||||
if (this.instance) {
|
||||
this.instance.setTrackByUrl(finalUrl);
|
||||
this.currentUrl = finalUrl;
|
||||
} else {
|
||||
this.init(url);
|
||||
}
|
||||
}
|
||||
dispose() {
|
||||
if (this.instance) {
|
||||
try {
|
||||
this.instance.destroy();
|
||||
} catch (e) {
|
||||
console.warn("Error destroying JASSUB:", e);
|
||||
}
|
||||
this.instance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
class SimpleSubtitleRenderer {
|
||||
constructor(video, canvas) {
|
||||
this.video = video;
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
this.cues = [];
|
||||
this.destroyed = false;
|
||||
this.setupCanvas();
|
||||
this.video.addEventListener('timeupdate', () => this.render());
|
||||
}
|
||||
setupCanvas() {
|
||||
const updateSize = () => {
|
||||
if (!this.video || !this.canvas) return;
|
||||
const rect = this.video.getBoundingClientRect();
|
||||
this.canvas.width = rect.width;
|
||||
this.canvas.height = rect.height;
|
||||
};
|
||||
updateSize();
|
||||
window.addEventListener('resize', updateSize);
|
||||
this.resizeHandler = updateSize;
|
||||
}
|
||||
async loadSubtitles(url) {
|
||||
try {
|
||||
let finalUrl = url;
|
||||
const isLocal = url.startsWith('/');
|
||||
const isAlreadyProxied = url.includes('/api/proxy');
|
||||
if (!isLocal && !isAlreadyProxied && (url.startsWith('http:') || url.startsWith('https:'))) {
|
||||
finalUrl = `/api/proxy?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
console.log('Fetching subtitles from:', finalUrl);
|
||||
const response = await fetch(finalUrl);
|
||||
if (!response.ok) throw new Error(`Status: ${response.status}`);
|
||||
const text = await response.text();
|
||||
this.cues = this.parseSRT(text);
|
||||
} catch (error) {
|
||||
console.error('Failed to load subtitles:', error);
|
||||
}
|
||||
}
|
||||
setTrack(url) {
|
||||
this.cues = [];
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.loadSubtitles(url);
|
||||
}
|
||||
parseSRT(srtText) {
|
||||
const normalizedText = srtText.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const blocks = normalizedText.trim().split('\n\n');
|
||||
return blocks.map(block => {
|
||||
const lines = block.split('\n');
|
||||
if (lines.length < 3) return null;
|
||||
const timeMatch = lines[1].match(/(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})/);
|
||||
if (!timeMatch) return null;
|
||||
const start = parseInt(timeMatch[1]) * 3600 + parseInt(timeMatch[2]) * 60 + parseInt(timeMatch[3]) + parseInt(timeMatch[4]) / 1000;
|
||||
const end = parseInt(timeMatch[5]) * 3600 + parseInt(timeMatch[6]) * 60 + parseInt(timeMatch[7]) + parseInt(timeMatch[8]) / 1000;
|
||||
let text = lines.slice(2).join('\n');
|
||||
text = text.replace(/<[^>]*>/g, '');
|
||||
text = text.replace(/\{[^}]*\}/g, '');
|
||||
return { start, end, text };
|
||||
}).filter(Boolean);
|
||||
}
|
||||
render() {
|
||||
if (this.destroyed) return;
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
const currentTime = this.video.currentTime;
|
||||
const cue = this.cues.find(c => currentTime >= c.start && currentTime <= c.end);
|
||||
if (cue) this.drawSubtitle(cue.text);
|
||||
}
|
||||
drawSubtitle(text) {
|
||||
const lines = text.split('\n');
|
||||
const fontSize = Math.max(20, this.canvas.height * 0.04);
|
||||
this.ctx.font = `bold ${fontSize}px Arial, sans-serif`;
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.textBaseline = 'bottom';
|
||||
const lineHeight = fontSize * 1.2;
|
||||
const startY = this.canvas.height - 60;
|
||||
lines.reverse().forEach((line, index) => {
|
||||
const y = startY - (index * lineHeight);
|
||||
this.ctx.strokeStyle = 'black';
|
||||
this.ctx.lineWidth = 4;
|
||||
this.ctx.strokeText(line, this.canvas.width / 2, y);
|
||||
this.ctx.fillStyle = 'white';
|
||||
this.ctx.fillText(line, this.canvas.width / 2, y);
|
||||
});
|
||||
}
|
||||
dispose() {
|
||||
this.destroyed = true;
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
if (this.resizeHandler) window.removeEventListener('resize', this.resizeHandler);
|
||||
}
|
||||
}
|
||||
window.SubtitleRenderer = SubtitleRenderer;
|
||||
window.SimpleSubtitleRenderer = SimpleSubtitleRenderer;
|
||||
@@ -115,4 +115,66 @@ function setupDropdown() {
|
||||
})
|
||||
}
|
||||
|
||||
loadMeUI()
|
||||
loadMeUI()
|
||||
|
||||
const mobileToggle = document.getElementById('mobile-menu-toggle');
|
||||
const navCenter = document.querySelector('.nav-center');
|
||||
|
||||
let overlay = document.querySelector('.menu-overlay');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.className = 'menu-overlay';
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
navCenter.classList.toggle('open');
|
||||
overlay.classList.toggle('active');
|
||||
|
||||
const isOpen = navCenter.classList.contains('open');
|
||||
mobileToggle.innerHTML = isOpen
|
||||
? '<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>'
|
||||
: '<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/></svg>';
|
||||
}
|
||||
|
||||
mobileToggle.addEventListener('click', toggleMenu);
|
||||
|
||||
overlay.addEventListener('click', () => {
|
||||
if (navCenter.classList.contains('open')) toggleMenu();
|
||||
});
|
||||
|
||||
navCenter.querySelectorAll('.nav-button').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (navCenter.classList.contains('open')) toggleMenu();
|
||||
});
|
||||
});
|
||||
|
||||
const searchWrapper = document.querySelector('.search-wrapper');
|
||||
const searchInput = document.getElementById('search-input');
|
||||
|
||||
searchWrapper.addEventListener('click', (e) => {
|
||||
if (window.innerWidth <= 768) {
|
||||
searchWrapper.classList.add('active-mobile');
|
||||
searchInput.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Cerrar el buscador si se hace clic fuera
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!searchWrapper.contains(e.target)) {
|
||||
searchWrapper.classList.remove('active-mobile');
|
||||
}
|
||||
});
|
||||
|
||||
const createRoomModal = new CreateRoomModal();
|
||||
|
||||
const createBtn = document.getElementById('nav-create-party');
|
||||
if (createBtn) {
|
||||
createBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const dropdown = document.getElementById('nav-dropdown');
|
||||
if(dropdown) dropdown.classList.remove('active');
|
||||
|
||||
createRoomModal.open();
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ let isLocal = false;
|
||||
let currentLanguage = null;
|
||||
let uniqueLanguages = [];
|
||||
let isSortAscending = true;
|
||||
let manualExtensionBookId = null;
|
||||
|
||||
const chapterPagination = Object.create(PaginationManager);
|
||||
chapterPagination.init(6, () => renderChapterList());
|
||||
@@ -36,6 +37,37 @@ async function init() {
|
||||
await loadChapters();
|
||||
await setupAddToListButton();
|
||||
|
||||
document.getElementById('manual-match-btn')?.addEventListener('click', () => {
|
||||
const select = document.getElementById('provider-filter');
|
||||
const provider = select.value;
|
||||
|
||||
// Obtener título para prellenar
|
||||
const currentTitle = bookData?.title?.romaji || bookData?.title?.english || '';
|
||||
|
||||
MatchModal.open({
|
||||
provider: provider,
|
||||
initialQuery: currentTitle,
|
||||
// Define CÓMO buscar
|
||||
onSearch: async (query, prov) => {
|
||||
const res = await fetch(`/api/search/books/${prov}?q=${encodeURIComponent(query)}`);
|
||||
const data = await res.json();
|
||||
return data.results || [];
|
||||
},
|
||||
// Define QUÉ hacer al seleccionar
|
||||
onSelect: (item) => {
|
||||
console.log("Selected Book ID:", item.id);
|
||||
manualExtensionBookId = item.id;
|
||||
|
||||
// Lógica existente de tu book.js para recargar caps
|
||||
loadChapters(provider);
|
||||
|
||||
// Feedback visual en el botón
|
||||
const btn = document.getElementById('manual-match-btn');
|
||||
if(btn) btn.style.color = '#22c55e';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error("Init Error:", err);
|
||||
showError("Error loading book");
|
||||
@@ -170,7 +202,7 @@ function renderRelations(edges) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'relation-card-horizontal';
|
||||
|
||||
const img = node.coverImage?.large || node.coverImage?.medium || '/public/assets/no-image.png';
|
||||
const img = node.coverImage?.large || node.coverImage?.medium || '/public/assets/placeholder.svg';
|
||||
const title = node.title?.romaji || node.title?.english || node.title?.native || 'Unknown';
|
||||
const type = edge.relationType ? edge.relationType.replace(/_/g, ' ') : 'Related';
|
||||
|
||||
@@ -183,7 +215,8 @@ function renderRelations(edges) {
|
||||
`;
|
||||
|
||||
el.onclick = () => {
|
||||
const targetType = node.type === 'ANIME' ? 'anime' : 'book';
|
||||
const imgUrl = node.coverImage?.medium || '';
|
||||
const targetType = imgUrl.includes('/manga/') ? 'book' : 'anime';
|
||||
window.location.href = `/${targetType}/${node.id}`;
|
||||
};
|
||||
|
||||
@@ -311,6 +344,9 @@ async function loadChapters(targetProvider = null) {
|
||||
const source = extensionName || 'anilist';
|
||||
fetchUrl = `/api/book/${bookId}/chapters?source=${source}`;
|
||||
if (targetProvider !== 'all') fetchUrl += `&provider=${targetProvider}`;
|
||||
if (manualExtensionBookId && targetProvider !== 'all') {
|
||||
fetchUrl += `&extensionBookId=${manualExtensionBookId}`;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(fetchUrl);
|
||||
@@ -515,6 +551,8 @@ async function loadAvailableExtensions() {
|
||||
|
||||
function setupProviderFilter() {
|
||||
const select = document.getElementById('provider-filter');
|
||||
const manualBtn = document.getElementById('manual-match-btn'); // NUEVO
|
||||
|
||||
if (!select) return;
|
||||
select.style.display = 'inline-block';
|
||||
select.innerHTML = '';
|
||||
@@ -538,11 +576,32 @@ function setupProviderFilter() {
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
// Lógica de selección inicial
|
||||
if (isLocal) select.value = 'local';
|
||||
else if (extensionName && availableExtensions.includes(extensionName)) select.value = extensionName;
|
||||
else if (availableExtensions.length > 0) select.value = availableExtensions[0];
|
||||
|
||||
select.onchange = () => loadChapters(select.value);
|
||||
// Visibilidad inicial del botón manual
|
||||
updateManualButtonVisibility(select.value);
|
||||
|
||||
select.onchange = () => {
|
||||
// Al cambiar de proveedor, reseteamos la selección manual para evitar conflictos
|
||||
manualExtensionBookId = null;
|
||||
updateManualButtonVisibility(select.value);
|
||||
loadChapters(select.value);
|
||||
};
|
||||
}
|
||||
|
||||
function updateManualButtonVisibility(provider) {
|
||||
const btn = document.getElementById('manual-match-btn');
|
||||
if (!btn) return;
|
||||
|
||||
// Solo mostrar si es un proveedor específico (no 'all' ni 'local')
|
||||
if (provider !== 'all' && provider !== 'local') {
|
||||
btn.style.display = 'flex';
|
||||
} else {
|
||||
btn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function updateExtensionPill() {
|
||||
@@ -569,9 +628,9 @@ function updateCustomAddButton() {
|
||||
}
|
||||
|
||||
function setupModalClickOutside() {
|
||||
const modal = document.getElementById('add-list-modal');
|
||||
if (modal) {
|
||||
modal.addEventListener('click', (e) => {
|
||||
const addListModal = document.getElementById('add-list-modal');
|
||||
if (addListModal) {
|
||||
addListModal.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'add-list-modal') ListModalManager.close();
|
||||
});
|
||||
}
|
||||
|
||||
BIN
desktop/src/scripts/jassub/default.woff2
Normal file
BIN
desktop/src/scripts/jassub/default.woff2
Normal file
Binary file not shown.
BIN
desktop/src/scripts/jassub/jassub-worker-modern.wasm
Normal file
BIN
desktop/src/scripts/jassub/jassub-worker-modern.wasm
Normal file
Binary file not shown.
11
desktop/src/scripts/jassub/jassub-worker.js
Normal file
11
desktop/src/scripts/jassub/jassub-worker.js
Normal file
File diff suppressed because one or more lines are too long
BIN
desktop/src/scripts/jassub/jassub-worker.wasm
Normal file
BIN
desktop/src/scripts/jassub/jassub-worker.wasm
Normal file
Binary file not shown.
@@ -122,13 +122,16 @@ const DashboardApp = {
|
||||
headerBadge.title = `Connected as ${data.anilistUserId}`;
|
||||
}
|
||||
if (statusEl) {
|
||||
statusEl.textContent = `Connected as ID: ${data.anilistUserId}`;
|
||||
statusEl.style.color = 'var(--color-success)';
|
||||
// CAMBIO: Mostrar fecha de expiración si existe
|
||||
const expiresDate = data.expiresAt ? new Date(data.expiresAt).toLocaleDateString() : 'Unknown';
|
||||
statusEl.innerHTML = `
|
||||
<span style="color:var(--color-success)">Connected as: <b>${data.anilistUserId}</b></span>
|
||||
<span style="display:block; font-size:0.75rem; color:#71717a">Expires: ${expiresDate}</span>
|
||||
`;
|
||||
}
|
||||
if (btn) {
|
||||
btn.textContent = 'Disconnect';
|
||||
btn.className = 'btn-stream-outline link-danger';
|
||||
|
||||
btn.onclick = () => this.disconnectAniList(userId);
|
||||
}
|
||||
} else {
|
||||
@@ -140,7 +143,7 @@ const DashboardApp = {
|
||||
if (btn) {
|
||||
btn.textContent = 'Connect';
|
||||
btn.className = 'btn-stream-outline';
|
||||
btn.onclick = () => this.redirectToAniListLogin();
|
||||
btn.onclick = () => this.openAniListModal();
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -154,6 +157,83 @@ const DashboardApp = {
|
||||
window.location.href = `https://anilist.co/api/v2/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&state=${state}`;
|
||||
} catch (err) { console.error(err); alert('Error starting AniList login'); }
|
||||
},
|
||||
openAniListModal: function() {
|
||||
const modal = document.getElementById('anilist-connect-modal');
|
||||
const body = document.getElementById('anilist-modal-body');
|
||||
const clientId = 32898; // Tu Client ID
|
||||
|
||||
// Generamos el HTML del modal dinámicamente
|
||||
body.innerHTML = `
|
||||
<p class="modal-description">Connect your AniList account to sync your progress automatically.</p>
|
||||
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<label style="display:block; font-size:0.85rem; font-weight:600; color:#a1a1aa; margin-bottom:0.5rem">Step 1: Get Token</label>
|
||||
<a href="https://anilist.co/api/v2/oauth/authorize?client_id=${clientId}&response_type=token"
|
||||
target="_blank"
|
||||
class="btn-blur"
|
||||
style="width:100%; text-align:center; box-sizing:border-box; display:block;">
|
||||
Open AniList Login ↗
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>Step 2: Paste Token</label>
|
||||
<input type="text" id="manual-anilist-token" class="stream-input" placeholder="Paste the long access token here..." autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="modal-footer" style="padding:0; background:transparent;">
|
||||
<button class="btn-primary" style="width:100%" onclick="DashboardApp.User.submitAniListToken()">Verify & Connect</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
},
|
||||
|
||||
closeAniListModal: function() {
|
||||
document.getElementById('anilist-connect-modal').classList.add('hidden');
|
||||
},
|
||||
|
||||
submitAniListToken: async function() {
|
||||
const tokenInput = document.getElementById('manual-anilist-token');
|
||||
const token = tokenInput.value.trim();
|
||||
const userId = DashboardApp.State.currentUserId;
|
||||
|
||||
if (!token) {
|
||||
alert('Please paste the AniList token first');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmBtn = document.querySelector('#anilist-connect-modal .btn-primary');
|
||||
const originalText = confirmBtn.textContent;
|
||||
confirmBtn.textContent = "Verifying...";
|
||||
confirmBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/anilist/store`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: userId,
|
||||
accessToken: token
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to verify token');
|
||||
|
||||
this.closeAniListModal();
|
||||
await this.checkIntegrations(userId);
|
||||
alert('AniList connected successfully!');
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(err.message || 'Invalid Token');
|
||||
} finally {
|
||||
confirmBtn.textContent = originalText;
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
},
|
||||
|
||||
disconnectAniList: async function(userId) {
|
||||
if(!confirm("Disconnect AniList?")) return;
|
||||
@@ -387,6 +467,96 @@ const DashboardApp = {
|
||||
},
|
||||
|
||||
Library: {
|
||||
tempMatchContext: null,
|
||||
pollInterval: null,
|
||||
updateDownloadStatus: async function() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/library/downloads/status`, {
|
||||
headers: window.AuthUtils.getSimpleAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
this.renderDownloadMonitor(data);
|
||||
|
||||
// Si hay descargas completadas nuevas, podríamos recargar la lista de archivos
|
||||
// (Opcional: lógica para detectar cambios y llamar a loadContent)
|
||||
|
||||
} catch (e) {
|
||||
console.error("Error polling downloads:", e);
|
||||
}
|
||||
},
|
||||
|
||||
renderDownloadMonitor: function(data) {
|
||||
const monitor = document.getElementById('downloads-monitor');
|
||||
const listContainer = document.getElementById('downloads-list-container');
|
||||
const activeCountEl = document.getElementById('dl-stat-active');
|
||||
|
||||
// Datos por defecto
|
||||
const downloads = data.downloads || { list: [], active: 0, failed: 0 };
|
||||
|
||||
// Actualizar contadores cabecera
|
||||
if(activeCountEl) activeCountEl.textContent = `${downloads.active} Active / ${downloads.list.length} Total`;
|
||||
|
||||
// Ocultar si vacío
|
||||
if (downloads.list.length === 0) {
|
||||
monitor.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
monitor.classList.remove('hidden');
|
||||
|
||||
listContainer.innerHTML = downloads.list.map(item => {
|
||||
const fileName = item.fileName || `Unknown_File_${item.unitNumber}`;
|
||||
const folderName = item.folderName || 'Unsorted';
|
||||
const status = item.status || 'pending';
|
||||
const progress = item.progress || 0;
|
||||
const speed = item.speed || '0 KB/s';
|
||||
|
||||
const isCompleted = status === 'completed';
|
||||
const isFailed = status === 'failed';
|
||||
|
||||
let statusText = `${progress}%`;
|
||||
if (isCompleted) statusText = 'Done';
|
||||
if (isFailed) statusText = 'Failed';
|
||||
|
||||
const folderIcon = `<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>`;
|
||||
|
||||
// ESTRUCTURA NUEVA: Más plana para permitir Flexbox horizontal
|
||||
return `
|
||||
<div class="dl-item compact">
|
||||
<div class="dl-left-col">
|
||||
<div class="dl-filename" title="${fileName}">${fileName}</div>
|
||||
<div class="dl-folder" title="${folderName}">${folderIcon} ${folderName}</div>
|
||||
</div>
|
||||
|
||||
<div class="dl-right-col">
|
||||
<div class="dl-meta-info">
|
||||
<span class="dl-speed">${isCompleted ? '' : speed}</span>
|
||||
<span class="dl-status-text ${status}">${statusText}</span>
|
||||
</div>
|
||||
<div class="dl-progress-track">
|
||||
<div class="dl-progress-fill ${status}" style="width: ${progress}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
},
|
||||
|
||||
startPolling: function() {
|
||||
if (this.pollInterval) clearInterval(this.pollInterval);
|
||||
this.updateDownloadStatus(); // Primera llamada inmediata
|
||||
this.pollInterval = setInterval(() => this.updateDownloadStatus(), 2000); // Cada 2 segundos
|
||||
},
|
||||
|
||||
stopPolling: function() {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
},
|
||||
loadStats: async function() {
|
||||
const types = ['anime', 'manga', 'novels'];
|
||||
const elements = { 'anime': 'local-anime-count', 'manga': 'local-manga-count', 'novels': 'local-novel-count' };
|
||||
@@ -446,7 +616,7 @@ const DashboardApp = {
|
||||
const meta = entry.metadata || {};
|
||||
let poster = meta.coverImage?.large || '/public/assets/placeholder.svg';
|
||||
|
||||
let title = isMatched ? (meta.title?.english || meta.title?.romaji) : entry.folder_name;
|
||||
let title = isMatched ? (meta.title?.english || meta.title?.romaji) : entry.path;
|
||||
if (!isMatched) title = title.replace(/\[.*?\]|\(.*?\)|\.mkv|\.mp4/g, '').trim();
|
||||
|
||||
const url = isMatched ? (type === 'anime' ? `/anime/${meta.id}` : `/book/${meta.id}`) : '#';
|
||||
@@ -459,12 +629,12 @@ const DashboardApp = {
|
||||
${!isMatched ? `<div class="unmatched-badge"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg> UNMATCHED</div>` : ''}
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<h3 class="item-title" title="${entry.folder_name}">${title}</h3>
|
||||
<h3 class="item-title" title="${entry.path}">${title}</h3>
|
||||
<div class="item-meta">
|
||||
<span class="meta-pill type-pill">${entry.files ? entry.files.length : 0} FILES</span>
|
||||
<span class="meta-pill type-pill">${entry.files} FILES</span>
|
||||
${isMatched ? '<span class="meta-pill status-pill">MATCHED</span>' : ''}
|
||||
</div>
|
||||
<div class="folder-path-tooltip">${entry.folder_name}</div>
|
||||
<div class="folder-path-tooltip">${entry.path}</div>
|
||||
</div>
|
||||
<button class="edit-icon-btn" onclick="DashboardApp.Library.openManualMatch('${entry.id}', '${type}')" title="${isMatched ? 'Rematch' : 'Fix Match'}">
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
@@ -505,16 +675,67 @@ const DashboardApp = {
|
||||
},
|
||||
|
||||
openManualMatch: function(id, type) {
|
||||
const newId = prompt("Enter AniList ID to force match:");
|
||||
if (newId) {
|
||||
fetch(`${API_BASE}/library/${type}/${id}/match`, {
|
||||
const item = DashboardApp.State.localLibraryData.find(x => x.id === id);
|
||||
const pathName = item ? item.path : 'Unknown path';
|
||||
|
||||
this.tempMatchContext = { id, type };
|
||||
|
||||
document.getElementById('manual-match-path').textContent = pathName;
|
||||
document.getElementById('manual-match-id').value = '';
|
||||
|
||||
const modal = document.getElementById('manual-match-modal');
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
setTimeout(() => document.getElementById('manual-match-id').focus(), 100);
|
||||
},
|
||||
|
||||
closeManualMatch: function() {
|
||||
document.getElementById('manual-match-modal').classList.add('hidden');
|
||||
this.tempMatchContext = null;
|
||||
},
|
||||
|
||||
submitManualMatch: async function() {
|
||||
if (!this.tempMatchContext) return;
|
||||
|
||||
const newId = document.getElementById('manual-match-id').value;
|
||||
if (!newId) {
|
||||
alert("Please enter a valid ID");
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, type } = this.tempMatchContext;
|
||||
|
||||
const confirmBtn = document.querySelector('#manual-match-modal .btn-primary');
|
||||
const originalText = confirmBtn.textContent;
|
||||
confirmBtn.textContent = "Matching...";
|
||||
confirmBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/library/${type}/${id}/match`, {
|
||||
method: 'POST',
|
||||
headers: { ...window.AuthUtils.getSimpleAuthHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source: 'anilist', matched_id: parseInt(newId) })
|
||||
}).then(res => {
|
||||
if(res.ok) { alert("Matched! Refreshing..."); this.loadContent(type); }
|
||||
else { alert("Failed to match."); }
|
||||
headers: {
|
||||
...window.AuthUtils.getSimpleAuthHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source: 'anilist',
|
||||
matched_id: parseInt(newId)
|
||||
})
|
||||
});
|
||||
|
||||
if(res.ok) {
|
||||
this.closeManualMatch();
|
||||
this.loadContent(type);
|
||||
} else {
|
||||
const errData = await res.json();
|
||||
alert("Failed to match: " + (errData.error || "Unknown error"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Connection error");
|
||||
} finally {
|
||||
confirmBtn.textContent = originalText;
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -532,6 +753,7 @@ const DashboardApp = {
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
// Gestión de clases activas
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
|
||||
@@ -541,9 +763,13 @@ const DashboardApp = {
|
||||
if (sec.id === targetId) sec.classList.add('active');
|
||||
});
|
||||
|
||||
// Lógica específica por pestaña
|
||||
if (tab.dataset.target === 'local') {
|
||||
DashboardApp.Library.loadStats();
|
||||
DashboardApp.Library.loadContent('anime');
|
||||
DashboardApp.Library.loadContent(DashboardApp.State.currentLocalType || 'anime');
|
||||
DashboardApp.Library.startPolling(); // <--- INICIAR POLLING
|
||||
} else {
|
||||
DashboardApp.Library.stopPolling(); // <--- DETENER POLLING AL SALIR
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
131
desktop/src/scripts/room-modal.js
Normal file
131
desktop/src/scripts/room-modal.js
Normal file
@@ -0,0 +1,131 @@
|
||||
class CreateRoomModal {
|
||||
constructor() {
|
||||
this.modalId = 'cr-modal-overlay';
|
||||
this.isRendered = false;
|
||||
this.render(); // Crear el HTML en el DOM al instanciar
|
||||
}
|
||||
|
||||
render() {
|
||||
if (document.getElementById(this.modalId)) return;
|
||||
|
||||
const modalHtml = `
|
||||
<div class="cr-modal-overlay" id="${this.modalId}">
|
||||
<div class="cr-modal-content">
|
||||
<button class="cr-modal-close" id="cr-close">✕</button>
|
||||
<h2 class="cr-modal-title">Create Watch Party</h2>
|
||||
|
||||
<form id="cr-form">
|
||||
<div class="cr-form-group">
|
||||
<label>Room Name</label>
|
||||
<input type="text" class="cr-input" name="name" placeholder="My Awesome Room" required maxlength="50" />
|
||||
</div>
|
||||
|
||||
<div class="cr-form-group">
|
||||
<label>Password (Optional)</label>
|
||||
<input type="password" class="cr-input" name="password" placeholder="Leave empty for public" maxlength="50" />
|
||||
</div>
|
||||
|
||||
<div class="cr-form-group cr-checkbox-group">
|
||||
<label class="cr-checkbox">
|
||||
<input type="checkbox" name="expose" />
|
||||
<span>Generate public link (via tunnel)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="cr-actions">
|
||||
<button type="button" class="cr-btn-cancel" id="cr-cancel">Cancel</button>
|
||||
<button type="submit" class="cr-btn-confirm">Create Room</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
this.bindEvents();
|
||||
this.isRendered = true;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
const modal = document.getElementById(this.modalId);
|
||||
const closeBtn = document.getElementById('cr-close');
|
||||
const cancelBtn = document.getElementById('cr-cancel');
|
||||
const form = document.getElementById('cr-form');
|
||||
|
||||
const close = () => this.close();
|
||||
|
||||
closeBtn.onclick = close;
|
||||
cancelBtn.onclick = close;
|
||||
|
||||
// Cerrar si clicamos fuera del contenido
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) close();
|
||||
};
|
||||
|
||||
form.onsubmit = (e) => this.handleSubmit(e);
|
||||
}
|
||||
|
||||
open() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
// Aquí puedes disparar tu modal de login o redirigir
|
||||
alert('You must be logged in to create a room');
|
||||
window.location.href = '/login'; // Opcional
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.getElementById(this.modalId);
|
||||
modal.classList.add('show');
|
||||
document.querySelector('#cr-form input[name="name"]').focus();
|
||||
}
|
||||
|
||||
close() {
|
||||
const modal = document.getElementById(this.modalId);
|
||||
modal.classList.remove('show');
|
||||
document.getElementById('cr-form').reset();
|
||||
}
|
||||
|
||||
async handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('button[type="submit"]');
|
||||
const originalText = btn.textContent;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Creating...';
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
const expose = formData.get('expose') === 'on';
|
||||
const name = formData.get('name').trim();
|
||||
const password = formData.get('password').trim();
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/rooms', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
password: password || undefined,
|
||||
expose
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to create room');
|
||||
|
||||
this.close();
|
||||
|
||||
window.location.href = `/room?id=${data.room.id}`
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.CreateRoomModal = CreateRoomModal;
|
||||
2274
desktop/src/scripts/room.js
Normal file
2274
desktop/src/scripts/room.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -838,8 +838,7 @@ function openAniListModal(userId) {
|
||||
modalUserActions.classList.remove('active');
|
||||
modalEditUser.classList.remove('active');
|
||||
|
||||
aniListContent.innerHTML = `<div style="text-align: center; padding: 2rem;">Loading integration status...</div>`;
|
||||
|
||||
// Estado de carga inicial
|
||||
modalAniList.innerHTML = `
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-content">
|
||||
@@ -860,12 +859,15 @@ function openAniListModal(userId) {
|
||||
|
||||
modalAniList.classList.add('active');
|
||||
|
||||
// Verificar si ya está conectado
|
||||
getIntegrationStatus(userId).then(integration => {
|
||||
const content = document.getElementById('aniListContent');
|
||||
const clientId = 32898; // Tu Client ID de AniList
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="anilist-status">
|
||||
${integration.connected ? `
|
||||
if (integration.connected) {
|
||||
// VISTA: YA CONECTADO
|
||||
content.innerHTML = `
|
||||
<div class="anilist-status">
|
||||
<div class="anilist-connected">
|
||||
<div class="anilist-icon">
|
||||
<img src="https://anilist.co/img/icons/icon.svg" alt="AniList" style="width:40px; height:40px;">
|
||||
@@ -879,24 +881,43 @@ function openAniListModal(userId) {
|
||||
<button class="btn-disconnect" onclick="handleDisconnectAniList()">
|
||||
Disconnect AniList
|
||||
</button>
|
||||
` : `
|
||||
<div style="text-align: center; padding: 1rem;">
|
||||
<h3 style="margin-bottom: 0.5rem;">Connect with AniList</h3>
|
||||
<p style="color: var(--color-text-secondary); margin-bottom: 1.5rem;">
|
||||
Sync your anime list by logging in with AniList.
|
||||
</p>
|
||||
<div style="display:flex; justify-content:center;">
|
||||
<button class="btn-connect" onclick="redirectToAniListLogin()">
|
||||
Login with AniList
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// VISTA: NO CONECTADO (Formulario Manual)
|
||||
content.innerHTML = `
|
||||
<div class="anilist-status">
|
||||
<div style="text-align: left; padding: 0.5rem;">
|
||||
<h3 style="margin-bottom: 1rem; font-size: 1.1rem;">How to connect:</h3>
|
||||
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<p style="color: var(--color-text-secondary); margin-bottom: 0.5rem; font-size: 0.9rem;">
|
||||
1. Open the authorization page in a new tab:
|
||||
</p>
|
||||
<a href="https://anilist.co/api/v2/oauth/authorize?client_id=${clientId}&response_type=token"
|
||||
target="_blank"
|
||||
class="btn-secondary"
|
||||
style="display: inline-block; text-decoration: none; text-align: center; width: 100%; padding: 0.8rem;">
|
||||
Open AniList Login ↗
|
||||
</a>
|
||||
</div>
|
||||
<p style="font-size:0.85rem; margin-top:1rem; color:var(--color-text-secondary)">
|
||||
You will be redirected and then returned here.
|
||||
</p>
|
||||
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<p style="color: var(--color-text-secondary); margin-bottom: 0.5rem; font-size: 0.9rem;">
|
||||
2. Authorize the app, then copy the <b>Access Token</b> provided and paste it here:
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<input type="text" id="manualAniListToken" placeholder="Paste your Access Token here..." autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-connect" onclick="handleManualAniListToken()">
|
||||
Verify & Save Token
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
const content = document.getElementById('aniListContent');
|
||||
@@ -904,6 +925,49 @@ function openAniListModal(userId) {
|
||||
});
|
||||
}
|
||||
|
||||
// Nueva función para manejar el token pegado manualmente
|
||||
async function handleManualAniListToken() {
|
||||
const tokenInput = document.getElementById('manualAniListToken');
|
||||
const token = tokenInput.value.trim();
|
||||
|
||||
if (!token) {
|
||||
showUserToast('Please paste the AniList token first', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const submitBtn = document.querySelector('.btn-connect');
|
||||
const originalText = submitBtn.textContent;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Verifying...';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/anilist/store`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: currentUserId,
|
||||
accessToken: token,
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Failed to verify token');
|
||||
}
|
||||
|
||||
showUserToast('AniList connected successfully!', 'success');
|
||||
// Recargar el modal para mostrar el estado "Conectado"
|
||||
openAniListModal(currentUserId);
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showUserToast(err.message || 'Invalid Token', 'error');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
async function redirectToAniListLogin() {
|
||||
try {
|
||||
const res = await fetch(`/api/login`, {
|
||||
@@ -918,15 +982,10 @@ async function redirectToAniListLogin() {
|
||||
localStorage.setItem('token', data.token);
|
||||
|
||||
const clientId = 32898;
|
||||
const redirectUri = encodeURIComponent(window.location.origin + '/api/anilist');
|
||||
const state = encodeURIComponent(currentUserId);
|
||||
|
||||
window.location.href =
|
||||
`https://anilist.co/api/v2/oauth/authorize` +
|
||||
`?client_id=${clientId}` +
|
||||
`&response_type=code` +
|
||||
`&redirect_uri=${redirectUri}` +
|
||||
`&state=${state}`;
|
||||
window.open(
|
||||
`https://anilist.co/api/v2/oauth/authorize?client_id=${clientId}&response_type=token`,
|
||||
'_blank'
|
||||
);
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
171
desktop/src/scripts/utils/match-modal.js
Normal file
171
desktop/src/scripts/utils/match-modal.js
Normal file
@@ -0,0 +1,171 @@
|
||||
const MatchModal = (function() {
|
||||
let _config = {
|
||||
onSearch: async (query, provider) => [], // Debe devolver Array de objetos
|
||||
onSelect: (item, provider) => {},
|
||||
provider: 'generic'
|
||||
};
|
||||
|
||||
let elements = {};
|
||||
let searchTimeout = null;
|
||||
|
||||
function init() {
|
||||
if (document.getElementById('waifu-match-modal')) return;
|
||||
|
||||
// Inyectar HTML
|
||||
const modalHTML = `
|
||||
<div class="match-modal-overlay" id="waifu-match-modal">
|
||||
<div class="match-modal-content">
|
||||
<div class="match-header">
|
||||
<h3 class="match-title">Manual Match <span id="match-provider-badge" style="opacity:0.6; font-size:0.8em; margin-left:8px;"></span></h3>
|
||||
<button class="match-close-btn" id="match-close-btn">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="match-search-container">
|
||||
<input type="text" id="match-input" class="match-input" placeholder="Search title..." autocomplete="off">
|
||||
<button id="match-btn-action" class="match-search-btn">Search</button>
|
||||
</div>
|
||||
|
||||
<div class="match-results-body" id="match-results-container">
|
||||
<div class="match-msg">Type to start searching...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHTML);
|
||||
|
||||
// Cachear elementos
|
||||
elements = {
|
||||
overlay: document.getElementById('waifu-match-modal'),
|
||||
input: document.getElementById('match-input'),
|
||||
results: document.getElementById('match-results-container'),
|
||||
badge: document.getElementById('match-provider-badge'),
|
||||
closeBtn: document.getElementById('match-close-btn'),
|
||||
searchBtn: document.getElementById('match-btn-action')
|
||||
};
|
||||
|
||||
// Event Listeners
|
||||
elements.closeBtn.onclick = close;
|
||||
elements.overlay.onclick = (e) => { if(e.target === elements.overlay) close(); };
|
||||
|
||||
// Búsqueda al hacer clic
|
||||
elements.searchBtn.onclick = () => performSearch(elements.input.value);
|
||||
|
||||
// Búsqueda al escribir (Debounce)
|
||||
elements.input.addEventListener('input', (e) => {
|
||||
clearTimeout(searchTimeout);
|
||||
if(e.target.value.trim().length === 0) return;
|
||||
searchTimeout = setTimeout(() => performSearch(e.target.value), 600);
|
||||
});
|
||||
|
||||
// Enter key
|
||||
elements.input.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') performSearch(elements.input.value);
|
||||
});
|
||||
}
|
||||
|
||||
function open(options) {
|
||||
init(); // Asegurar que el DOM existe
|
||||
|
||||
_config = { ..._config, ...options };
|
||||
|
||||
// Resetear UI
|
||||
elements.input.value = options.initialQuery || '';
|
||||
elements.results.innerHTML = '<div class="match-msg">Search above to find matches...</div>';
|
||||
elements.badge.innerText = options.provider ? `(${options.provider})` : '';
|
||||
|
||||
// Mostrar Modal
|
||||
elements.overlay.classList.add('active');
|
||||
|
||||
// Auto-search si hay query inicial
|
||||
if (options.initialQuery) {
|
||||
performSearch(options.initialQuery);
|
||||
}
|
||||
|
||||
setTimeout(() => elements.input.focus(), 100);
|
||||
}
|
||||
|
||||
function close() {
|
||||
if(elements.overlay) elements.overlay.classList.remove('active');
|
||||
}
|
||||
|
||||
async function performSearch(query) {
|
||||
if (!query || query.trim().length < 2) return;
|
||||
|
||||
elements.results.innerHTML = '<div class="match-spinner"></div>';
|
||||
|
||||
try {
|
||||
// Ejecutar la función de búsqueda pasada en la config
|
||||
const results = await _config.onSearch(query, _config.provider);
|
||||
renderResults(results);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
elements.results.innerHTML = '<div class="match-msg error">Error searching provider.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderResults(results) {
|
||||
elements.results.innerHTML = '';
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
elements.results.innerHTML = '<div class="match-msg">No matches found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'match-list-grid';
|
||||
|
||||
results.forEach(item => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'match-item';
|
||||
|
||||
// Normalización de datos para asegurar compatibilidad con Anime/Libros
|
||||
const img = item.coverImage?.large || item.coverImage || item.image || '/public/assets/no-image.png';
|
||||
const title = item.title?.english || item.title?.romaji || item.title || 'Unknown Title';
|
||||
const meta = item.releaseDate || item.year || item.startDate?.year || '';
|
||||
const url = item.url || item.externalUrl || null;
|
||||
|
||||
el.innerHTML = `
|
||||
<img src="${img}" class="match-poster" loading="lazy">
|
||||
<div class="match-info">
|
||||
<div class="match-item-title">${title}</div>
|
||||
<div class="match-item-meta">${meta}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Botón de enlace externo (si existe URL)
|
||||
if (url) {
|
||||
const linkBtn = document.createElement('a');
|
||||
linkBtn.href = url;
|
||||
linkBtn.target = "_blank";
|
||||
linkBtn.className = "match-link-btn";
|
||||
linkBtn.title = "View Source";
|
||||
linkBtn.innerHTML = `
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
</svg>
|
||||
`;
|
||||
// Evitar que el click en el enlace dispare el select
|
||||
linkBtn.onclick = (e) => e.stopPropagation();
|
||||
el.appendChild(linkBtn);
|
||||
}
|
||||
|
||||
// Click en la tarjeta selecciona
|
||||
el.onclick = () => {
|
||||
_config.onSelect(item);
|
||||
close();
|
||||
};
|
||||
|
||||
grid.appendChild(el);
|
||||
});
|
||||
|
||||
elements.results.appendChild(grid);
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
close
|
||||
};
|
||||
})();
|
||||
@@ -2,6 +2,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import yaml from 'js-yaml';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const BASE_DIR = path.join(os.homedir(), 'WaifuBoards');
|
||||
const CONFIG_PATH = path.join(BASE_DIR, 'config.yaml');
|
||||
@@ -14,7 +15,12 @@ const DEFAULT_CONFIG = {
|
||||
},
|
||||
paths: {
|
||||
mpv: null,
|
||||
ffmpeg: null
|
||||
ffmpeg: null,
|
||||
ffprobe: null,
|
||||
cloudflared: null,
|
||||
},
|
||||
server: {
|
||||
jwt_secret: null
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,7 +32,9 @@ export const CONFIG_SCHEMA = {
|
||||
},
|
||||
paths: {
|
||||
mpv: { description: "Required to open anime episodes in mpv on desktop version." },
|
||||
ffmpeg: { description: "Required for downloading anime episodes." }
|
||||
ffmpeg: { description: "Required for downloading anime episodes." },
|
||||
ffprobe: { description: "Required for watching local anime episodes." },
|
||||
cloudflared: { description: "Required for creating pubic rooms." }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,13 +43,31 @@ function ensureConfigFile() {
|
||||
fs.mkdirSync(BASE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
let configExists = fs.existsSync(CONFIG_PATH);
|
||||
|
||||
if (!configExists) {
|
||||
fs.writeFileSync(
|
||||
CONFIG_PATH,
|
||||
yaml.dump(DEFAULT_CONFIG),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const loaded = yaml.load(raw) || {};
|
||||
|
||||
if (!loaded.server) loaded.server = {};
|
||||
if (!loaded.server.jwt_secret) {
|
||||
loaded.server.jwt_secret = crypto.randomBytes(32).toString('hex');
|
||||
fs.writeFileSync(CONFIG_PATH, yaml.dump(deepMerge(structuredClone(DEFAULT_CONFIG), loaded)), 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
export function getPublicConfig() {
|
||||
const { values } = getConfig();
|
||||
const publicConfig = structuredClone(values);
|
||||
if (publicConfig.server) delete publicConfig.server.jwt_secret;
|
||||
return publicConfig;
|
||||
}
|
||||
|
||||
export function getConfig() {
|
||||
|
||||
@@ -130,6 +130,12 @@ async function viewsRoutes(fastify: FastifyInstance) {
|
||||
reply.type('text/html').send(html);
|
||||
});
|
||||
|
||||
fastify.get('/room', (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', 'room.html');
|
||||
const html = fs.readFileSync(htmlPath, 'utf-8');
|
||||
reply.type('text/html').send(html);
|
||||
});
|
||||
|
||||
fastify.setNotFoundHandler((req, reply) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', '404.html');
|
||||
const html = fs.readFileSync(htmlPath, 'utf-8');
|
||||
|
||||
@@ -5,15 +5,16 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon" />
|
||||
<title>WaifuBoard</title>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
|
||||
<script src="https://cdn.plyr.io/3.7.8/plyr.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import JASSUB from 'https://cdn.jsdelivr.net/npm/jassub@1.8.8/dist/jassub.es.js';
|
||||
window.JASSUB = JASSUB;
|
||||
</script>
|
||||
<link rel="stylesheet" href="/views/css/globals.css" />
|
||||
<link rel="stylesheet" href="/views/css/components/anilist-modal.css" />
|
||||
<link rel="stylesheet" href="/views/css/anime/anime.css" />
|
||||
<link rel="stylesheet" href="/views/css/anime/player.css" />
|
||||
<link rel="stylesheet" href="/views/css/components/match-modal.css">
|
||||
<link rel="stylesheet" href="/views/css/components/titlebar.css">
|
||||
<script src="/src/scripts/titlebar.js"></script>
|
||||
</head>
|
||||
@@ -53,18 +54,27 @@
|
||||
|
||||
<div class="player-wrapper" id="player-wrapper" style="display: none;">
|
||||
<div class="player-container">
|
||||
<!-- Side Navigation Buttons -->
|
||||
<button id="prev-ep-btn" class="side-nav-btn left" title="Previous Episode">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 18l-6-6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button id="next-ep-btn" class="side-nav-btn right" title="Next Episode">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 18l6-6-6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Header Controls -->
|
||||
<div class="player-header">
|
||||
<div class="header-left">
|
||||
<button class="btn-icon-glass" id="close-player-btn" title="Close Player">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5"/>
|
||||
<path d="M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="episode-info">
|
||||
<span class="ep-label">Watching</span>
|
||||
@@ -74,19 +84,28 @@
|
||||
|
||||
<div class="header-right">
|
||||
<button class="btn-icon-glass" id="download-btn" title="Download Episode" style="display: none;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="settings-group">
|
||||
<button class="btn-icon-glass" id="manual-match-btn" title="Manual Match" style="display: none;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button id="mpv-btn" class="glass-btn-mpv" title="Open in MPV">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M5 3l14 9-14 9V3z"></path>
|
||||
</svg>
|
||||
<span>MPV</span>
|
||||
</button>
|
||||
|
||||
<div class="sd-toggle" id="sd-toggle" data-state="sub">
|
||||
<div class="sd-bg"></div>
|
||||
<div class="sd-option active" id="opt-sub">Sub</div>
|
||||
@@ -99,40 +118,82 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Frame -->
|
||||
<div class="video-frame">
|
||||
<video id="player" controls crossorigin playsinline></video>
|
||||
<video id="player" crossorigin playsinline></video>
|
||||
<canvas id="subtitles-canvas"></canvas>
|
||||
|
||||
<div id="player-loading" class="player-loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
<p id="player-loading-text">Loading Stream...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-overlay" id="download-modal">
|
||||
<div class="modal-content download-settings-content">
|
||||
<button class="modal-close" id="close-download-modal">✕</button>
|
||||
<h2 class="modal-title">Download Settings</h2>
|
||||
|
||||
<div class="download-sections-wrapper">
|
||||
<div id="dl-quality-section" class="dl-section" style="display:none;">
|
||||
<h3>Video Quality</h3>
|
||||
<div class="dl-list" id="dl-quality-list"></div>
|
||||
<!-- Skip Overlay Button -->
|
||||
<button id="skip-overlay-btn">
|
||||
Skip Intro
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<path d="M13 17l5-5-5-5M6 17l5-5-5-5"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Custom Controls -->
|
||||
<div class="custom-controls">
|
||||
<div class="controls-gradient"></div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="progress-container">
|
||||
<div class="progress-buffer"></div>
|
||||
<div class="progress-played"></div>
|
||||
<div class="progress-handle"></div>
|
||||
</div>
|
||||
|
||||
<div id="dl-audio-section" class="dl-section" style="display:none;">
|
||||
<h3>Audio Tracks</h3>
|
||||
<div class="dl-list" id="dl-audio-list"></div>
|
||||
</div>
|
||||
<!-- Controls Row -->
|
||||
<div class="controls-row">
|
||||
<div class="controls-left">
|
||||
<button class="control-btn play-pause" id="play-pause-btn" title="Play/Pause (Space)">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div id="dl-subs-section" class="dl-section">
|
||||
<h3>Subtitles</h3>
|
||||
<div class="dl-list" id="dl-subs-list"></div>
|
||||
<div class="volume-control">
|
||||
<button class="control-btn" id="volume-btn" title="Mute (M)">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="volume-slider-container">
|
||||
<input type="range" class="volume-slider" id="volume-slider" min="0" max="100" value="100">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="time-display" id="time-display">0:00 / 0:00</span>
|
||||
</div>
|
||||
|
||||
<div class="controls-center">
|
||||
<!-- Center space for future controls -->
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
<button class="control-btn" id="settings-btn" title="Settings">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="control-btn" id="fullscreen-btn" title="Fullscreen (F)">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dl-actions">
|
||||
<button class="btn-cancel" id="cancel-dl-btn">Cancel</button>
|
||||
<button class="btn-confirm" id="confirm-dl-btn">Start Download</button>
|
||||
<!-- Settings Panel -->
|
||||
<div class="settings-panel" id="settings-panel">
|
||||
<!-- Populated dynamically by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,15 +269,46 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="download-modal">
|
||||
<div class="modal-content download-settings-content">
|
||||
<button class="modal-close" id="close-download-modal">✕</button>
|
||||
<h2 class="modal-title">Download Settings</h2>
|
||||
|
||||
<div class="download-sections-wrapper">
|
||||
<div id="dl-quality-section" class="dl-section" style="display:none;">
|
||||
<h3>Video Quality</h3>
|
||||
<div class="dl-list" id="dl-quality-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="dl-audio-section" class="dl-section" style="display:none;">
|
||||
<h3>Audio Tracks</h3>
|
||||
<div class="dl-list" id="dl-audio-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="dl-subs-section" class="dl-section">
|
||||
<h3>Subtitles</h3>
|
||||
<div class="dl-list" id="dl-subs-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dl-actions">
|
||||
<button class="btn-cancel" id="cancel-dl-btn">Cancel</button>
|
||||
<button class="btn-confirm" id="confirm-dl-btn">Start Download</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/src/scripts/utils/auth-utils.js"></script>
|
||||
<script src="/src/scripts/auth-guard.js"></script>
|
||||
<script src="/src/scripts/utils/url-utils.js"></script>
|
||||
<script src="/src/scripts/utils/pagination-manager.js"></script>
|
||||
<script src="/src/scripts/utils/media-metadata-utils.js"></script>
|
||||
<script src="/src/scripts/utils/youtube-player-utils.js"></script>
|
||||
<script src="/src/scripts/utils/list-modal-manager.js"></script>
|
||||
<script src="/src/scripts/utils/match-modal.js"></script>
|
||||
<script src="/src/scripts/anime/subtitle-renderer.js"></script>
|
||||
|
||||
<script src="/src/scripts/anime/player.js"></script>
|
||||
<script src="/src/scripts/anime/entry.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,10 +7,12 @@
|
||||
<link rel="stylesheet" href="/views/css/globals.css">
|
||||
<link rel="stylesheet" href="/views/css/components/navbar.css">
|
||||
<link rel="stylesheet" href="/views/css/components/hero.css">
|
||||
<link rel="stylesheet" href="/views/css/home.css">
|
||||
<link rel="stylesheet" href="/views/css/components/anilist-modal.css">
|
||||
<link rel="stylesheet" href="/views/css/components/updateNotifier.css">
|
||||
<link rel="stylesheet" href="/views/css/components/local-library.css">
|
||||
<link rel="stylesheet" href="/views/css/components/titlebar.css">
|
||||
<link rel="stylesheet" href="/views/css/components/create-room.css"/>
|
||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
|
||||
<script src="/src/scripts/titlebar.js"></script>
|
||||
</head>
|
||||
@@ -124,6 +126,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<script src="/src/scripts/utils/auth-utils.js"></script>
|
||||
<script src="/src/scripts/room-modal.js"></script>
|
||||
<script src="/src/scripts/utils/notification-utils.js"></script>
|
||||
<script src="/src/scripts/utils/search-manager.js"></script>
|
||||
<script src="/src/scripts/utils/list-modal-manager.js"></script>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<link rel="stylesheet" href="/views/css/books/book.css">
|
||||
<link rel="stylesheet" href="/views/css/components/updateNotifier.css">
|
||||
<link rel="stylesheet" href="/views/css/components/titlebar.css">
|
||||
<link rel="stylesheet" href="/views/css/components/match-modal.css">
|
||||
<script src="/src/scripts/titlebar.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -91,6 +92,12 @@
|
||||
<h2>Chapters</h2>
|
||||
|
||||
<div class="chapter-controls">
|
||||
<button id="manual-match-btn" class="glass-btn-icon" style="display: none;" title="Manual Match">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<select id="provider-filter" class="glass-select" style="display: none;">
|
||||
<option value="all">All Providers</option>
|
||||
</select>
|
||||
@@ -150,6 +157,7 @@
|
||||
<script src="/src/scripts/utils/pagination-manager.js"></script>
|
||||
<script src="/src/scripts/utils/media-metadata-utils.js"></script>
|
||||
<script src="/src/scripts/utils/list-modal-manager.js"></script>
|
||||
<script src="/src/scripts/utils/match-modal.js"></script>
|
||||
<script src="/src/scripts/books/book.js"></script>
|
||||
<script src="/src/scripts/auth-guard.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
<link rel="stylesheet" href="/views/css/globals.css">
|
||||
<link rel="stylesheet" href="/views/css/components/navbar.css">
|
||||
<link rel="stylesheet" href="/views/css/components/hero.css">
|
||||
<link rel="stylesheet" href="/views/css/home.css">
|
||||
<link rel="stylesheet" href="/views/css/components/titlebar.css">
|
||||
<link rel="stylesheet" href="/views/css/components/anilist-modal.css">
|
||||
<link rel="stylesheet" href="/views/css/components/updateNotifier.css">
|
||||
<script src="/src/scripts/room-modal.js"></script>
|
||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
|
||||
<script src="/src/scripts/titlebar.js"></script>
|
||||
<link rel="stylesheet" href="/views/css/components/local-library.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="titlebar"> <div class="title-left">
|
||||
@@ -101,6 +102,7 @@
|
||||
</div>
|
||||
|
||||
<script src="/src/scripts/utils/auth-utils.js"></script>
|
||||
<script src="/src/scripts/room-modal.js"></script>
|
||||
<script src="/src/scripts/utils/notification-utils.js"></script>
|
||||
<script src="/src/scripts/utils/search-manager.js"></script>
|
||||
<script src="/src/scripts/utils/list-modal-manager.js"></script>
|
||||
|
||||
@@ -53,6 +53,13 @@
|
||||
<span>Profile</span>
|
||||
</a>
|
||||
|
||||
<button class="dropdown-item" id="nav-create-party">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
||||
</svg>
|
||||
<span>Watchparty</span>
|
||||
</button>
|
||||
|
||||
<button class="dropdown-item" id="nav-settings">
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
@@ -71,5 +78,10 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="mobile-menu-toggle" id="mobile-menu-toggle">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -122,12 +122,23 @@
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: white;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 10px;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
height: 45px;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
textarea.form-input {
|
||||
height: auto;
|
||||
padding: 1rem;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
@@ -137,13 +148,33 @@
|
||||
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.15);
|
||||
}
|
||||
|
||||
.form-group.notes-group { grid-column: 1 / span 2; }
|
||||
.form-group.checkbox-group { grid-column: 3 / 4; align-self: end; margin-bottom: 0.8rem; }
|
||||
.form-group.notes-group {
|
||||
grid-column: 1 / -1;
|
||||
order: 10;
|
||||
}
|
||||
.form-group.checkbox-group {
|
||||
grid-column: 3 / 4;
|
||||
align-self: end;
|
||||
margin-bottom: 0.8rem;
|
||||
order: 5;
|
||||
}
|
||||
.form-group.full-width { grid-column: 1 / -1; }
|
||||
.notes-textarea { resize: vertical; min-height: 100px; line-height: 1.5; }
|
||||
|
||||
.date-group { display: flex; gap: 1.5rem; }
|
||||
.date-input-pair { flex: 1; display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
/* Cambia de 3 columnas a 2 columnas para que Start y End llenen el espacio */
|
||||
.date-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr; /* Antes era 1fr 1fr 1fr */
|
||||
gap: 2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date-input-pair {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
flex-direction: row;
|
||||
@@ -276,4 +307,10 @@
|
||||
.btn-primary { order: 1; }
|
||||
.btn-secondary { order: 2; }
|
||||
.btn-danger { order: 3; margin-top: 0.5rem; }
|
||||
}
|
||||
|
||||
input[type="date"].form-input {
|
||||
display: block; /* Elimina el comportamiento flex */
|
||||
text-align: left; /* Asegura alineación a la izquierda */
|
||||
padding-top: 10px; /* Ajuste pequeño para centrar verticalmente si es necesario */
|
||||
}
|
||||
291
desktop/views/css/components/create-room.css
Normal file
291
desktop/views/css/components/create-room.css
Normal file
@@ -0,0 +1,291 @@
|
||||
/* create-room.css */
|
||||
.cr-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10001; /* Mayor que el navbar */
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.cr-modal-overlay.show {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cr-modal-content {
|
||||
background: rgba(20, 20, 20, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
max-width: 450px;
|
||||
width: 90%;
|
||||
position: relative;
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.cr-modal-overlay.show .cr-modal-content {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.cr-modal-close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.cr-modal-close:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cr-modal-title {
|
||||
margin: 0 0 24px 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: white;
|
||||
background: linear-gradient(to right, #fff, #a78bfa);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.cr-form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.cr-form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.cr-input {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.cr-input:focus {
|
||||
border-color: #8b5cf6; /* Tu color primario */
|
||||
}
|
||||
|
||||
.cr-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.cr-btn-cancel {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cr-btn-confirm {
|
||||
background: #8b5cf6;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cr-btn-confirm:hover {
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
.cr-btn-confirm:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Add to room.css */
|
||||
.modal-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.btn-icon-small {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.config-grid {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.config-poster img {
|
||||
width: 140px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.config-form {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-select, .form-input {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.form-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-group.half {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.config-grid { flex-direction: column; align-items: center; }
|
||||
.config-poster img { width: 100px; }
|
||||
}
|
||||
|
||||
.modal-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.btn-icon-small {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-icon-small:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
/* Config Grid Layout */
|
||||
.config-grid {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.config-poster img {
|
||||
width: 140px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.config-form {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
.form-select, .form-input {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.form-select:focus, .form-input:focus {
|
||||
border-color: var(--brand-color);
|
||||
}
|
||||
|
||||
.form-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Form Select specific for dark mode options */
|
||||
.form-select option {
|
||||
background: #1a1a1a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-group.half {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Mobile Responsiveness */
|
||||
@media (max-width: 600px) {
|
||||
.config-grid { flex-direction: column; align-items: center; }
|
||||
.config-poster img { width: 100px; }
|
||||
.form-row { flex-direction: column; gap: 0; }
|
||||
}
|
||||
282
desktop/views/css/components/match-modal.css
Normal file
282
desktop/views/css/components/match-modal.css
Normal file
@@ -0,0 +1,282 @@
|
||||
/* match-modal.css */
|
||||
:root {
|
||||
--match-primary: #8b5cf6;
|
||||
--match-bg: rgba(15, 15, 15, 0.95);
|
||||
--match-border: rgba(255, 255, 255, 0.1);
|
||||
--match-input-bg: rgba(0, 0, 0, 0.4);
|
||||
--match-text-muted: #a1a1aa;
|
||||
}
|
||||
|
||||
/* Overlay Global con efecto Glass */
|
||||
.match-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.match-modal-overlay.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Contenido del Modal */
|
||||
.match-modal-content {
|
||||
background: var(--match-bg);
|
||||
border: 1px solid var(--match-border);
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 700px; /* Un poco más ancho para mejor lectura */
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow:
|
||||
0 25px 80px rgba(0, 0, 0, 0.9),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
transform: scale(0.95) translateY(10px);
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.match-modal-overlay.active .match-modal-content {
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
/* Header Estilizado */
|
||||
.match-header {
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid var(--match-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: linear-gradient(to bottom, rgba(255,255,255,0.02), transparent);
|
||||
}
|
||||
|
||||
.match-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
letter-spacing: -0.02em;
|
||||
/* Efecto gradiente en el texto como en anilist-modal */
|
||||
background: linear-gradient(to right, #fff, #aaa);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
#match-provider-badge {
|
||||
opacity: 0.6;
|
||||
font-size: 0.8em;
|
||||
margin-left: 8px;
|
||||
font-weight: normal;
|
||||
-webkit-text-fill-color: #888; /* Resetear el gradiente para el badge */
|
||||
}
|
||||
|
||||
.match-close-btn {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--match-border);
|
||||
color: #ccc;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.match-close-btn:hover {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Buscador y Controles */
|
||||
.match-search-container {
|
||||
padding: 1.5rem 2rem 1rem 2rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
background: rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.match-input {
|
||||
flex: 1;
|
||||
background: var(--match-input-bg);
|
||||
border: 1px solid var(--match-border);
|
||||
color: white;
|
||||
padding: 1rem 1.2rem;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.match-input:focus {
|
||||
background: rgba(0,0,0,0.6);
|
||||
border-color: var(--match-primary);
|
||||
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.15);
|
||||
}
|
||||
|
||||
.match-search-btn {
|
||||
padding: 0 1.8rem;
|
||||
background: var(--match-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.match-search-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(139, 92, 246, 0.4);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* Lista de Resultados */
|
||||
.match-results-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 2rem 2rem 2rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.2) transparent;
|
||||
}
|
||||
|
||||
.match-results-body::-webkit-scrollbar { width: 6px; }
|
||||
.match-results-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 10px; }
|
||||
|
||||
.match-list-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Tarjeta de Resultado Mejorada */
|
||||
.match-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.2rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 0.8rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.match-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.match-poster {
|
||||
width: 50px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: #222;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.match-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.match-item-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.match-item-meta {
|
||||
font-size: 0.85rem;
|
||||
color: var(--match-text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Botón de enlace externo refinado */
|
||||
.match-link-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
transition: all 0.2s;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.match-link-btn:hover {
|
||||
background: var(--match-primary);
|
||||
color: white;
|
||||
border-color: var(--match-primary);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 15px rgba(139, 92, 246, 0.4);
|
||||
}
|
||||
|
||||
/* Mensajes y Spinner */
|
||||
.match-spinner {
|
||||
width: 40px; height: 40px;
|
||||
border: 3px solid rgba(255,255,255,0.1);
|
||||
border-top-color: var(--match-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 3rem auto;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.match-msg {
|
||||
text-align: center;
|
||||
color: var(--match-text-muted);
|
||||
margin-top: 3rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.match-msg.error { color: #ef4444; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.match-modal-content {
|
||||
height: 100%;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
.match-search-container {
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.match-search-btn {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,11 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 3rem;
|
||||
background: linear-gradient(to bottom, rgba(9, 9, 11, 0.9) 0%, rgba(9, 9, 11, 0) 100%);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(9, 9, 11, 0.9) 0%,
|
||||
rgba(9, 9, 11, 0) 100%
|
||||
);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
@@ -211,7 +215,9 @@
|
||||
min-width: 260px;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(139, 92, 246, 0.1);
|
||||
box-shadow:
|
||||
0 20px 40px rgba(0, 0, 0, 0.6),
|
||||
0 0 0 1px rgba(139, 92, 246, 0.1);
|
||||
z-index: 9999;
|
||||
overflow: hidden;
|
||||
animation: dropdownSlide 0.2s ease-out;
|
||||
@@ -415,4 +421,178 @@
|
||||
.search-results::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
z-index: 2001;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
.navbar {
|
||||
height: 70px;
|
||||
padding: 0 1.5rem;
|
||||
background: rgba(9, 9, 11, 0.8);
|
||||
backdrop-filter: blur(15px);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.nav-brand span { display: none; }
|
||||
.brand-icon { width: 40px; height: 40px; }
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: flex;
|
||||
}
|
||||
.mobile-menu-toggle:active { transform: scale(0.95); }
|
||||
|
||||
.nav-center {
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: -100%;
|
||||
width: 85%;
|
||||
max-width: 320px;
|
||||
height: 100vh;
|
||||
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
|
||||
background: linear-gradient(160deg, rgba(20, 20, 25, 0.98) 0%, rgba(10, 10, 12, 0.99) 100%);
|
||||
box-shadow: -10px 0 40px rgba(0, 0, 0, 0.6);
|
||||
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
gap: 1rem;
|
||||
z-index: 2000;
|
||||
|
||||
transition: right 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.nav-center.open {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 1.2rem 1.5rem;
|
||||
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-transform: uppercase;
|
||||
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||
border-radius: 16px;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.5, 1);
|
||||
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.nav-button.active {
|
||||
background: linear-gradient(90deg, rgba(139, 92, 246, 0.2) 0%, transparent 100%);
|
||||
border-color: rgba(139, 92, 246, 0.4);
|
||||
color: white;
|
||||
box-shadow: 0 4px 20px rgba(139, 92, 246, 0.15);
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.nav-center.open .nav-button {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.nav-button.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
height: 50%;
|
||||
width: 3px;
|
||||
background: #8b5cf6;
|
||||
border-radius: 0 4px 4px 0;
|
||||
box-shadow: 0 0 10px #8b5cf6;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
margin-left: 10px;
|
||||
margin-right: auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
padding: 0;
|
||||
pointer-events: none; /* No clickable cuando está oculto */
|
||||
position: absolute;
|
||||
left: 0;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 2005;
|
||||
}
|
||||
|
||||
/* Estado expandido al tocar */
|
||||
.search-wrapper:focus-within {
|
||||
width: 220px; /* Ancho suficiente para escribir */
|
||||
}
|
||||
|
||||
.search-wrapper:focus-within .search-input {
|
||||
opacity: 1;
|
||||
width: 220px;
|
||||
padding: 0.6rem 1rem 0.6rem 2.5rem;
|
||||
background: #18181b;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 99px;
|
||||
pointer-events: auto; /* Permitir escritura */
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.search-wrapper.active-mobile .search-input {
|
||||
opacity: 1;
|
||||
width: 220px;
|
||||
padding: 0.6rem 1rem 0.6rem 2.5rem;
|
||||
background: #18181b;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Aseguramos que el icono siempre esté arriba del input expandido */
|
||||
.search-icon {
|
||||
z-index: 2006;
|
||||
}
|
||||
|
||||
/* Evitar que el menú lateral tape el buscador si ambos intentan estar abiertos */
|
||||
.nav-center.open {
|
||||
z-index: 1999;
|
||||
}
|
||||
}
|
||||
@@ -198,3 +198,13 @@ body {
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html.electron #room-view {
|
||||
height: calc(100vh - var(--titlebar-height));
|
||||
margin-top: var(--titlebar-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html.electron #room-view .room-layout {
|
||||
height: 100%;
|
||||
}
|
||||
96
desktop/views/css/home.css
Normal file
96
desktop/views/css/home.css
Normal file
@@ -0,0 +1,96 @@
|
||||
/* home.css - Estilo Streaming Profesional (Sin modificar cards/flechas) */
|
||||
|
||||
/* 1. Fondo y Fusión del Layout */
|
||||
body {
|
||||
background-color: #0b0b0b; /* Fondo oscuro profundo */
|
||||
}
|
||||
|
||||
/* Efecto de superposición: El contenido sube sobre el banner */
|
||||
main#online-content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
margin-top: -12vh; /* Sube el contenido visualmente hacia el banner */
|
||||
padding-top: 3rem;
|
||||
/* Degradado para que el corte no sea brusco */
|
||||
background: linear-gradient(to bottom, transparent 0%, #0b0b0b 15%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
|
||||
/* Estilizamos los botones del Hero para igualar a anime.css */
|
||||
.hero-buttons .btn-primary {
|
||||
background: white;
|
||||
color: black;
|
||||
font-weight: 800;
|
||||
border: none;
|
||||
padding: 0.9rem 2.4rem;
|
||||
border-radius: 8px; /* Bordes menos redondos, más modernos */
|
||||
text-transform: uppercase;
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.hero-buttons .btn-blur {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
font-weight: 700;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 3. Títulos de Secciones */
|
||||
/* Añadimos un acento de color a la izquierda */
|
||||
.section-header {
|
||||
border-left: 5px solid var(--color-primary); /* Línea morada vertical */
|
||||
padding-left: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* 4. Botón Flotante (Modo Biblioteca) */
|
||||
/* Estilo Glassmorphism sin cambiar su posición */
|
||||
.library-mode-btn {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
background: rgba(20, 20, 23, 0.6);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
border-radius: 16px; /* Cuadrado redondeado en vez de círculo total */
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.library-mode-btn svg {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.library-mode-btn:hover {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 30px rgba(139, 92, 246, 0.4); /* Glow morado */
|
||||
}
|
||||
|
||||
.library-mode-btn:hover svg {
|
||||
opacity: 1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Ajustes Responsive para que el overlap no rompa el móvil */
|
||||
@media (max-width: 768px) {
|
||||
main#online-content {
|
||||
margin-top: -5vh; /* Menos subida en móvil */
|
||||
background: linear-gradient(to bottom, transparent 0%, #0b0b0b 10%);
|
||||
}
|
||||
}
|
||||
@@ -283,6 +283,12 @@
|
||||
background-size: 1rem;
|
||||
}
|
||||
|
||||
.minimal-select option {
|
||||
background-color: var(--color-bg-elevated, #18181b);
|
||||
color: #e4e4e7;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.minimal-select:hover { background: rgba(255,255,255,0.1); color: white; }
|
||||
.minimal-select:focus { border-color: var(--color-primary, #8b5cf6); color: white; }
|
||||
|
||||
@@ -704,4 +710,235 @@
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* =========================================
|
||||
8. CUSTOM MODAL (Manual Match)
|
||||
========================================= */
|
||||
.custom-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.custom-modal-overlay.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.custom-modal-content {
|
||||
background: var(--color-bg-elevated, #18181b);
|
||||
border: 1px solid var(--border-medium, rgba(255,255,255,0.1));
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: scale(1);
|
||||
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.custom-modal-overlay.hidden .custom-modal-content {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 { margin: 0; font-size: 1.25rem; }
|
||||
|
||||
.close-modal-btn {
|
||||
background: transparent; border: none;
|
||||
color: var(--color-text-muted); font-size: 1.5rem;
|
||||
cursor: pointer; line-height: 1;
|
||||
}
|
||||
.close-modal-btn:hover { color: white; }
|
||||
|
||||
.modal-body { padding: 1.5rem; }
|
||||
|
||||
.modal-description {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem; margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.path-display {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
padding: 0.8rem;
|
||||
border-radius: 6px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #e4e4e7;
|
||||
word-break: break-all;
|
||||
margin-top: 0.5rem;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.input-group { margin-bottom: 1.5rem; }
|
||||
.input-group label {
|
||||
display: block; font-size: 0.85rem;
|
||||
font-weight: 600; color: var(--color-text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
background: rgba(0,0,0,0.2);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
DOWNLOAD MANAGER (COMPACTO)
|
||||
========================================= */
|
||||
.downloads-monitor {
|
||||
background: var(--color-bg-elevated, #18181b);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 2rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.monitor-header {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 0.8rem 1.2rem; /* Header más delgado */
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.monitor-title h3 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: white;
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.monitor-list {
|
||||
padding: 0;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* --- ITEM COMPACTO --- */
|
||||
.dl-item.compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 1.2rem; /* Mucho menos padding vertical */
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
background: transparent;
|
||||
transition: background 0.2s;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.dl-item.compact:last-child { border-bottom: none; }
|
||||
.dl-item.compact:hover { background: rgba(255,255,255,0.02); }
|
||||
|
||||
/* Columna Izquierda: Nombres */
|
||||
.dl-left-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex: 1; /* Ocupa el espacio disponible */
|
||||
min-width: 0; /* Permite truncar texto */
|
||||
}
|
||||
|
||||
.dl-filename {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #f4f4f5;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: 'Consolas', monospace;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.dl-folder {
|
||||
font-size: 0.75rem;
|
||||
color: #71717a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dl-folder svg {
|
||||
color: var(--color-primary, #8b5cf6);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Columna Derecha: Progreso y Stats */
|
||||
.dl-right-col {
|
||||
width: 40%; /* Ancho fijo para la zona de progreso */
|
||||
max-width: 300px;
|
||||
min-width: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dl-meta-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.7rem;
|
||||
color: #a1a1aa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dl-status-text.completed { color: #4ade80; }
|
||||
.dl-status-text.failed { color: #ef4444; }
|
||||
|
||||
/* Barra de progreso más fina */
|
||||
.dl-progress-track {
|
||||
width: 100%;
|
||||
height: 4px; /* Barra delgada */
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dl-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-primary, #8b5cf6);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease-out;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.dl-progress-fill.completed { background: #4ade80; }
|
||||
.dl-progress-fill.failed { background: #ef4444; }
|
||||
|
||||
/* Responsive para móviles */
|
||||
@media (max-width: 600px) {
|
||||
.dl-item.compact {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.dl-right-col { width: 100%; max-width: none; }
|
||||
}
|
||||
853
desktop/views/css/room.css
Normal file
853
desktop/views/css/room.css
Normal file
@@ -0,0 +1,853 @@
|
||||
/* =========================================
|
||||
1. VARIABLES & RESET
|
||||
========================================= */
|
||||
:root {
|
||||
--brand-color: #8b5cf6;
|
||||
--brand-gradient: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);
|
||||
--glass-bg: rgba(20, 20, 20, 0.6);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--glass-blur: blur(12px);
|
||||
--text-main: #ffffff;
|
||||
--text-muted: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
2. UTILITIES & ANIMATIONS
|
||||
========================================= */
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: var(--brand-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes slideUp { from { transform: translateX(-50%) translateY(20px); opacity: 0; } to { transform: translateX(-50%) translateY(0); opacity: 1; } }
|
||||
@keyframes slideDown { from { transform: translateX(-50%) translateY(-20px); opacity: 0; } to { transform: translateX(-50%) translateY(0); opacity: 1; } }
|
||||
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
|
||||
@keyframes scaleIn { from { transform: translate(-50%, -50%) scale(0.8); opacity: 0; } to { transform: translate(-50%, -50%) scale(1); opacity: 1; } }
|
||||
@keyframes toastSlideIn { from { opacity: 0; transform: translateX(-20px); } to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes toastFadeOut { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-10px); visibility: hidden; } }
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
|
||||
70% { transform: scale(1); box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
|
||||
100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
3. BUTTONS & INPUTS
|
||||
========================================= */
|
||||
.btn-icon-glass, .btn-icon-small, .modal-close {
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
color: #eee;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-icon-glass { width: 36px; height: 36px; backdrop-filter: blur(8px); }
|
||||
.btn-icon-small { padding: 6px; }
|
||||
.modal-close { width: 32px; height: 32px; position: absolute; top: 20px; right: 20px; background: rgba(255, 255, 255, 0.1); }
|
||||
|
||||
.btn-icon-glass:hover, .btn-icon-small:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-1px);
|
||||
color: white;
|
||||
}
|
||||
.modal-close:hover { background: rgba(255, 255, 255, 0.3); transform: rotate(90deg); }
|
||||
|
||||
.btn-glass-primary {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: rgba(139, 92, 246, 0.25);
|
||||
border: 1px solid rgba(139, 92, 246, 0.4);
|
||||
color: white;
|
||||
padding: 0 16px; height: 36px;
|
||||
border-radius: 10px; font-weight: 600; font-size: 0.9rem;
|
||||
cursor: pointer; backdrop-filter: blur(8px);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-glass-primary:hover {
|
||||
background: rgba(139, 92, 246, 0.4);
|
||||
box-shadow: 0 0 15px rgba(139, 92, 246, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-confirm, .btn-primary {
|
||||
background: var(--brand-color);
|
||||
border: none; color: white;
|
||||
padding: 10px 24px; border-radius: 10px;
|
||||
font-weight: 700; cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
}
|
||||
.btn-confirm:hover, .btn-primary:hover { background: #7c3aed; transform: translateY(-2px); }
|
||||
.btn-confirm:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||
.btn-confirm.full-width { width: 100%; }
|
||||
|
||||
.btn-cancel {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
padding: 10px 20px; border-radius: 8px;
|
||||
cursor: pointer; font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-cancel:hover { background: rgba(255, 255, 255, 0.1); color: white; }
|
||||
|
||||
.btn-quick-action {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
height: 34px; padding: 0 12px;
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
border-radius: 8px; color: #d8b4fe;
|
||||
font-size: 0.85rem; font-weight: 600;
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.btn-quick-action:hover {
|
||||
background: var(--brand-color); border-color: var(--brand-color);
|
||||
color: white; transform: translateY(-1px);
|
||||
}
|
||||
#copy-invite-btn { transition: all 0.3s ease; }
|
||||
#copy-invite-btn:hover { transform: scale(1.05); }
|
||||
#copy-invite-btn:active { transform: scale(0.95); }
|
||||
|
||||
/* Inputs */
|
||||
input[type="text"], input[type="password"], input[type="number"], .form-input {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: white; padding: 12px 16px;
|
||||
border-radius: 10px; font-size: 1rem;
|
||||
outline: none; transition: border-color 0.2s;
|
||||
}
|
||||
input:focus, .form-input:focus { border-color: var(--brand-color); background: rgba(255, 255, 255, 0.08); }
|
||||
input[type=number]::-webkit-inner-spin-button, input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
input[type=number] { -moz-appearance: textfield; }
|
||||
|
||||
/* Custom Select Wrappers */
|
||||
.quick-select-wrapper {
|
||||
position: relative; display: flex; align-items: center;
|
||||
height: 34px; background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 8px; transition: all 0.2s;
|
||||
padding: 0 8px; min-width: 110px;
|
||||
}
|
||||
.quick-select-wrapper:hover { background: rgba(255, 255, 255, 0.08); }
|
||||
.quick-select-wrapper svg { pointer-events: none; color: var(--text-muted); margin-right: 6px; flex-shrink: 0; }
|
||||
.select-arrow { font-size: 0.6rem; color: rgba(255, 255, 255, 0.4); pointer-events: none; margin-left: 4px; }
|
||||
|
||||
.quick-select, .glass-select-sm {
|
||||
appearance: none; -webkit-appearance: none;
|
||||
background: transparent; border: none;
|
||||
color: #fff; width: 100%; height: 100%;
|
||||
cursor: pointer; outline: none; position: relative; z-index: 2;
|
||||
}
|
||||
.glass-select-sm {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 0 32px 0 12px; height: 32px; line-height: 30px;
|
||||
border-radius: 8px; font-size: 0.85rem; font-weight: 600;
|
||||
max-width: 140px; white-space: nowrap; text-overflow: ellipsis;
|
||||
background-image: url("data:image/svg+xml,..."); /* SVG truncated for brevity */
|
||||
background-repeat: no-repeat; background-position: right 8px center; background-size: 14px;
|
||||
}
|
||||
.quick-select option, .glass-select-sm option { background: #1a1a1a; color: #eee; }
|
||||
|
||||
/* =========================================
|
||||
4. LAYOUT
|
||||
========================================= */
|
||||
.room-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
height: 100vh; overflow: hidden;
|
||||
transition: grid-template-columns 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.room-layout.chat-hidden { grid-template-columns: 1fr 0px !important; }
|
||||
|
||||
/* Video Area */
|
||||
.video-area {
|
||||
display: flex; flex-direction: column;
|
||||
background: #000; flex: 1; min-height: 0;
|
||||
overflow: hidden; position: relative;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
5. HEADER
|
||||
========================================= */
|
||||
.room-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0 24px; height: 64px;
|
||||
background: rgba(10, 10, 10, 0.85); backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
z-index: 20; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left, .header-right { display: flex; align-items: center; gap: 16px; flex: 1; }
|
||||
.header-right { justify-content: flex-end; }
|
||||
.header-center { flex: 2; display: flex; justify-content: center; z-index: 50; }
|
||||
|
||||
.room-info { display: flex; flex-direction: column; justify-content: center; line-height: 1.2; }
|
||||
#room-name { margin: 0; font-size: 1rem; font-weight: 700; color: white; letter-spacing: -0.01em; }
|
||||
.np-fade { display: flex; align-items: center; opacity: 0.7; font-size: 0.8rem; gap: 6px; margin-top: 2px; }
|
||||
.np-title { color: #ccc; font-weight: 500; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.np-sep { color: #555; }
|
||||
.np-badge { background: rgba(255, 255, 255, 0.1); color: #fff; padding: 1px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 700; }
|
||||
|
||||
/* Header Controls */
|
||||
.quick-controls-group {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
background: rgba(15, 15, 15, 0.85);
|
||||
backdrop-filter: blur(16px);
|
||||
padding: 4px; height: 44px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.quick-controls-group:hover { border-color: rgba(255, 255, 255, 0.2); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); }
|
||||
|
||||
.control-divider { width: 1px; height: 20px; background: rgba(255, 255, 255, 0.1); margin: 0 4px; }
|
||||
|
||||
/* Sub/Dub Toggle */
|
||||
.sd-toggle.small {
|
||||
height: 34px; min-width: 86px;
|
||||
background: transparent; border: none;
|
||||
display: flex; position: relative; cursor: pointer; box-sizing: border-box;
|
||||
padding: 2px;
|
||||
}
|
||||
.sd-toggle.small .sd-bg {
|
||||
position: absolute; top: 2px; left: 2px;
|
||||
width: calc(50% - 2px); height: calc(100% - 4px);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 6px;
|
||||
transition: transform 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
}
|
||||
.sd-toggle[data-state="dub"] .sd-bg { transform: translateX(100%); }
|
||||
.sd-toggle.small .sd-option {
|
||||
flex: 1; z-index: 2;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.75rem; font-weight: 700;
|
||||
color: rgba(255,255,255,0.4);
|
||||
transition: color 0.2s; user-select: none;
|
||||
}
|
||||
.sd-toggle.small .sd-option.active { color: #fff; }
|
||||
|
||||
.viewers-pill {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
background: rgba(0,0,0,0.3); padding: 0 10px;
|
||||
border-radius: 20px; border: 1px solid rgba(255,255,255,0.05);
|
||||
font-size: 0.8rem; color: #aaa; height: 32px;
|
||||
}
|
||||
|
||||
#toggle-chat-btn { position: relative; }
|
||||
#toggle-chat-btn.has-unread::after {
|
||||
content: ''; position: absolute; top: 2px; right: 2px;
|
||||
width: 10px; height: 10px; background-color: #ef4444;
|
||||
border: 2px solid #1a1a1a; border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
6. PLAYER
|
||||
========================================= */
|
||||
.player-wrapper {
|
||||
display: flex !important; flex-direction: column; flex: 1;
|
||||
width: 100%; height: auto !important; min-height: 0;
|
||||
position: relative !important; z-index: 1 !important;
|
||||
background: transparent !important; overflow: hidden;
|
||||
}
|
||||
.player-container { width: 100%; height: 100%; display: flex; flex-direction: column; position: relative; }
|
||||
.video-frame {
|
||||
flex: 1; min-height: 0; position: relative;
|
||||
background: #000; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
#player { width: 100%; height: 100%; max-height: 100%; }
|
||||
#subtitles-canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
|
||||
|
||||
.custom-controls {
|
||||
position: absolute; bottom: 0; left: 0; width: 100%; z-index: 60;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0) 100%);
|
||||
padding: 20px 10px 10px;
|
||||
}
|
||||
.controls-left { display: flex; align-items: center; gap: 10px; }
|
||||
.time-display { white-space: nowrap; font-variant-numeric: tabular-nums; font-size: 0.9rem; color: #ddd; min-width: fit-content; }
|
||||
|
||||
/* Hidden Legacy Buttons */
|
||||
#download-btn, #manual-match-btn, #server-select, #extension-select, #sd-toggle, .side-nav-btn { display: none !important; }
|
||||
|
||||
/* Settings Panel */
|
||||
.settings-panel {
|
||||
position: absolute; bottom: 70px; right: 20px; z-index: 1000;
|
||||
max-height: 60vh; overflow-y: auto;
|
||||
background: rgba(15, 15, 15, 0.95);
|
||||
border: 1px solid var(--glass-border); border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Video Toasts */
|
||||
.video-toast-container {
|
||||
position: absolute; bottom: 100px; left: 20px; z-index: 80;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
pointer-events: none; max-width: 400px;
|
||||
}
|
||||
.video-toast {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: rgba(15, 15, 15, 0.85); backdrop-filter: blur(8px);
|
||||
padding: 8px 12px; border-radius: 8px;
|
||||
border-left: 3px solid var(--brand-color);
|
||||
color: white; font-size: 0.9rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
animation: toastSlideIn 0.3s ease forwards, toastFadeOut 0.5s ease 4.5s forwards;
|
||||
pointer-events: auto; opacity: 0;
|
||||
}
|
||||
.toast-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
|
||||
.toast-content { display: flex; flex-direction: column; line-height: 1.2; }
|
||||
.toast-user { font-weight: 700; font-size: 0.8rem; color: #a78bfa; }
|
||||
.toast-msg { color: #eee; margin-top: 2px; }
|
||||
.video-toast.system-toast {
|
||||
border-left-color: #9ca3af; background: rgba(20, 20, 20, 0.7);
|
||||
justify-content: center; padding: 6px 12px; min-height: auto;
|
||||
}
|
||||
.video-toast.system-toast .toast-msg { font-size: 0.85rem; font-style: italic; color: rgba(255, 255, 255, 0.8); margin: 0; }
|
||||
|
||||
/* =========================================
|
||||
7. CHAT SIDEBAR
|
||||
========================================= */
|
||||
.chat-sidebar {
|
||||
display: flex; flex-direction: column; height: 100%;
|
||||
background: rgba(15, 15, 15, 0.95);
|
||||
border-left: 1px solid var(--glass-border); overflow: hidden;
|
||||
}
|
||||
.room-layout.chat-hidden .chat-sidebar { opacity: 0; pointer-events: none; border-left: none; }
|
||||
|
||||
.chat-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
.chat-header h3 { margin: 0; font-size: 1.1rem; font-weight: 700; color: white; }
|
||||
|
||||
.sidebar-tabs {
|
||||
display: flex; width: 100%; flex: 0 0 auto; height: 50px;
|
||||
border-bottom: 1px solid var(--glass-border); background: rgba(0,0,0,0.2);
|
||||
}
|
||||
.tab-btn {
|
||||
flex: 1; height: 100%; padding: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: transparent; border: none; border-bottom: 2px solid transparent;
|
||||
color: var(--text-muted); font-weight: 600; cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab-btn:hover { background: rgba(255,255,255,0.05); color: white; }
|
||||
.tab-btn.active { border-bottom-color: var(--brand-color); color: white; background: rgba(255,255,255,0.02); }
|
||||
|
||||
.tab-content { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; }
|
||||
|
||||
/* Users List */
|
||||
.users-list {
|
||||
padding: 12px; border-bottom: 1px solid var(--glass-border);
|
||||
max-height: 200px; overflow-y: auto; background: rgba(0,0,0,0.2);
|
||||
}
|
||||
.user-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 8px; margin-bottom: 4px; }
|
||||
.user-item:hover { background: rgba(255, 255, 255, 0.05); }
|
||||
.user-avatar {
|
||||
width: 32px; height: 32px; border-radius: 50%;
|
||||
background: var(--brand-gradient);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; color: white; font-size: 0.8rem;
|
||||
}
|
||||
.user-avatar img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
|
||||
.user-name { flex: 1; font-size: 0.9rem; color: white; }
|
||||
.user-badge { font-size: 0.7rem; background: var(--brand-color); color: white; padding: 2px 8px; border-radius: 4px; font-weight: 600; }
|
||||
|
||||
/* Chat Messages */
|
||||
.chat-messages {
|
||||
flex: 1; overflow-y: auto; padding: 16px;
|
||||
display: flex; flex-direction: column; gap: 12px; min-height: 0;
|
||||
}
|
||||
.chat-message { display: flex; gap: 10px; }
|
||||
.chat-message.system { justify-content: center; margin: 8px 0; }
|
||||
.chat-message.system .message-content {
|
||||
background: rgba(255, 255, 255, 0.05); color: var(--text-muted);
|
||||
font-size: 0.8rem; text-align: center; padding: 4px 12px; border-radius: 12px;
|
||||
}
|
||||
.message-avatar {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: var(--brand-gradient);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; color: white; font-size: 0.9rem; flex-shrink: 0;
|
||||
}
|
||||
.message-body { flex: 1; min-width: 0; }
|
||||
.message-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.message-username { font-weight: 600; color: white; font-size: 0.9rem; }
|
||||
.message-time { font-size: 0.75rem; color: rgba(255, 255, 255, 0.4); }
|
||||
.message-content { color: rgba(255, 255, 255, 0.9); font-size: 0.95rem; line-height: 1.4; word-wrap: break-word; }
|
||||
|
||||
.chat-input { display: flex; gap: 8px; padding: 16px; border-top: 1px solid var(--glass-border); }
|
||||
.chat-input input {
|
||||
flex: 1; background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--glass-border); color: white;
|
||||
padding: 12px 16px; border-radius: 10px; outline: none;
|
||||
}
|
||||
.chat-input button {
|
||||
background: var(--brand-color); border: none; color: white;
|
||||
padding: 0 16px; border-radius: 10px; cursor: pointer; transition: background 0.2s;
|
||||
}
|
||||
.chat-input button:hover { background: #7c3aed; }
|
||||
|
||||
/* =========================================
|
||||
8. QUEUE TAB
|
||||
========================================= */
|
||||
.queue-list {
|
||||
flex: 1; overflow-y: auto; padding: 10px;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.queue-item {
|
||||
display: flex; gap: 10px; padding: 10px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 8px; border: 1px solid transparent;
|
||||
position: relative; align-items: center; transition: transform 0.2s;
|
||||
}
|
||||
.queue-item:hover { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.1); }
|
||||
.q-img { width: 50px; height: 70px; object-fit: cover; border-radius: 4px; flex-shrink: 0; }
|
||||
.q-info { flex: 1; display: flex; flex-direction: column; justify-content: center; overflow: hidden; }
|
||||
.q-title { font-weight: 700; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.q-meta { font-size: 0.8rem; color: #aaa; }
|
||||
.q-actions { display: flex; flex-direction: column; gap: 4px; opacity: 0.7; transition: opacity 0.2s; }
|
||||
.queue-item:hover .q-actions { opacity: 1; }
|
||||
|
||||
.q-btn {
|
||||
background: rgba(255,255,255,0.1); border: none; color: white;
|
||||
width: 28px; height: 28px; border-radius: 6px; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; transition: all 0.2s;
|
||||
}
|
||||
.q-btn:hover { background: rgba(255,255,255,0.2); transform: scale(1.1); }
|
||||
.q-btn.play:hover { background: var(--brand-color); }
|
||||
.q-btn.remove:hover { background: #ff6b6b; }
|
||||
.badge { background: var(--brand-color); padding: 1px 6px; border-radius: 10px; font-size: 0.7rem; margin-left: 6px; }
|
||||
.queue-empty { text-align: center; color: #666; margin-top: 40px; font-style: italic; }
|
||||
|
||||
/* =========================================
|
||||
9. MODALS
|
||||
========================================= */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.8); backdrop-filter: blur(8px);
|
||||
display: none; align-items: center; justify-content: center;
|
||||
z-index: 10000; opacity: 0; transition: opacity 0.3s ease;
|
||||
}
|
||||
.modal-overlay.show { display: flex; opacity: 1; }
|
||||
|
||||
.modal-content {
|
||||
background: rgba(20, 20, 20, 0.95);
|
||||
border: 1px solid var(--glass-border); border-radius: 16px;
|
||||
padding: 32px; max-width: 500px; width: 90%;
|
||||
position: relative; transform: scale(0.9); transition: transform 0.3s ease;
|
||||
}
|
||||
.modal-overlay.show .modal-content { transform: scale(1); }
|
||||
.modal-title { margin: 0 0 24px 0; font-size: 1.5rem; font-weight: 800; color: white; }
|
||||
.modal-header-row { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; }
|
||||
.modal-header-row .modal-title { margin: 0; }
|
||||
|
||||
.form-group { margin-bottom: 20px; }
|
||||
.form-group label { display: block; margin-bottom: 8px; font-weight: 600; color: white; }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; }
|
||||
|
||||
/* Join Info */
|
||||
.join-host-info { display: flex; flex-direction: column; align-items: center; margin-bottom: 24px; animation: fadeIn 0.5s ease; }
|
||||
.join-avatar-container {
|
||||
width: 80px; height: 80px; border-radius: 50%; padding: 3px;
|
||||
background: var(--brand-gradient); margin-bottom: 12px;
|
||||
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.4);
|
||||
}
|
||||
.join-avatar-container img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; border: 3px solid #1a1a1a; background: #2a2a2a; }
|
||||
.join-text { font-size: 1.1rem; color: white; font-weight: 500; text-align: center; margin: 0; }
|
||||
.join-text span { font-weight: 700; color: #a78bfa; }
|
||||
|
||||
/* Search Content */
|
||||
.anime-search-content { max-width: 800px; max-height: 85vh; display: flex; flex-direction: column; }
|
||||
.search-bar { display: flex; gap: 12px; margin-bottom: 20px; }
|
||||
.search-bar button {
|
||||
background: var(--brand-color); border: none; color: white;
|
||||
padding: 12px 24px; border-radius: 10px; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.anime-results {
|
||||
flex: 1; overflow-y: auto; display: flex; flex-direction: column;
|
||||
gap: 12px; min-height: 0; max-height: 60vh; padding-right: 8px;
|
||||
}
|
||||
.anime-result-item, .search-item {
|
||||
display: flex; gap: 16px; padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 12px;
|
||||
cursor: pointer; text-decoration: none; color: inherit; transition: all 0.2s;
|
||||
}
|
||||
.anime-result-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08); border-color: var(--brand-color);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
.search-poster { width: 60px; height: 85px; border-radius: 8px; object-fit: cover; flex-shrink: 0; }
|
||||
.search-info { flex: 1; display: flex; flex-direction: column; justify-content: center; }
|
||||
.search-title { font-weight: 700; color: white; margin-bottom: 4px; font-size: 1rem; }
|
||||
.search-meta { font-size: 0.85rem; color: var(--text-muted); }
|
||||
|
||||
/* Config Layout */
|
||||
.config-layout { display: flex; gap: 24px; margin-top: 20px; }
|
||||
.config-sidebar { width: 140px; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
||||
.config-cover {
|
||||
width: 100%; aspect-ratio: 2/3; object-fit: cover;
|
||||
border-radius: 12px; box-shadow: 0 8px 20px rgba(0,0,0,0.4);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
.config-main { flex: 1; display: flex; flex-direction: column; gap: 20px; }
|
||||
.cfg-section-title {
|
||||
font-size: 0.85rem; text-transform: uppercase;
|
||||
letter-spacing: 1px; color: rgba(255,255,255,0.5);
|
||||
margin-bottom: 8px; font-weight: 700;
|
||||
}
|
||||
|
||||
/* Chips & Toggles in Modal */
|
||||
.chips-grid { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.chip {
|
||||
padding: 8px 16px; background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px;
|
||||
color: #ccc; font-size: 0.9rem; cursor: pointer;
|
||||
transition: all 0.2s; user-select: none;
|
||||
}
|
||||
.chip:hover { background: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.3); transform: translateY(-2px); }
|
||||
.chip.active {
|
||||
background: var(--brand-color); border-color: var(--brand-color);
|
||||
color: white; box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
.cat-toggle { display: flex; background: rgba(0,0,0,0.3); padding: 4px; border-radius: 10px; width: fit-content; }
|
||||
.cat-opt {
|
||||
padding: 6px 16px; border-radius: 8px; font-size: 0.85rem; color: #888;
|
||||
cursor: pointer; transition: all 0.2s; font-weight: 600;
|
||||
}
|
||||
.cat-opt.active { background: rgba(255,255,255,0.15); color: white; }
|
||||
.cat-opt.disabled { opacity: 0.3; pointer-events: none; text-decoration: line-through; }
|
||||
|
||||
/* Episodes Grid */
|
||||
.episodes-grid-container {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(50px, 1fr));
|
||||
gap: 8px; max-height: 280px; overflow-y: auto; padding: 10px;
|
||||
background: rgba(0, 0, 0, 0.2); border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.episodes-grid-container::-webkit-scrollbar { width: 4px; }
|
||||
.episodes-grid-container::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); }
|
||||
|
||||
.ep-grid-btn {
|
||||
background: rgba(255,255,255,0.03); border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #ccc; padding: 8px 0; border-radius: 6px; text-align: center;
|
||||
font-size: 0.9rem; font-weight: 600; cursor: pointer;
|
||||
transition: all 0.2s; user-select: none;
|
||||
}
|
||||
.ep-grid-btn:hover { background: white; color: black; transform: translateY(-2px); }
|
||||
.ep-grid-btn.selected, .ep-grid-btn.active {
|
||||
background: var(--brand-color); color: white;
|
||||
border-color: var(--brand-color); box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.btn-text-tiny {
|
||||
background: transparent; border: none; color: var(--brand-color);
|
||||
font-size: 0.75rem; font-weight: 700; cursor: pointer; padding: 2px 6px;
|
||||
}
|
||||
.btn-text-tiny:hover { text-decoration: underline; }
|
||||
|
||||
/* Manual Control */
|
||||
.ep-control {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: rgba(0, 0, 0, 0.4); border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px; padding: 4px; width: 100%; height: 48px;
|
||||
margin-top: 8px; box-shadow: inset 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
.ep-btn {
|
||||
width: 40px; height: 38px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255,255,255,0.05);
|
||||
color: white; border-radius: 8px; cursor: pointer; font-size: 1.2rem; transition: all 0.2s ease;
|
||||
}
|
||||
.ep-btn:hover { background: var(--brand-color); border-color: var(--brand-color); transform: translateY(-1px); }
|
||||
.ep-btn:active { transform: translateY(1px); }
|
||||
.ep-input {
|
||||
flex: 1; min-width: 0; background: transparent; border: none;
|
||||
color: white; text-align: center; font-size: 1.2rem; font-weight: 800;
|
||||
outline: none; font-family: monospace;
|
||||
}
|
||||
.grid-loader { width: 100%; padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.9rem; }
|
||||
|
||||
/* Modal Pagination */
|
||||
.modal-pagination {
|
||||
display: flex; justify-content: center; align-items: center; gap: 15px;
|
||||
margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.modal-page-btn {
|
||||
background: transparent; border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: white; width: 30px; height: 30px; border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.modal-page-btn:hover:not(:disabled) { background: white; color: black; }
|
||||
.modal-page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.modal-page-info { font-size: 0.85rem; color: #888; font-weight: 700; }
|
||||
|
||||
/* =========================================
|
||||
10. MEDIA QUERIES (Combined)
|
||||
========================================= */
|
||||
@media (max-width: 1200px) {
|
||||
.room-layout { grid-template-columns: 1fr; grid-template-rows: 1fr auto; }
|
||||
.room-layout.chat-hidden { grid-template-rows: 1fr 0px; }
|
||||
.chat-sidebar {
|
||||
height: 350px; border-left: none; border-top: 1px solid var(--glass-border);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.config-layout { flex-direction: column; }
|
||||
.config-sidebar { width: 100%; flex-direction: row; }
|
||||
.config-cover { width: 80px; }
|
||||
.ep-control { width: auto; flex: 1; }
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
PERMISSIONS & MODERATION
|
||||
========================================= */
|
||||
|
||||
.permissions-content {
|
||||
max-width: 700px;
|
||||
width: 90%;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.permissions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.permission-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.permission-card:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.user-info-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.user-avatar-small {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-gradient);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-avatar-small img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.user-name-text {
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.guest-badge {
|
||||
font-size: 0.7rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #aaa;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.permissions-toggles {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.perm-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.perm-toggle input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--brand-color);
|
||||
}
|
||||
|
||||
.perm-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.perm-toggle:hover .perm-label {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.perm-label svg {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ban-btn {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ban-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border-color: #ef4444;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.banned-ips-section {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.banned-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.banned-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.banned-ip {
|
||||
font-family: monospace;
|
||||
color: #ef4444;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.unban-btn {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||
color: #4ade80;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.unban-btn:hover {
|
||||
background: rgba(74, 222, 128, 0.1);
|
||||
border-color: #4ade80;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.permission-card {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.user-info-section {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.ban-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,29 @@
|
||||
.background-gradient {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at top, rgba(139, 92, 246, 0.15) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at bottom right, rgba(59, 130, 246, 0.1) 0%, transparent 50%);
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse at top,
|
||||
rgba(139, 92, 246, 0.15) 0%,
|
||||
transparent 60%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse at bottom right,
|
||||
rgba(59, 130, 246, 0.1) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
z-index: 0;
|
||||
animation: gradientShift 10s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.content-container {
|
||||
@@ -80,9 +94,8 @@
|
||||
box-shadow: 0 20px 40px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
/* Badge de contraseña protegida */
|
||||
.user-card.has-password::after {
|
||||
content: '🔒';
|
||||
content: "🔒";
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
@@ -116,7 +129,11 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(139, 92, 246, 0.1) 0%, rgba(59, 130, 246, 0.05) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(139, 92, 246, 0.1) 0%,
|
||||
rgba(59, 130, 246, 0.05) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.user-avatar-placeholder svg {
|
||||
@@ -131,7 +148,12 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 1rem 1.5rem;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.7) 50%, transparent 100%);
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(0, 0, 0, 0.95) 0%,
|
||||
rgba(0, 0, 0, 0.7) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
@@ -168,7 +190,9 @@
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s, background 0.2s;
|
||||
transition:
|
||||
opacity 0.3s,
|
||||
background 0.2s;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
@@ -205,7 +229,6 @@
|
||||
box-shadow: 0 10px 30px var(--color-primary-glow);
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
@@ -438,7 +461,6 @@ input[type="file"] {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Estilos para modal de password */
|
||||
.password-modal-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
@@ -461,7 +483,6 @@ input[type="file"] {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* ESTILOS PARA MODAL DE ACCIONES INDIVIDUALES */
|
||||
.manage-actions-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -673,10 +694,13 @@ input[type="file"] {
|
||||
border: 1px solid rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInDown {
|
||||
@@ -720,22 +744,121 @@ input[type="file"] {
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.page-wrapper {
|
||||
padding: 1rem;
|
||||
|
||||
align-items: flex-start;
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2.5rem;
|
||||
font-size: 2.2rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.users-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.user-config-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.user-config-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.btn-add-user {
|
||||
width: 100%;
|
||||
padding: 1.25rem;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.users-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.user-info {
|
||||
transform: translateY(0);
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(0, 0, 0, 0.95) 0%,
|
||||
rgba(0, 0, 0, 0.6) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.user-card:hover {
|
||||
transform: none;
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.user-card:active {
|
||||
transform: scale(0.97);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.modal {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: 20px 20px 0 0;
|
||||
max-height: 85vh;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="password"] {
|
||||
padding: 1.2rem;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 1.2rem;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/views/css/components/updateNotifier.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
||||
|
||||
<script src="/src/scripts/room-modal.js"></script>
|
||||
<script src="https://unpkg.com/masonry-layout@4/dist/masonry.pkgd.min.js" async></script>
|
||||
<link rel="stylesheet" href="/views/css/components/titlebar.css">
|
||||
<script src="/src/scripts/titlebar.js"></script>
|
||||
@@ -66,6 +66,7 @@
|
||||
</div>
|
||||
|
||||
<script src="/src/scripts/updateNotifier.js"></script>
|
||||
<script src="/src/scripts/room-modal.js"></script>
|
||||
<script src="/src/scripts/gallery/gallery.js"></script>
|
||||
<script src="/src/scripts/rpc-inapp.js"></script>
|
||||
<script src="/src/scripts/auth-guard.js"></script>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
||||
<link rel="stylesheet" href="/views/css/components/updateNotifier.css">
|
||||
<script src="/src/scripts/room-modal.js"></script>
|
||||
<script src="/src/scripts/titlebar.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -56,6 +57,7 @@
|
||||
</div>
|
||||
|
||||
<script src="/src/scripts/updateNotifier.js"></script>
|
||||
<script src="/src/scripts/room-modal.js"></script>
|
||||
<script src="/src/scripts/gallery/image.js"></script>
|
||||
<script src="/src/scripts/rpc-inapp.js"></script>
|
||||
<script src="/src/scripts/auth-guard.js"></script>
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
<link rel="stylesheet" href="/views/css/components/updateNotifier.css">
|
||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/views/css/components/titlebar.css">
|
||||
<link rel="stylesheet" href="/views/css/components/create-room.css"/>
|
||||
<script src="/src/scripts/titlebar.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="titlebar">
|
||||
@@ -66,9 +68,9 @@
|
||||
</main>
|
||||
|
||||
<script src="/src/scripts/utils/notification-utils.js"></script>
|
||||
<link rel="stylesheet" href="/views/css/components/create-room.css"/>
|
||||
<script src="/src/scripts/updateNotifier.js"></script>
|
||||
<script src="/src/scripts/marketplace.js"></script>
|
||||
<script src="/src/scripts/titlebar.js"></script>
|
||||
<script src="/src/scripts/rpc-inapp.js"></script>
|
||||
<script src="/src/scripts/auth-guard.js"></script>
|
||||
<script src="/src/scripts/settings.js"></script>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<link rel="stylesheet" href="/views/css/profile.css">
|
||||
<link rel="stylesheet" href="/views/css/components/updateNotifier.css">
|
||||
<link rel="stylesheet" href="/views/css/components/titlebar.css">
|
||||
<link rel="stylesheet" href="/views/css/components/create-room.css"/>
|
||||
<script src="/src/scripts/titlebar.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -96,6 +97,22 @@
|
||||
|
||||
<div id="section-local" class="tab-section">
|
||||
|
||||
<div id="downloads-monitor" class="downloads-monitor">
|
||||
<div class="monitor-header">
|
||||
<div class="monitor-title">
|
||||
<span class="pulse-dot"></span>
|
||||
<h3>Download Manager</h3>
|
||||
</div>
|
||||
<div class="monitor-stats">
|
||||
<span id="dl-stat-active">0 Active</span>
|
||||
<span class="divider">•</span>
|
||||
<span id="dl-stat-failed">0 Failed</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="downloads-list-container" class="monitor-list">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar local-toolbar">
|
||||
<div class="search-box">
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
@@ -194,13 +211,50 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div id="manual-match-modal" class="custom-modal-overlay hidden">
|
||||
<div class="custom-modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Fix Match</h3>
|
||||
<button class="close-modal-btn" onclick="DashboardApp.Library.closeManualMatch()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p class="modal-description">Introduce el ID de AniList correcto para asociar este archivo local.</p>
|
||||
|
||||
<div class="input-group">
|
||||
<label>File Path / Folder Name</label>
|
||||
<div id="manual-match-path" class="path-display"></div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>AniList ID</label>
|
||||
<input type="number" id="manual-match-id" class="stream-input" placeholder="Ej: 21 (One Piece)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" onclick="DashboardApp.Library.closeManualMatch()">Cancel</button>
|
||||
<button class="btn-primary" onclick="DashboardApp.Library.submitManualMatch()">Confirm Match</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="updateToast" class="hidden">
|
||||
<p>Update available: <span id="latestVersionDisplay">v1.x</span></p>
|
||||
<a id="downloadButton" href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases" target="_blank">Download</a>
|
||||
</div>
|
||||
<div id="anilist-connect-modal" class="custom-modal-overlay hidden">
|
||||
<div class="custom-modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>AniList Integration</h3>
|
||||
<button class="close-modal-btn" onclick="DashboardApp.User.closeAniListModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" id="anilist-modal-body">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/src/scripts/updateNotifier.js"></script>
|
||||
<script src="/src/scripts/room-modal.js"></script>
|
||||
<script src="/src/scripts/rpc-inapp.js"></script>
|
||||
<script src="/src/scripts/auth-guard.js"></script>
|
||||
<script src="/src/scripts/utils/auth-utils.js"></script>
|
||||
|
||||
300
desktop/views/room.html
Normal file
300
desktop/views/room.html
Normal file
@@ -0,0 +1,300 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon" />
|
||||
<title>Watch Party - WaifuBoard</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||
<script type="module">
|
||||
import JASSUB from 'https://cdn.jsdelivr.net/npm/jassub@1.8.8/dist/jassub.es.js';
|
||||
window.JASSUB = JASSUB;
|
||||
</script>
|
||||
<link rel="stylesheet" href="/views/css/globals.css" />
|
||||
<link rel="stylesheet" href="/views/css/anime/player.css" />
|
||||
<link rel="stylesheet" href="/views/css/room.css" />
|
||||
<link rel="stylesheet" href="/views/css/components/create-room.css"/>
|
||||
<link rel="stylesheet" href="/views/css/components/titlebar.css"/>
|
||||
<script src="/src/scripts/titlebar.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="titlebar">
|
||||
<div class="title-left">
|
||||
<img class="app-icon" src="/public/assets/waifuboards.ico" alt=""/>
|
||||
<span class="app-title">WaifuBoard</span>
|
||||
</div>
|
||||
<div class="title-right">
|
||||
<button class="min">−</button>
|
||||
<button class="max">🗖</button>
|
||||
<button class="close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="room-view">
|
||||
<div class="room-layout" id="room-layout">
|
||||
<div class="video-area">
|
||||
<div class="room-header">
|
||||
<div class="header-left">
|
||||
<div class="room-info">
|
||||
<h2 id="room-name">Loading...</h2>
|
||||
<div id="now-playing-info" class="np-fade">
|
||||
<span id="np-title" class="np-title">Waiting selection...</span>
|
||||
<span class="np-sep">•</span>
|
||||
<span id="np-episode" class="np-badge">Episode --</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-center" id="host-controls" style="display: none;">
|
||||
<div class="quick-controls-group">
|
||||
<div class="sd-toggle small" id="room-sd-toggle" data-state="sub">
|
||||
<div class="sd-bg"></div>
|
||||
<div class="sd-option active" data-val="sub">Sub</div>
|
||||
<div class="sd-option" data-val="dub">Dub</div>
|
||||
</div>
|
||||
|
||||
<div class="control-divider"></div>
|
||||
|
||||
<div class="quick-select-wrapper">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 7h-9"/><path d="M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></svg>
|
||||
<select id="room-ext-select" class="quick-select" title="Extension">
|
||||
<option value="" disabled selected>Extension</option>
|
||||
</select>
|
||||
<div class="select-arrow">▼</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-select-wrapper">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line x1="6" y1="18" x2="6.01" y2="18"></line></svg>
|
||||
<select id="room-server-select" class="quick-select" title="Server">
|
||||
<option value="" disabled selected>Server</option>
|
||||
</select>
|
||||
<div class="select-arrow">▼</div>
|
||||
</div>
|
||||
|
||||
<div class="control-divider"></div>
|
||||
|
||||
<button id="copy-invite-btn" class="btn-quick-action" title="Copy invite link" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.07 0l1.41-1.41a5 5 0 0 0-7.07-7.07L10 5"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.07 0L5.5 12.41a5 5 0 0 0 7.07 7.07L14 19"/>
|
||||
</svg>
|
||||
<span>Invite</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="viewers-pill">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
|
||||
<span id="room-viewers">0</span>
|
||||
</div>
|
||||
|
||||
<button id="select-anime-btn" class="btn-glass-primary" style="display: none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.35-4.35"></path></svg>
|
||||
</button>
|
||||
|
||||
<button id="toggle-chat-btn" class="btn-icon-glass" title="Toggle Chat">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="9" y1="3" x2="9" y2="21"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="player-wrapper">
|
||||
<div class="player-container show-cursor">
|
||||
<div class="video-frame">
|
||||
<canvas id="subtitles-canvas"></canvas>
|
||||
<div id="video-toast-container" class="video-toast-container"></div>
|
||||
<video id="player" crossorigin="anonymous" playsinline></video>
|
||||
|
||||
<div id="player-loading" class="player-loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
<p id="player-loading-text">Waiting for host...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="custom-controls">
|
||||
<div class="progress-container">
|
||||
<div class="progress-buffer"></div>
|
||||
<div class="progress-played"></div>
|
||||
<div class="progress-handle"></div>
|
||||
</div>
|
||||
|
||||
<div class="controls-row">
|
||||
<div class="controls-left">
|
||||
<button class="control-btn play-pause" id="play-pause-btn">
|
||||
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<button class="control-btn volume" id="volume-btn">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/></svg>
|
||||
</button>
|
||||
<input type="range" class="volume-slider" id="volume-slider" min="0" max="100" value="100">
|
||||
<span class="time-display" id="time-display">0:00 / 0:00</span>
|
||||
</div>
|
||||
|
||||
<div class="controls-center"></div>
|
||||
|
||||
<div class="controls-right">
|
||||
<button class="control-btn settings" id="settings-btn">
|
||||
<svg viewBox="0 0 24 24"><path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/></svg>
|
||||
</button>
|
||||
<div class="settings-panel" id="settings-panel"></div>
|
||||
<button class="control-btn fullscreen" id="fullscreen-btn">
|
||||
<svg viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-sidebar">
|
||||
<div class="sidebar-tabs">
|
||||
<button class="tab-btn active" id="tab-chat-btn">Chat</button>
|
||||
<button class="tab-btn" id="tab-queue-btn">Queue <span id="queue-count" class="badge">0</span></button>
|
||||
</div>
|
||||
|
||||
<div id="tab-content-chat" class="tab-content active" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div style="padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.1); display: flex; justify-content: flex-end;">
|
||||
<button id="toggle-users-btn" class="btn-icon-glass" title="Toggle User List" style="width: 32px; height: 32px;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="users-list" id="users-list" style="display: none;"></div>
|
||||
<div class="chat-messages" id="chat-messages"></div>
|
||||
<form class="chat-input" id="chat-form" autocomplete="off">
|
||||
<input type="text" id="chat-input" placeholder="Type a message..." maxlength="500" autocomplete="off" />
|
||||
<button type="submit">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div id="tab-content-queue" class="tab-content" style="display: none;">
|
||||
<div class="queue-list" id="queue-list">
|
||||
<div class="queue-empty">Queue is empty</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="join-room-modal">
|
||||
<div class="modal-content">
|
||||
<div id="join-host-info" class="join-host-info" style="display: none;">
|
||||
<div class="join-avatar-container">
|
||||
<img id="join-host-avatar" src="" alt="Host">
|
||||
</div>
|
||||
<p id="join-host-text" class="join-text"></p>
|
||||
</div>
|
||||
|
||||
<h2 class="modal-title" style="text-align: center;">Join Room</h2>
|
||||
|
||||
<form id="join-room-form">
|
||||
<div class="form-group">
|
||||
<label>Your Name</label>
|
||||
<input type="text" id="guest-name-input" placeholder="Enter your name" maxlength="30" />
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="password-group" style="display: none;">
|
||||
<label>Room Password</label>
|
||||
<input type="password" id="join-password-input" placeholder="Enter password" maxlength="50" />
|
||||
</div>
|
||||
|
||||
<div class="form-actions" style="justify-content: center;">
|
||||
<button type="button" class="btn-cancel" id="cancel-join-btn">Go Back</button>
|
||||
<button type="submit" class="btn-confirm">Join Party</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="anime-search-modal">
|
||||
<div class="modal-content anime-search-content">
|
||||
<button class="modal-close" id="close-search-modal">✕</button>
|
||||
|
||||
<div id="step-search">
|
||||
<h2 class="modal-title">Select Anime</h2>
|
||||
<div class="search-bar">
|
||||
<input type="text" id="anime-search-input" placeholder="Search anime..." />
|
||||
<button id="anime-search-btn">Search</button>
|
||||
</div>
|
||||
<div id="anime-results" class="anime-results"></div>
|
||||
</div>
|
||||
|
||||
<div id="step-config" style="display: none;">
|
||||
<div class="modal-header-row">
|
||||
<button class="btn-icon-small" id="back-to-search" title="Back">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
<h2 class="modal-title" id="selected-anime-title">Configure Episode</h2>
|
||||
</div>
|
||||
|
||||
<div class="config-layout">
|
||||
<div class="config-sidebar">
|
||||
<img id="config-cover" class="config-cover" src="" alt="Cover">
|
||||
</div>
|
||||
|
||||
<div class="config-main" style="justify-content: center;">
|
||||
|
||||
<div style="display: flex; flex-direction: column; height: 100%;">
|
||||
|
||||
<div class="cfg-header" style="margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="cfg-section-title">Select Episodes</div>
|
||||
<div class="cfg-actions">
|
||||
<button id="select-all-page" class="btn-text-tiny">Select Page</button>
|
||||
<button id="select-none-eps" class="btn-text-tiny">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="episodes-grid" class="episodes-grid-container">
|
||||
<div class="grid-loader">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div id="modal-pagination" class="modal-pagination" style="display: none;">
|
||||
<button id="modal-prev-page" class="modal-page-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
|
||||
</button>
|
||||
<span id="modal-page-info" class="modal-page-info">1 - 50</span>
|
||||
<button id="modal-next-page" class="modal-page-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="manual-ep-input" style="margin-top: 10px; display: none;">
|
||||
<label style="font-size: 0.8rem; color: #aaa;">Manual Entry:</label>
|
||||
<div class="ep-control">
|
||||
<button class="ep-btn" id="ep-dec">−</button>
|
||||
<input type="number" id="inp-episode" class="ep-input" value="1" min="1">
|
||||
<button class="ep-btn" id="ep-inc">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="config-error" style="color:#ff6b6b; font-size:0.9rem; display:none; background:rgba(255,0,0,0.1); padding:10px; border-radius:8px; margin-top: 10px;"></div>
|
||||
|
||||
<div class="form-actions" style="margin-top:auto; display:flex; gap:10px; padding-top: 20px;">
|
||||
<button id="btn-add-queue" class="btn-cancel" style="flex:1; justify-content: center; border-color: var(--brand-color); color: white;">
|
||||
+ Add Selected (<span id="sel-count">0</span>)
|
||||
</button>
|
||||
|
||||
<button id="btn-launch-stream" class="btn-confirm" style="flex:1;">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
Play First
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/src/scripts/utils/auth-utils.js"></script>
|
||||
<script src="/src/scripts/utils/search-manager.js"></script>
|
||||
<script src="/src/scripts/anime/subtitle-renderer.js"></script>
|
||||
<script src="/src/scripts/anime/player.js"></script>
|
||||
<script src="/src/scripts/room.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -13,6 +13,7 @@
|
||||
<link rel="stylesheet" href="/views/css/components/navbar.css">
|
||||
<link rel="stylesheet" href="/views/css/components/updateNotifier.css">
|
||||
<link rel="stylesheet" href="/views/css/components/titlebar.css">
|
||||
<link rel="stylesheet" href="/views/css/components/create-room.css"/>
|
||||
<script src="/src/scripts/titlebar.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -89,6 +90,7 @@
|
||||
</div>
|
||||
|
||||
<script src="/src/scripts/updateNotifier.js"></script>
|
||||
<script src="/src/scripts/room-modal.js"></script>
|
||||
<script src="/src/scripts/schedule/schedule.js"></script>
|
||||
<script src="/src/scripts/rpc-inapp.js"></script>
|
||||
<script src="/src/scripts/auth-guard.js"></script>
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
FROM mcr.microsoft.com/playwright:v1.50.0-jammy
|
||||
FROM node:20-bookworm
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
ffmpeg \
|
||||
curl \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN wget -q https://github.com/cloudflare/cloudflared/releases/download/2025.11.1/cloudflared-linux-amd64.deb \
|
||||
&& dpkg -i cloudflared-linux-amd64.deb \
|
||||
&& rm cloudflared-linux-amd64.deb
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN git clone https://git.waifuboard.app/ItsSkaiya/WaifuBoard.git repo
|
||||
|
||||
RUN npm ci
|
||||
RUN mv repo/docker/* . \
|
||||
&& rm -rf repo
|
||||
|
||||
RUN npx playwright install --with-deps chromium
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npx playwright install chromium
|
||||
RUN npx playwright install-deps
|
||||
|
||||
EXPOSE 54322
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
|
||||
75
docker/package-lock.json
generated
75
docker/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bindings": "^1.5.0",
|
||||
@@ -22,13 +23,15 @@
|
||||
"node-addon-api": "^8.5.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"playwright-chromium": "^1.57.0",
|
||||
"sqlite3": "^5.1.7"
|
||||
"sqlite3": "^5.1.7",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"node-gyp": "^12.1.0",
|
||||
"ts-node": "^10.9.0",
|
||||
"typescript": "^5.3.0"
|
||||
@@ -221,6 +224,27 @@
|
||||
"glob": "^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/websocket": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz",
|
||||
"integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"duplexify": "^4.1.3",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"ws": "^8.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@gar/promisify": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
|
||||
@@ -453,6 +477,16 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz",
|
||||
@@ -1161,6 +1195,18 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexify": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
|
||||
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.4.1",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1",
|
||||
"stream-shift": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
@@ -4027,6 +4073,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stream-shift": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
|
||||
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
@@ -4558,6 +4610,27 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.4.23",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bindings": "^1.5.0",
|
||||
@@ -25,13 +26,15 @@
|
||||
"node-addon-api": "^8.5.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"playwright-chromium": "^1.57.0",
|
||||
"sqlite3": "^5.1.7"
|
||||
"sqlite3": "^5.1.7",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"node-gyp": "^12.1.0",
|
||||
"ts-node": "^10.9.0",
|
||||
"typescript": "^5.3.0"
|
||||
|
||||
@@ -11,9 +11,6 @@ const { loadExtensions } = require("./dist/shared/extensions");
|
||||
const {refreshTrendingAnime, refreshTopAiringAnime} = require("./dist/api/anime/anime.service");
|
||||
const {refreshPopularBooks, refreshTrendingBooks} = require("./dist/api/books/books.service");
|
||||
const { ensureConfigFile } = require("./dist/shared/config");
|
||||
const dotenv = require("dotenv");
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const viewsRoutes = require("./dist/views/views.routes");
|
||||
const animeRoutes = require("./dist/api/anime/anime.routes");
|
||||
@@ -26,19 +23,95 @@ const listRoutes = require("./dist/api/list/list.routes");
|
||||
const anilistRoute = require("./dist/api/anilist/anilist");
|
||||
const localRoutes = require("./dist/api/local/local.routes");
|
||||
const configRoutes = require("./dist/api/config/config.routes");
|
||||
const roomRoutes = require("./dist/api/rooms/rooms.routes");
|
||||
const { setupRoomWebSocket } = require("./dist/api/rooms/rooms.websocket");
|
||||
|
||||
fastify.addHook("preHandler", async (request) => {
|
||||
const { getConfig } = require('./dist/shared/config');
|
||||
const { values } = getConfig();
|
||||
const jwtSecret = values.server?.jwt_secret;
|
||||
|
||||
fastify.addHook("preHandler", async (request, reply) => {
|
||||
const auth = request.headers.authorization;
|
||||
if (!auth) return;
|
||||
|
||||
try {
|
||||
const token = auth.replace("Bearer ", "");
|
||||
request.user = jwt.verify(token, process.env.JWT_SECRET);
|
||||
request.user = jwt.verify(token, jwtSecret);
|
||||
} catch (e) {
|
||||
return reply.code(401).send({ error: "Invalid token" });
|
||||
}
|
||||
});
|
||||
|
||||
const roomService = require('./dist/api/rooms/rooms.service');
|
||||
|
||||
fastify.addHook('onRequest', async (req, reply) => {
|
||||
const isTunnel =
|
||||
!!req.headers['cf-connecting-ip'] ||
|
||||
!!req.headers['cf-ray'];
|
||||
|
||||
if (!isTunnel) return;
|
||||
|
||||
if (req.url.startsWith('/public/') ||
|
||||
req.url.startsWith('/views/') ||
|
||||
req.url.startsWith('/src/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url.startsWith('/room')) {
|
||||
const urlParams = new URLSearchParams(req.url.split('?')[1]);
|
||||
const roomId = urlParams.get('id');
|
||||
|
||||
if (!roomId) {
|
||||
return reply.code(404).send({ error: 'Room ID required' });
|
||||
}
|
||||
|
||||
const room = roomService.getRoom(roomId);
|
||||
if (!room || room.exposed !== true) {
|
||||
return reply.code(404).send({ error: 'Room not found' });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const wsMatch = req.url.match(/^\/ws\/room\/([a-f0-9]+)/);
|
||||
if (wsMatch) {
|
||||
const roomId = wsMatch[1];
|
||||
const room = roomService.getRoom(roomId);
|
||||
|
||||
if (!room || room.exposed !== true) {
|
||||
return reply.code(404).send({ error: 'Room not found' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const apiMatch = req.url.match(/^\/api\/rooms\/([a-f0-9]+)/);
|
||||
if (apiMatch) {
|
||||
const roomId = apiMatch[1];
|
||||
const room = roomService.getRoom(roomId);
|
||||
|
||||
if (!room || room.exposed !== true) {
|
||||
return reply.code(404).send({ error: 'Room not found' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedEndpoints = [
|
||||
'/api/watch/stream',
|
||||
'/api/proxy',
|
||||
'/api/extensions',
|
||||
'/api/search'
|
||||
];
|
||||
|
||||
for (const endpoint of allowedEndpoints) {
|
||||
if (req.url.startsWith(endpoint)) {
|
||||
console.log('[Tunnel] ✓ Allowing utility endpoint:', endpoint);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return reply.code(404).send({ error: 'Not found' });
|
||||
});
|
||||
|
||||
fastify.register(require("@fastify/static"), {
|
||||
root: path.join(__dirname, "public"),
|
||||
prefix: "/public/",
|
||||
@@ -68,17 +141,20 @@ fastify.register(anilistRoute, { prefix: "/api" });
|
||||
fastify.register(listRoutes, { prefix: "/api" });
|
||||
fastify.register(localRoutes, { prefix: "/api" });
|
||||
fastify.register(configRoutes, { prefix: "/api" });
|
||||
fastify.register(roomRoutes, { prefix: "/api" });
|
||||
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
await fastify.register(require('@fastify/websocket'));
|
||||
ensureConfigFile()
|
||||
initDatabase("anilist");
|
||||
initDatabase("favorites");
|
||||
initDatabase("cache");
|
||||
initDatabase("userdata");
|
||||
initDatabase("local_library");
|
||||
setupRoomWebSocket(fastify);
|
||||
|
||||
const refreshAll = async () => {
|
||||
await refreshTrendingAnime();
|
||||
@@ -90,7 +166,7 @@ const start = async () => {
|
||||
await refreshPopularBooks();
|
||||
};
|
||||
|
||||
cron.schedule("*/30 * * * *", async () => {
|
||||
const job = cron.schedule("*/30 * * * *", async () => {
|
||||
try {
|
||||
await refreshAll();
|
||||
console.log("cache refreshed");
|
||||
@@ -107,6 +183,16 @@ const start = async () => {
|
||||
console.error("initial refresh failed", e)
|
||||
);
|
||||
console.log(`Server running at http://localhost:54322`);
|
||||
|
||||
const shutdown = async () => {
|
||||
job.stop();
|
||||
await fastify.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -2,58 +2,49 @@ import { FastifyInstance } from "fastify";
|
||||
import { run } from "../../shared/database";
|
||||
|
||||
async function anilist(fastify: FastifyInstance) {
|
||||
fastify.get("/anilist", async (request, reply) => {
|
||||
fastify.post("/anilist/store", async (request, reply) => {
|
||||
try {
|
||||
const { code, state } = request.query as { code?: string; state?: string };
|
||||
const {
|
||||
userId,
|
||||
accessToken,
|
||||
tokenType = "Bearer",
|
||||
expiresIn
|
||||
} = request.body as {
|
||||
userId: number;
|
||||
accessToken: string;
|
||||
tokenType?: string;
|
||||
expiresIn?: number;
|
||||
};
|
||||
|
||||
if (!code) return reply.status(400).send("No code");
|
||||
if (!state) return reply.status(400).send("No user state");
|
||||
|
||||
const userId = Number(state);
|
||||
if (!userId || isNaN(userId)) {
|
||||
return reply.status(400).send("Invalid user id");
|
||||
}
|
||||
|
||||
const tokenRes = await fetch("https://anilist.co/api/v2/oauth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
client_id: process.env.ANILIST_CLIENT_ID,
|
||||
client_secret: process.env.ANILIST_CLIENT_SECRET,
|
||||
redirect_uri: "http://localhost:54322/api/anilist",
|
||||
code
|
||||
})
|
||||
});
|
||||
|
||||
const tokenData = await tokenRes.json();
|
||||
|
||||
if (!tokenData.access_token) {
|
||||
console.error("AniList token error:", tokenData);
|
||||
return reply.status(500).send("Failed to get AniList token");
|
||||
if (!userId || !accessToken) {
|
||||
return reply.status(400).send({ error: "Faltan datos (User ID o Token)" });
|
||||
}
|
||||
|
||||
// 1. Verificar que el token es válido consultando a AniList
|
||||
const userRes = await fetch("https://graphql.anilist.co", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `${tokenData.token_type} ${tokenData.access_token}`
|
||||
Authorization: `${tokenType} ${accessToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: `query { Viewer { id } }`
|
||||
})
|
||||
});
|
||||
|
||||
if (!userRes.ok) {
|
||||
return reply.status(401).send({ error: "Token de AniList inválido o expirado" });
|
||||
}
|
||||
|
||||
const userData = await userRes.json();
|
||||
const anilistUserId = userData?.data?.Viewer?.id;
|
||||
|
||||
if (!anilistUserId) {
|
||||
console.error("AniList Viewer error:", userData);
|
||||
return reply.status(500).send("Failed to fetch AniList user");
|
||||
return reply.status(500).send({ error: "No se pudo obtener el ID de usuario de AniList" });
|
||||
}
|
||||
|
||||
const expiresAt = new Date(
|
||||
Date.now() + tokenData.expires_in * 1000
|
||||
Date.now() + 365 * 24 * 60 * 60 * 1000
|
||||
).toISOString();
|
||||
|
||||
await run(
|
||||
@@ -71,19 +62,19 @@ async function anilist(fastify: FastifyInstance) {
|
||||
[
|
||||
userId,
|
||||
"AniList",
|
||||
tokenData.access_token,
|
||||
tokenData.refresh_token,
|
||||
tokenData.token_type,
|
||||
accessToken,
|
||||
"", // <- aquí
|
||||
tokenType,
|
||||
anilistUserId,
|
||||
expiresAt
|
||||
],
|
||||
"userdata"
|
||||
);
|
||||
|
||||
return reply.redirect("http://localhost:54322/?anilist=success");
|
||||
return reply.send({ ok: true, anilistUserId });
|
||||
} catch (e) {
|
||||
console.error("AniList error:", e);
|
||||
return reply.redirect("http://localhost:54322/?anilist=error");
|
||||
return reply.status(500).send({ error: "Error interno del servidor al guardar AniList" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export async function searchInExtension(req: any, reply: FastifyReply) {
|
||||
|
||||
export async function getWatchStream(req: WatchStreamRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { animeId, episode, server, category, ext, source } = req.query;
|
||||
const { animeId, episode, server, category, ext, source, extensionAnimeId } = req.query;
|
||||
|
||||
const extension = getExtension(ext);
|
||||
if (!extension) return { error: "Extension not found" };
|
||||
@@ -98,7 +98,8 @@ export async function getWatchStream(req: WatchStreamRequest, reply: FastifyRepl
|
||||
animeId,
|
||||
source,
|
||||
server,
|
||||
category
|
||||
category,
|
||||
extensionAnimeId
|
||||
);
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
|
||||
@@ -340,6 +340,7 @@ export async function searchAnimeInExtension(ext: Extension | null, name: string
|
||||
averageScore: m.rating || m.score || null,
|
||||
format: 'ANIME',
|
||||
seasonYear: null,
|
||||
url: m.url,
|
||||
isExtensionResult: true,
|
||||
}));
|
||||
}
|
||||
@@ -360,15 +361,36 @@ export async function searchEpisodesInExtension(ext: Extension | null, name: str
|
||||
if (cached) {
|
||||
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
|
||||
|
||||
if (!isExpired) {
|
||||
console.log(`[${name}] Episodes cache hit for: ${query}`);
|
||||
try {
|
||||
return JSON.parse(cached.result) as Episode[];
|
||||
} catch (e) {
|
||||
console.error(`[${name}] Error parsing cached episodes:`, e);
|
||||
try {
|
||||
const parsed = JSON.parse(cached.result) as {
|
||||
mediaId?: string;
|
||||
episodes: Episode[];
|
||||
};
|
||||
|
||||
if (!isExpired) {
|
||||
console.log(`[${name}] Episodes cache hit for: ${query}`);
|
||||
return parsed.episodes;
|
||||
}
|
||||
} else {
|
||||
console.log(`[${name}] Episodes cache expired for: ${query}`);
|
||||
|
||||
// Si el caché expiró pero tenemos mediaId, refrescamos directamente
|
||||
if (parsed.mediaId && ext.type === "anime-board" && ext.search && typeof ext.findEpisodes === "function") {
|
||||
console.log(`[${name}] Episodes cache expired but mediaId found, refreshing...`);
|
||||
const chapterList = await ext.findEpisodes(parsed.mediaId);
|
||||
|
||||
if (!Array.isArray(chapterList)) return [];
|
||||
|
||||
const result: Episode[] = chapterList.map(ep => ({
|
||||
id: ep.id,
|
||||
number: ep.number,
|
||||
url: ep.url,
|
||||
title: ep.title
|
||||
}));
|
||||
|
||||
await setCache(cacheKey, { mediaId: parsed.mediaId, episodes: result }, CACHE_TTL_MS);
|
||||
return result;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[${name}] Error parsing cached episodes:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,6 +443,7 @@ export async function searchEpisodesInExtension(ext: Extension | null, name: str
|
||||
|
||||
const chapterList = await ext.findEpisodes(mediaId);
|
||||
if (!Array.isArray(chapterList)) return [];
|
||||
|
||||
const result: Episode[] = chapterList.map(ep => ({
|
||||
id: ep.id,
|
||||
number: ep.number,
|
||||
@@ -428,7 +451,8 @@ export async function searchEpisodesInExtension(ext: Extension | null, name: str
|
||||
title: ep.title
|
||||
}));
|
||||
|
||||
await setCache(cacheKey, result, CACHE_TTL_MS);
|
||||
// Cachear tanto el mediaId como los episodios
|
||||
await setCache(cacheKey, { mediaId, episodes: result }, CACHE_TTL_MS);
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
@@ -439,25 +463,27 @@ export async function searchEpisodesInExtension(ext: Extension | null, name: str
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function getStreamData(extension: Extension, episode: string, id: string, source: string, server?: string, category?: string): Promise<StreamData> {
|
||||
export async function getStreamData(extension: Extension, episode: string, id: string, source: string, server?: string, category?: string, extensionAnimeId?: string): Promise<StreamData> {
|
||||
const providerName = extension.constructor.name;
|
||||
|
||||
const cacheKey = `anime:stream:${providerName}:${id}:${episode}:${server || 'default'}:${category || 'sub'}`;
|
||||
const finalCategory = category ?? "sub";
|
||||
|
||||
const cached = await getCache(cacheKey);
|
||||
const cacheKey =
|
||||
`anime:stream:${providerName}:${id}:${episode}:${server}:${finalCategory}`;
|
||||
if (!extensionAnimeId) {
|
||||
const cached = await getCache(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
|
||||
if (cached) {
|
||||
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
|
||||
|
||||
if (!isExpired) {
|
||||
console.log(`[${providerName}] Stream data cache hit for episode ${episode}`);
|
||||
try {
|
||||
return JSON.parse(cached.result) as StreamData;
|
||||
} catch (e) {
|
||||
console.error(`[${providerName}] Error parsing cached stream data:`, e);
|
||||
if (!isExpired) {
|
||||
console.log(`[${providerName}] Stream data cache hit for episode ${episode}`);
|
||||
try {
|
||||
return JSON.parse(cached.result) as StreamData;
|
||||
} catch (e) {
|
||||
console.error(`[${providerName}] Error parsing cached stream data:`, e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[${providerName}] Stream data cache expired for episode ${episode}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,22 +491,41 @@ export async function getStreamData(extension: Extension, episode: string, id: s
|
||||
throw new Error("Extension doesn't support required methods");
|
||||
}
|
||||
let episodes;
|
||||
let animeTitle: string | undefined;
|
||||
|
||||
if (source === "anilist"){
|
||||
const anime: any = await getAnimeById(id)
|
||||
if (source === "anilist" && !extensionAnimeId) {
|
||||
const anime: any = await getAnimeById(id);
|
||||
episodes = await searchEpisodesInExtension(extension, extension.constructor.name, anime.title.romaji);
|
||||
} else {
|
||||
const targetId = extensionAnimeId ?? id;
|
||||
episodes = await extension.findEpisodes(targetId);
|
||||
|
||||
if (extensionAnimeId) {
|
||||
const anime: any = await getAnimeById(id);
|
||||
animeTitle = anime.title.romaji;
|
||||
|
||||
const episodesCacheKey = `anime:episodes:${providerName}:${animeTitle}`;
|
||||
const episodesResult: Episode[] = episodes.map((ep: any) => ({
|
||||
id: ep.id,
|
||||
number: ep.number,
|
||||
url: ep.url,
|
||||
title: ep.title
|
||||
}));
|
||||
|
||||
await setCache(episodesCacheKey, {
|
||||
mediaId: extensionAnimeId,
|
||||
episodes: episodesResult
|
||||
}, CACHE_TTL_MS);
|
||||
}
|
||||
}
|
||||
else{
|
||||
episodes = await extension.findEpisodes(id);
|
||||
}
|
||||
const targetEp = episodes.find(e => e.number === parseInt(episode));
|
||||
|
||||
const targetEp = episodes.find((e: any) => e.number === parseInt(episode));
|
||||
|
||||
if (!targetEp) {
|
||||
throw new Error("Episode not found");
|
||||
}
|
||||
|
||||
const serverName = server || "default";
|
||||
const streamData = await extension.findEpisodeServer(targetEp, serverName);
|
||||
const streamData = await extension.findEpisodeServer(targetEp, server, category);
|
||||
|
||||
await setCache(cacheKey, streamData, CACHE_TTL_MS);
|
||||
return streamData;
|
||||
|
||||
@@ -88,9 +88,10 @@ export async function getChapters(req: any, reply: FastifyReply) {
|
||||
const { id } = req.params;
|
||||
const source = req.query.source || 'anilist';
|
||||
const provider = req.query.provider;
|
||||
const extensionBookId = req.query.extensionBookId;
|
||||
|
||||
const isExternal = source !== 'anilist';
|
||||
return await booksService.getChaptersForBook(id, isExternal, provider);
|
||||
return await booksService.getChaptersForBook(id, isExternal, provider, extensionBookId);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return { chapters: [] };
|
||||
|
||||
@@ -326,7 +326,8 @@ export async function searchBooksInExtension(ext: Extension | null, name: string
|
||||
averageScore: m.rating || m.score || null,
|
||||
format: m.format,
|
||||
seasonYear: null,
|
||||
isExtensionResult: true
|
||||
isExtensionResult: true,
|
||||
url: m.url,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -361,42 +362,64 @@ async function fetchBookMetadata(id: string): Promise<Book | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function searchChaptersInExtension(ext: Extension, name: string, searchTitle: string, search: boolean, origin: string): Promise<ChapterWithProvider[]> {
|
||||
const cacheKey = `chapters:${name}:${origin}:${search ? "search" : "id"}:${searchTitle}`;
|
||||
const cached = await getCache(cacheKey);
|
||||
async function searchChaptersInExtension(ext: Extension, name: string, lookupId: string, cacheId: string, search: boolean, origin: string, disableCache = false): Promise<ChapterWithProvider[]> {
|
||||
const cacheKey = `chapters:${name}:${origin}:id:${cacheId}`;
|
||||
if (!disableCache) {
|
||||
const cached = await getCache(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
|
||||
if (cached) {
|
||||
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
|
||||
|
||||
if (!isExpired) {
|
||||
console.log(`[${name}] Chapters cache hit for: ${searchTitle}`);
|
||||
try {
|
||||
return JSON.parse(cached.result) as ChapterWithProvider[];
|
||||
const parsed = JSON.parse(cached.result) as {
|
||||
mediaId?: string;
|
||||
chapters: ChapterWithProvider[];
|
||||
};
|
||||
|
||||
if (!isExpired) {
|
||||
console.log(`[${name}] Chapters cache hit for: ${lookupId}`);
|
||||
return parsed.chapters;
|
||||
}
|
||||
|
||||
if (parsed.mediaId) {
|
||||
const chaps = await ext.findChapters!(parsed.mediaId);
|
||||
|
||||
const result = chaps.map(ch => ({
|
||||
id: ch.id,
|
||||
number: parseFloat(ch.number.toString()),
|
||||
title: ch.title,
|
||||
date: ch.releaseDate,
|
||||
provider: name,
|
||||
index: ch.index,
|
||||
language: ch.language ?? null,
|
||||
}));
|
||||
|
||||
await setCache(cacheKey, { mediaId: parsed.mediaId, chapters: result }, CACHE_TTL_MS);
|
||||
return result;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[${name}] Error parsing cached chapters:`, e);
|
||||
}
|
||||
} else {
|
||||
console.log(`[${name}] Chapters cache expired for: ${searchTitle}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[${name}] Searching chapters for: ${searchTitle}`);
|
||||
console.log(`[${name}] Searching chapters for: ${lookupId}`);
|
||||
|
||||
let mediaId: string;
|
||||
if (search) {
|
||||
const matches = await ext.search!({
|
||||
query: searchTitle,
|
||||
query: lookupId,
|
||||
media: {
|
||||
romajiTitle: searchTitle,
|
||||
englishTitle: searchTitle,
|
||||
romajiTitle: lookupId,
|
||||
englishTitle: lookupId,
|
||||
startDate: { year: 0, month: 0, day: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
if (!matches?.length) return [];
|
||||
|
||||
const nq = normalize(searchTitle);
|
||||
const nq = normalize(lookupId);
|
||||
|
||||
const scored = matches.map(m => {
|
||||
const nt = normalize(m.title);
|
||||
@@ -414,7 +437,7 @@ async function searchChaptersInExtension(ext: Extension, name: string, searchTit
|
||||
mediaId = scored[0].m.id;
|
||||
|
||||
} else {
|
||||
const match = await ext.getMetadata(searchTitle);
|
||||
const match = await ext.getMetadata(lookupId);
|
||||
mediaId = match.id;
|
||||
}
|
||||
|
||||
@@ -435,7 +458,10 @@ async function searchChaptersInExtension(ext: Extension, name: string, searchTit
|
||||
language: ch.language ?? null,
|
||||
}));
|
||||
|
||||
await setCache(cacheKey, result, CACHE_TTL_MS);
|
||||
await setCache(cacheKey, {
|
||||
mediaId,
|
||||
chapters: result
|
||||
}, CACHE_TTL_MS);
|
||||
return result;
|
||||
|
||||
} catch (e) {
|
||||
@@ -445,7 +471,7 @@ async function searchChaptersInExtension(ext: Extension, name: string, searchTit
|
||||
}
|
||||
}
|
||||
|
||||
export async function getChaptersForBook(id: string, ext: Boolean, onlyProvider?: string): Promise<{ chapters: ChapterWithProvider[] }> {
|
||||
export async function getChaptersForBook(id: string, ext: Boolean, onlyProvider?: string, extensionBookId?: string): Promise<{ chapters: ChapterWithProvider[] }> {
|
||||
let bookData: Book | null = null;
|
||||
let searchTitle: string = "";
|
||||
|
||||
@@ -475,11 +501,30 @@ export async function getChaptersForBook(id: string, ext: Boolean, onlyProvider?
|
||||
|
||||
for (const [name, ext] of bookExtensions) {
|
||||
if (onlyProvider && name !== onlyProvider) continue;
|
||||
if (name == extension) {
|
||||
const chapters = await searchChaptersInExtension(ext, name, id, false, exts);
|
||||
if (extensionBookId && name === onlyProvider) {
|
||||
const targetId = extensionBookId ?? id;
|
||||
|
||||
const chapters = await searchChaptersInExtension(
|
||||
ext,
|
||||
name,
|
||||
targetId, // lookup
|
||||
id, // cache siempre con el id normal
|
||||
false,
|
||||
exts,
|
||||
Boolean(extensionBookId)
|
||||
);
|
||||
|
||||
allChapters.push(...chapters);
|
||||
} else {
|
||||
const chapters = await searchChaptersInExtension(ext, name, searchTitle, true, exts);
|
||||
const chapters = await searchChaptersInExtension(
|
||||
ext,
|
||||
name,
|
||||
searchTitle,
|
||||
id, // cache con id normal
|
||||
true,
|
||||
exts
|
||||
);
|
||||
|
||||
allChapters.push(...chapters);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { getConfig, setConfig } from '../../shared/config';
|
||||
|
||||
function hideSecrets(values: any) {
|
||||
const copy = structuredClone(values);
|
||||
if (copy.server?.jwt_secret) delete copy.server.jwt_secret;
|
||||
return copy;
|
||||
}
|
||||
|
||||
export async function getFullConfig(req: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { values, schema } = getConfig();
|
||||
return { values, schema };
|
||||
return { values: hideSecrets(values), schema };
|
||||
} catch {
|
||||
return { error: "Error loading config" };
|
||||
}
|
||||
@@ -22,7 +28,7 @@ export async function getConfigSection(
|
||||
return { error: "Section not found" };
|
||||
}
|
||||
|
||||
return { [section]: values[section] };
|
||||
return { [section]: hideSecrets(values)[section] };
|
||||
} catch {
|
||||
return { error: "Error loading config section" };
|
||||
}
|
||||
|
||||
@@ -9,8 +9,40 @@ import AdmZip from 'adm-zip';
|
||||
import { spawn } from 'child_process';
|
||||
const { values } = loadConfig();
|
||||
|
||||
const FFMPEG_PATH =
|
||||
values.paths?.ffmpeg || 'ffmpeg';
|
||||
const FFMPEG_PATH = values.paths?.ffmpeg || 'ffmpeg';
|
||||
|
||||
type DownloadStatus = {
|
||||
id: string;
|
||||
type: 'anime' | 'manga' | 'novel';
|
||||
anilistId: number;
|
||||
unitNumber: number;
|
||||
status: 'pending' | 'downloading' | 'completed' | 'failed';
|
||||
progress: number;
|
||||
speed?: string;
|
||||
timeElapsed?: string;
|
||||
error?: string;
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
folderName?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
const activeDownloads = new Map<string, DownloadStatus>();
|
||||
|
||||
export function getActiveDownloads(): DownloadStatus[] {
|
||||
return Array.from(activeDownloads.values());
|
||||
}
|
||||
|
||||
export function getDownloadById(id: string): DownloadStatus | undefined {
|
||||
return activeDownloads.get(id);
|
||||
}
|
||||
|
||||
function updateDownloadProgress(id: string, updates: Partial<DownloadStatus>) {
|
||||
const current = activeDownloads.get(id);
|
||||
if (current) {
|
||||
activeDownloads.set(id, { ...current, ...updates });
|
||||
}
|
||||
}
|
||||
|
||||
type AnimeDownloadParams = {
|
||||
anilistId: number;
|
||||
@@ -20,6 +52,7 @@ type AnimeDownloadParams = {
|
||||
quality?: string;
|
||||
subtitles?: Array<{ language: string; url: string }>;
|
||||
chapters?: Array<{ title: string; start_time: number; end_time: number }>;
|
||||
totalDuration?: number;
|
||||
};
|
||||
|
||||
type BookDownloadParams = {
|
||||
@@ -107,29 +140,48 @@ async function getOrCreateEntry(
|
||||
}
|
||||
|
||||
export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
const { anilistId, episodeNumber, streamUrl, subtitles, chapters } = params;
|
||||
const { anilistId, episodeNumber, streamUrl, subtitles, chapters, totalDuration } = params;
|
||||
|
||||
const entry = await getOrCreateEntry(anilistId, 'anime');
|
||||
const entry: any = await getOrCreateEntry(anilistId, 'anime');
|
||||
const fileName = `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`;
|
||||
|
||||
const downloadId = crypto.randomUUID();
|
||||
activeDownloads.set(downloadId, {
|
||||
id: downloadId,
|
||||
type: 'anime',
|
||||
anilistId,
|
||||
unitNumber: episodeNumber,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
startedAt: Date.now(),
|
||||
folderName: entry.folderName,
|
||||
fileName: fileName
|
||||
});
|
||||
|
||||
const exists = await queryOne(
|
||||
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
|
||||
[entry.id, episodeNumber],
|
||||
'local_library'
|
||||
);
|
||||
if (exists) return { status: 'ALREADY_EXISTS', entry_id: entry.id, episode: episodeNumber };
|
||||
|
||||
if (exists) {
|
||||
activeDownloads.delete(downloadId);
|
||||
return { status: 'ALREADY_EXISTS', entry_id: entry.id, episode: episodeNumber };
|
||||
}
|
||||
|
||||
const outputPath = path.join(entry.path, `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`);
|
||||
const tempDir = path.join(entry.path, '.temp');
|
||||
await ensureDirectory(tempDir);
|
||||
|
||||
try {
|
||||
updateDownloadProgress(downloadId, { status: 'downloading' });
|
||||
|
||||
let videoInput = streamUrl;
|
||||
let audioInputs: string[] = [];
|
||||
|
||||
const isMaster = (params as any).is_master === true;
|
||||
|
||||
if (isMaster) {
|
||||
|
||||
const variant = (params as any).variant;
|
||||
const audios = (params as any).audio;
|
||||
|
||||
@@ -178,13 +230,11 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
|
||||
if (chapters?.length) {
|
||||
const meta = path.join(tempDir, 'chapters.txt');
|
||||
|
||||
const sorted = [...chapters].sort((a, b) => a.start_time - b.start_time);
|
||||
const lines: string[] = [';FFMETADATA1'];
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const c = sorted[i];
|
||||
|
||||
const start = Math.floor(c.start_time * 1000);
|
||||
const end = Math.floor(c.end_time * 1000);
|
||||
const title = (c.title || 'chapter').toUpperCase();
|
||||
@@ -220,18 +270,14 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
|
||||
fs.writeFileSync(meta, lines.join('\n'));
|
||||
args.push('-i', meta);
|
||||
|
||||
// índice correcto del metadata input
|
||||
chaptersInputIndex = 1 + audioInputs.length + subFiles.length;
|
||||
}
|
||||
|
||||
args.push('-map', '0:v:0');
|
||||
|
||||
if (audioInputs.length > 0) {
|
||||
|
||||
audioInputs.forEach((_, i) => {
|
||||
args.push('-map', `${i + 1}:a:0`);
|
||||
|
||||
const audioInfo = (params as any).audio?.[i];
|
||||
if (audioInfo) {
|
||||
const audioStreamIndex = i;
|
||||
@@ -244,7 +290,6 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
||||
args.push('-map', '0:a:0?');
|
||||
}
|
||||
|
||||
@@ -258,68 +303,43 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
args.push('-map_metadata', `${chaptersInputIndex}`);
|
||||
}
|
||||
|
||||
args.push('-c:v', 'copy');
|
||||
|
||||
args.push('-c:a', 'copy');
|
||||
|
||||
if (subFiles.length) {
|
||||
args.push('-c:s', 'srt');
|
||||
|
||||
}
|
||||
|
||||
args.push('-y');
|
||||
|
||||
args.push(outputPath);
|
||||
args.push('-c:v', 'copy', '-c:a', 'copy');
|
||||
if (subFiles.length) args.push('-c:s', 'srt');
|
||||
args.push('-y', outputPath);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
console.log('🎬 Iniciando descarga con FFmpeg...');
|
||||
console.log('📹 Video:', videoInput);
|
||||
if (audioInputs.length > 0) {
|
||||
console.log('🔊 Audio tracks:', audioInputs.length);
|
||||
}
|
||||
console.log('💾 Output:', outputPath);
|
||||
console.log('Args:', args.join(' '));
|
||||
|
||||
const ff = spawn(FFMPEG_PATH, args, {
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let lastProgress = '';
|
||||
|
||||
ff.stdout.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
console.log('[stdout]', text);
|
||||
});
|
||||
|
||||
ff.stderr.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
const timeMatch = text.match(/time=(\S+)/);
|
||||
const speedMatch = text.match(/speed=(\S+)/);
|
||||
|
||||
if (text.includes('time=') || text.includes('speed=')) {
|
||||
const timeMatch = text.match(/time=(\S+)/);
|
||||
const speedMatch = text.match(/speed=(\S+)/);
|
||||
if (timeMatch || speedMatch) {
|
||||
lastProgress = `⏱️ Time: ${timeMatch?.[1] || 'N/A'} | Speed: ${speedMatch?.[1] || 'N/A'}`;
|
||||
console.log(lastProgress);
|
||||
if (timeMatch || speedMatch) {
|
||||
const updates: any = {};
|
||||
|
||||
if (timeMatch) updates.timeElapsed = timeMatch[1];
|
||||
if (speedMatch) updates.speed = speedMatch[1];
|
||||
|
||||
if (timeMatch && totalDuration && totalDuration > 0) {
|
||||
const elapsedSeconds = parseFFmpegTime(timeMatch[1]);
|
||||
updates.progress = Math.min(
|
||||
99,
|
||||
Math.round((elapsedSeconds / totalDuration) * 100)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log('[ffmpeg]', text);
|
||||
updateDownloadProgress(downloadId, updates);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
ff.on('error', (error) => {
|
||||
console.error('❌ Error al iniciar FFmpeg:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
ff.on('error', (error) => reject(error));
|
||||
ff.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
console.log('✅ Descarga completada exitosamente');
|
||||
resolve(true);
|
||||
} else {
|
||||
console.error(`❌ FFmpeg terminó con código: ${code}`);
|
||||
reject(new Error(`FFmpeg exited with code ${code}`));
|
||||
}
|
||||
if (code === 0) resolve(true);
|
||||
else reject(new Error(`FFmpeg exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -339,8 +359,17 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
'local_library'
|
||||
);
|
||||
|
||||
updateDownloadProgress(downloadId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
completedAt: Date.now()
|
||||
});
|
||||
|
||||
setTimeout(() => activeDownloads.delete(downloadId), 30000);
|
||||
|
||||
return {
|
||||
status: 'SUCCESS',
|
||||
download_id: downloadId,
|
||||
entry_id: entry.id,
|
||||
file_id: fileId,
|
||||
episode: episodeNumber,
|
||||
@@ -350,6 +379,14 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
} catch (e: any) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
||||
|
||||
updateDownloadProgress(downloadId, {
|
||||
status: 'failed',
|
||||
error: e.message
|
||||
});
|
||||
|
||||
setTimeout(() => activeDownloads.delete(downloadId), 60000);
|
||||
|
||||
const err = new Error('DOWNLOAD_FAILED');
|
||||
(err as any).details = e.message;
|
||||
throw err;
|
||||
@@ -362,6 +399,23 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
const type = format === 'manga' ? 'manga' : 'novels';
|
||||
const entry = await getOrCreateEntry(anilistId, type);
|
||||
|
||||
const ext = format === 'manga' ? 'cbz' : 'epub';
|
||||
const fileName = `Chapter_${chapterNumber.toString().padStart(3, '0')}.${ext}`;
|
||||
|
||||
const downloadId = crypto.randomUUID();
|
||||
|
||||
activeDownloads.set(downloadId, {
|
||||
id: downloadId,
|
||||
type: format === 'manga' ? 'manga' : 'novel',
|
||||
anilistId,
|
||||
unitNumber: chapterNumber,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
startedAt: Date.now(),
|
||||
folderName: entry.folderName,
|
||||
fileName: fileName
|
||||
});
|
||||
|
||||
const existingFile = await queryOne(
|
||||
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
|
||||
[entry.id, chapterNumber],
|
||||
@@ -369,6 +423,7 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
);
|
||||
|
||||
if (existingFile) {
|
||||
activeDownloads.delete(downloadId);
|
||||
return {
|
||||
status: 'ALREADY_EXISTS',
|
||||
message: `Chapter ${chapterNumber} already exists`,
|
||||
@@ -378,6 +433,8 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
}
|
||||
|
||||
try {
|
||||
updateDownloadProgress(downloadId, { status: 'downloading' });
|
||||
|
||||
let outputPath: string;
|
||||
let fileId: string;
|
||||
|
||||
@@ -388,7 +445,8 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
const zip = new AdmZip();
|
||||
const sortedImages = images!.sort((a, b) => a.index - b.index);
|
||||
|
||||
for (const img of sortedImages) {
|
||||
for (let i = 0; i < sortedImages.length; i++) {
|
||||
const img = sortedImages[i];
|
||||
const res = await fetch(img.url);
|
||||
if (!res.ok) throw new Error(`HTTP_${res.status}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
@@ -396,6 +454,10 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
const ext = path.extname(new URL(img.url).pathname) || '.jpg';
|
||||
const filename = `${img.index.toString().padStart(4, '0')}${ext}`;
|
||||
zip.addFile(filename, buf);
|
||||
|
||||
updateDownloadProgress(downloadId, {
|
||||
progress: Math.floor((i / sortedImages.length) * 100)
|
||||
});
|
||||
}
|
||||
|
||||
zip.writeZip(outputPath);
|
||||
@@ -405,7 +467,6 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
outputPath = path.join(entry.path, chapterName);
|
||||
|
||||
const zip = new AdmZip();
|
||||
|
||||
zip.addFile('mimetype', Buffer.from('application/epub+zip'), '', 0);
|
||||
|
||||
const containerXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -443,7 +504,6 @@ ${content}
|
||||
</body>
|
||||
</html>`;
|
||||
zip.addFile('OEBPS/chapter.xhtml', Buffer.from(chapterXhtml));
|
||||
|
||||
zip.writeZip(outputPath);
|
||||
}
|
||||
|
||||
@@ -461,8 +521,17 @@ ${content}
|
||||
'local_library'
|
||||
);
|
||||
|
||||
updateDownloadProgress(downloadId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
completedAt: Date.now()
|
||||
});
|
||||
|
||||
setTimeout(() => activeDownloads.delete(downloadId), 30000);
|
||||
|
||||
return {
|
||||
status: 'SUCCESS',
|
||||
download_id: downloadId,
|
||||
entry_id: entry.id,
|
||||
file_id: fileId,
|
||||
chapter: chapterNumber,
|
||||
@@ -471,8 +540,26 @@ ${content}
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
updateDownloadProgress(downloadId, {
|
||||
status: 'failed',
|
||||
error: error.message
|
||||
});
|
||||
|
||||
setTimeout(() => activeDownloads.delete(downloadId), 60000);
|
||||
|
||||
const err = new Error('DOWNLOAD_FAILED');
|
||||
(err as any).details = error.message;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function parseFFmpegTime(timeStr: string): number {
|
||||
const parts = timeStr.split(':');
|
||||
if (parts.length < 3) return 0;
|
||||
|
||||
const h = parseFloat(parts[0]) || 0;
|
||||
const m = parseFloat(parts[1]) || 0;
|
||||
const s = parseFloat(parts[2]) || 0;
|
||||
|
||||
return (h * 3600) + (m * 60) + s;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {FastifyReply, FastifyRequest} from 'fastify';
|
||||
import fs from 'fs';
|
||||
import * as service from './local.service';
|
||||
import * as downloadService from './download.service';
|
||||
import * as streamingService from './streaming.service';
|
||||
|
||||
type ScanQuery = {
|
||||
mode?: 'full' | 'incremental';
|
||||
@@ -21,12 +22,13 @@ type DownloadAnimeBody =
|
||||
| {
|
||||
anilist_id: number;
|
||||
episode_number: number;
|
||||
stream_url: string; // media playlist FINAL
|
||||
stream_url: string;
|
||||
is_master?: false;
|
||||
subtitles?: {
|
||||
language: string;
|
||||
url: string;
|
||||
}[];
|
||||
duration?: number;
|
||||
chapters?: {
|
||||
title: string;
|
||||
start_time: number;
|
||||
@@ -36,28 +38,25 @@ type DownloadAnimeBody =
|
||||
| {
|
||||
anilist_id: number;
|
||||
episode_number: number;
|
||||
stream_url: string; // master.m3u8
|
||||
stream_url: string;
|
||||
duration?: number;
|
||||
is_master: true;
|
||||
|
||||
variant: {
|
||||
resolution: string;
|
||||
bandwidth?: number;
|
||||
codecs?: string;
|
||||
playlist_url: string;
|
||||
};
|
||||
|
||||
audio?: {
|
||||
group?: string;
|
||||
language?: string;
|
||||
name?: string;
|
||||
playlist_url: string;
|
||||
}[];
|
||||
|
||||
subtitles?: {
|
||||
language: string;
|
||||
url: string;
|
||||
}[];
|
||||
|
||||
chapters?: {
|
||||
title: string;
|
||||
start_time: number;
|
||||
@@ -91,8 +90,7 @@ export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQue
|
||||
export async function listEntries(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { type } = request.params;
|
||||
const entries = await service.getEntriesByType(type);
|
||||
return entries;
|
||||
return await service.getEntriesByType(type);
|
||||
} catch {
|
||||
return reply.status(500).send({ error: 'FAILED_TO_LIST_ENTRIES' });
|
||||
}
|
||||
@@ -260,6 +258,7 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
||||
anilist_id,
|
||||
episode_number,
|
||||
stream_url,
|
||||
duration,
|
||||
is_master,
|
||||
subtitles,
|
||||
chapters
|
||||
@@ -267,7 +266,6 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
||||
|
||||
const clientHeaders = (request.body as any).headers || {};
|
||||
|
||||
// Validación básica
|
||||
if (!anilist_id || !episode_number || !stream_url) {
|
||||
return reply.status(400).send({
|
||||
error: 'MISSING_REQUIRED_FIELDS',
|
||||
@@ -275,26 +273,23 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
||||
});
|
||||
}
|
||||
|
||||
// Proxy del stream URL principal
|
||||
const proxyUrl = buildProxyUrl(stream_url, clientHeaders);
|
||||
console.log('Stream URL:', proxyUrl);
|
||||
|
||||
// Proxy de subtítulos
|
||||
const proxiedSubs = subtitles?.map(sub => ({
|
||||
...sub,
|
||||
url: buildProxyUrl(sub.url, clientHeaders)
|
||||
}));
|
||||
|
||||
// Preparar parámetros base
|
||||
const downloadParams: any = {
|
||||
anilistId: anilist_id,
|
||||
episodeNumber: episode_number,
|
||||
streamUrl: proxyUrl,
|
||||
subtitles: proxiedSubs,
|
||||
chapters
|
||||
chapters,
|
||||
totalDuration: duration
|
||||
};
|
||||
|
||||
// Si es master playlist, agregar campos adicionales
|
||||
if (is_master === true) {
|
||||
const { variant, audio } = request.body as any;
|
||||
|
||||
@@ -306,14 +301,11 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
||||
}
|
||||
|
||||
downloadParams.is_master = true;
|
||||
|
||||
// Proxy del variant playlist
|
||||
downloadParams.variant = {
|
||||
...variant,
|
||||
playlist_url: buildProxyUrl(variant.playlist_url, clientHeaders)
|
||||
};
|
||||
|
||||
// Proxy de audio tracks si existen
|
||||
if (audio && audio.length > 0) {
|
||||
downloadParams.audio = audio.map((a: any) => ({
|
||||
...a,
|
||||
@@ -409,4 +401,85 @@ export async function downloadBook(request: FastifyRequest<{ Body: DownloadBookB
|
||||
|
||||
return reply.status(500).send({ error: 'FAILED_TO_DOWNLOAD_BOOK' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDownloadStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const downloads = downloadService.getActiveDownloads();
|
||||
const streams = streamingService.getActiveStreamsStatus();
|
||||
|
||||
return {
|
||||
downloads: {
|
||||
total: downloads.length,
|
||||
active: downloads.filter(d => d.status === 'downloading').length,
|
||||
completed: downloads.filter(d => d.status === 'completed').length,
|
||||
failed: downloads.filter(d => d.status === 'failed').length,
|
||||
list: downloads
|
||||
},
|
||||
streams: {
|
||||
total: streams.length,
|
||||
active: streams.filter(s => !s.isComplete).length,
|
||||
completed: streams.filter(s => s.isComplete).length,
|
||||
list: streams
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error getting download status:', err);
|
||||
return reply.status(500).send({ error: 'FAILED_TO_GET_DOWNLOAD_STATUS' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAnimeStreamManifest(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { type, id, unit } = request.params as any;
|
||||
|
||||
if (type !== 'anime') {
|
||||
return reply.status(400).send({ error: 'ONLY_ANIME_SUPPORTED' });
|
||||
}
|
||||
|
||||
const fileInfo = await service.getFileForStreaming(id, unit);
|
||||
|
||||
if (!fileInfo) {
|
||||
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
|
||||
}
|
||||
|
||||
const manifest = await streamingService.getStreamingManifest(fileInfo.filePath);
|
||||
|
||||
if (!manifest) {
|
||||
return reply.status(500).send({ error: 'FAILED_TO_GENERATE_MANIFEST' });
|
||||
}
|
||||
|
||||
return manifest;
|
||||
} catch (err: any) {
|
||||
console.error('Error getting stream manifest:', err);
|
||||
return reply.status(500).send({ error: 'FAILED_TO_GET_STREAM_MANIFEST' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function serveHLSFile(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { hash, filename } = request.params as any;
|
||||
|
||||
const file = await streamingService.getHLSFile(hash, filename);
|
||||
|
||||
if (!file) {
|
||||
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
|
||||
}
|
||||
|
||||
const contentType = filename.endsWith('.m3u8')
|
||||
? 'application/vnd.apple.mpegurl'
|
||||
: filename.endsWith('.vtt')
|
||||
? 'text/vtt'
|
||||
: 'video/mp2t';
|
||||
|
||||
reply
|
||||
.header('Content-Type', contentType)
|
||||
.header('Content-Length', file.stat.size)
|
||||
.header('Access-Control-Allow-Origin', '*');
|
||||
|
||||
return fs.createReadStream(file.path);
|
||||
} catch (err) {
|
||||
console.error('Error serving HLS file:', err);
|
||||
return reply.status(500).send({ error: 'FAILED_TO_SERVE_HLS_FILE' });
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ async function localRoutes(fastify: FastifyInstance) {
|
||||
fastify.post('/library/scan', controller.scanLibrary);
|
||||
fastify.get('/library/:type', controller.listEntries);
|
||||
fastify.get('/library/:type/:id', controller.getEntry);
|
||||
|
||||
// Streaming básico (legacy)
|
||||
fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit);
|
||||
fastify.post('/library/:type/:id/match', controller.matchEntry);
|
||||
fastify.get('/library/:id/units', controller.getUnits);
|
||||
@@ -12,6 +14,9 @@ async function localRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/library/:unitId/resource/:resId', controller.getPage);
|
||||
fastify.post('/library/download/anime', controller.downloadAnime);
|
||||
fastify.post('/library/download/book', controller.downloadBook);
|
||||
fastify.get('/library/downloads/status', controller.getDownloadStatus);
|
||||
fastify.get('/library/stream/:type/:id/:unit/manifest', controller.getAnimeStreamManifest);
|
||||
fastify.get('/library/hls/:hash/:filename', controller.serveHLSFile);
|
||||
}
|
||||
|
||||
export default localRoutes;
|
||||
@@ -7,6 +7,7 @@ import path from "path";
|
||||
import { getAnimeById, searchAnimeLocal } from "../anime/anime.service";
|
||||
import { getBookById, searchBooksAniList } from "../books/books.service";
|
||||
import AdmZip from 'adm-zip';
|
||||
import { getStreamHash, getSubtitleFileStream } from './streaming.service';
|
||||
|
||||
const MANGA_IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp'];
|
||||
const MANGA_ARCHIVES = ['.cbz', '.cbr', '.zip'];
|
||||
@@ -186,8 +187,22 @@ export async function performLibraryScan(mode: 'full' | 'incremental' = 'increme
|
||||
}
|
||||
|
||||
export async function getEntriesByType(type: string) {
|
||||
const entries = await queryAll(`SELECT * FROM local_entries WHERE type = ?`, [type], 'local_library');
|
||||
return await Promise.all(entries.map((entry: any) => resolveEntryMetadata(entry, type)));
|
||||
const sql = `
|
||||
SELECT local_entries.*, COUNT(local_files.id) as file_count
|
||||
FROM local_entries
|
||||
LEFT JOIN local_files ON local_entries.id = local_files.entry_id
|
||||
WHERE local_entries.type = ?
|
||||
GROUP BY local_entries.id
|
||||
`;
|
||||
const entries = await queryAll(sql, [type], 'local_library');
|
||||
return await Promise.all(entries.map(async (entry: any) => {
|
||||
const metadata = await resolveEntryMetadata(entry, type);
|
||||
return {
|
||||
...metadata,
|
||||
path: entry.path,
|
||||
files: entry.file_count
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getEntryDetails(type: string, id: string) {
|
||||
|
||||
622
docker/src/api/local/streaming.service.ts
Normal file
622
docker/src/api/local/streaming.service.ts
Normal file
@@ -0,0 +1,622 @@
|
||||
import { getConfig as loadConfig } from '../../shared/config';
|
||||
import { queryOne } from '../../shared/database';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const { values } = loadConfig();
|
||||
const FFMPEG_PATH = values.paths?.ffmpeg || 'ffmpeg';
|
||||
const FFPROBE_PATH = values.paths?.ffprobe || 'ffprobe';
|
||||
|
||||
const STREAM_TTL = 2 * 60 * 60 * 1000;
|
||||
|
||||
type VideoStreamInfo = {
|
||||
index: number;
|
||||
codec: string;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
};
|
||||
|
||||
type AudioStreamInfo = {
|
||||
index: number;
|
||||
codec: string;
|
||||
language?: string;
|
||||
title?: string;
|
||||
channels: number;
|
||||
sampleRate: number;
|
||||
};
|
||||
|
||||
type SubtitleStreamInfo = {
|
||||
index: number;
|
||||
codec: string;
|
||||
language?: string;
|
||||
title?: string;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
type ChapterInfo = {
|
||||
id: number;
|
||||
start: number;
|
||||
end: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type MediaInfo = {
|
||||
video: VideoStreamInfo[];
|
||||
audio: AudioStreamInfo[];
|
||||
subtitles: SubtitleStreamInfo[];
|
||||
chapters: ChapterInfo[];
|
||||
duration: number;
|
||||
};
|
||||
|
||||
type ActiveStream = {
|
||||
hash: string;
|
||||
filePath: string;
|
||||
hlsDir: string;
|
||||
info: MediaInfo;
|
||||
process?: any;
|
||||
startedAt: number;
|
||||
lastAccessed: number;
|
||||
isComplete: boolean;
|
||||
};
|
||||
|
||||
const activeStreams = new Map<string, ActiveStream>();
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [hash, stream] of activeStreams.entries()) {
|
||||
const age = now - stream.lastAccessed;
|
||||
|
||||
if (age > STREAM_TTL) {
|
||||
console.log(`🗑️ Limpiando stream antiguo: ${hash}`);
|
||||
|
||||
if (stream.process && !stream.process.killed) {
|
||||
stream.process.kill('SIGKILL');
|
||||
}
|
||||
|
||||
if (fs.existsSync(stream.hlsDir)) {
|
||||
fs.rmSync(stream.hlsDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
activeStreams.delete(hash);
|
||||
}
|
||||
}
|
||||
}, 60 * 1000);
|
||||
|
||||
export function getStreamHash(filePath: string): string {
|
||||
const stat = fs.statSync(filePath);
|
||||
return crypto
|
||||
.createHash('md5')
|
||||
.update(`${filePath}-${stat.mtime.getTime()}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function ensureHLSDirectory(hash: string): string {
|
||||
const tempDir = path.join(require('os').tmpdir(), 'hls-streams', hash);
|
||||
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
function buildStreamMap(info: MediaInfo): string {
|
||||
const maps: string[] = [];
|
||||
|
||||
maps.push(`v:0,a:0`);
|
||||
|
||||
return maps.join(' ');
|
||||
}
|
||||
|
||||
function writeMasterPlaylist(info: MediaInfo, hlsDir: string) {
|
||||
const lines: string[] = ['#EXTM3U'];
|
||||
|
||||
info.audio.forEach((a, i) => {
|
||||
lines.push(
|
||||
`#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="${a.title || `Audio ${i+1}`}",LANGUAGE="${a.language || 'und'}",AUTOSELECT=YES,DEFAULT=${i===0?'YES':'NO'},URI="audio_${i}.m3u8"`
|
||||
);
|
||||
});
|
||||
|
||||
info.subtitles.forEach((s, i) => {
|
||||
lines.push(
|
||||
`#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="${s.title || `Sub ${i+1}`}",LANGUAGE="${s.language || 'und'}",AUTOSELECT=YES,DEFAULT=NO,URI="subs_${i}.m3u8"`
|
||||
);
|
||||
});
|
||||
|
||||
const v = info.video[0];
|
||||
const bandwidth = v.width * v.height * v.fps * 0.07 | 0;
|
||||
|
||||
lines.push(
|
||||
`#EXT-X-STREAM-INF:BANDWIDTH=${bandwidth},RESOLUTION=${v.width}x${v.height},AUDIO="audio"${info.subtitles.length ? ',SUBTITLES="subs"' : ''}`
|
||||
);
|
||||
lines.push('video.m3u8');
|
||||
|
||||
fs.writeFileSync(path.join(hlsDir, 'master.m3u8'), lines.join('\n'));
|
||||
}
|
||||
|
||||
function startHLSConversion(filePath: string, info: MediaInfo, hash: string): ActiveStream {
|
||||
const hlsDir = ensureHLSDirectory(hash);
|
||||
|
||||
const stream: ActiveStream = {
|
||||
hash,
|
||||
filePath,
|
||||
hlsDir,
|
||||
info,
|
||||
process: null,
|
||||
startedAt: Date.now(),
|
||||
lastAccessed: Date.now(),
|
||||
isComplete: false
|
||||
};
|
||||
|
||||
activeStreams.set(hash, stream);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
|
||||
await extractSubtitles(filePath, info, hlsDir);
|
||||
|
||||
writeMasterPlaylist(info, hlsDir);
|
||||
|
||||
startVideoTranscoding(stream, filePath, info, hlsDir);
|
||||
|
||||
} catch (err) {
|
||||
console.error('❌ Error en el flujo de inicio:', err);
|
||||
}
|
||||
})();
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
function startVideoTranscoding(stream: ActiveStream, filePath: string, info: MediaInfo, hlsDir: string) {
|
||||
const args: string[] = ['-i', filePath];
|
||||
|
||||
args.push(
|
||||
'-map', '0:v:0',
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'veryfast',
|
||||
'-profile:v', 'main',
|
||||
'-g', '48',
|
||||
'-keyint_min', '48',
|
||||
'-sc_threshold', '0',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-f', 'hls',
|
||||
'-hls_time', '4',
|
||||
'-hls_playlist_type', 'event',
|
||||
|
||||
'-hls_flags', 'independent_segments',
|
||||
'-hls_segment_filename', path.join(hlsDir, 'v_%05d.ts'),
|
||||
path.join(hlsDir, 'video.m3u8')
|
||||
);
|
||||
|
||||
info.audio.forEach((a, i) => {
|
||||
args.push(
|
||||
'-map', `0:${a.index}`,
|
||||
`-c:a:${i}`, 'aac',
|
||||
`-b:a:${i}`, '128k',
|
||||
'-f', 'hls',
|
||||
'-hls_time', '4',
|
||||
'-hls_playlist_type', 'event',
|
||||
'-hls_flags', 'independent_segments',
|
||||
'-hls_segment_filename', path.join(hlsDir, `a${i}_%05d.ts`),
|
||||
path.join(hlsDir, `audio_${i}.m3u8`)
|
||||
);
|
||||
});
|
||||
|
||||
console.log('🎬 Starting Video/Audio transcoding:', args.join(' '));
|
||||
|
||||
const ffmpeg = spawn(FFMPEG_PATH, args, {
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
stream.process = ffmpeg;
|
||||
|
||||
ffmpeg.stderr.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
|
||||
if (text.includes('time=')) {
|
||||
const timeMatch = text.match(/time=(\S+)/);
|
||||
if (timeMatch) console.log(`⏱️ Converting ${stream.hash.substr(0,6)}: ${timeMatch[1]}`);
|
||||
}
|
||||
});
|
||||
|
||||
ffmpeg.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
console.log('✅ Video transcoding complete');
|
||||
stream.isComplete = true;
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const SUBTITLE_EXTENSIONS: Record<string, string> = {
|
||||
'ass': 'ass',
|
||||
'ssa': 'ass',
|
||||
'subrip': 'srt',
|
||||
'webvtt': 'vtt',
|
||||
'hdmv_pgs_subtitle': 'sup',
|
||||
'mov_text': 'srt',
|
||||
'dvd_subtitle': 'sub',
|
||||
'text': 'srt'
|
||||
};
|
||||
|
||||
async function extractSubtitles(filePath: string, info: MediaInfo, hlsDir: string): Promise<void> {
|
||||
if (info.subtitles.length === 0) return;
|
||||
|
||||
console.log('📝 Extrayendo subtítulos...');
|
||||
|
||||
const promises = info.subtitles.map((s, i) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const codec = s.codec.toLowerCase();
|
||||
const ext = codec === 'subrip' ? 'srt'
|
||||
: codec === 'ass' || codec === 'ssa' ? 'ass'
|
||||
: codec === 'webvtt' ? 'vtt'
|
||||
: 'sub';
|
||||
|
||||
const outputFilename = `s${i}_full.${ext}`;
|
||||
const outputPath = path.join(hlsDir, outputFilename);
|
||||
const playlistPath = path.join(hlsDir, `subs_${i}.m3u8`);
|
||||
|
||||
if (s.duration === 0) {
|
||||
console.log(`⚠️ Sub vacío, skip: ${i}`);
|
||||
createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration);
|
||||
fs.writeFileSync(outputPath, '');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` Track ${i}: ${s.title || s.language || 'Unknown'} [${codec}] -> ${ext}`);
|
||||
|
||||
const args = [
|
||||
'-i', filePath,
|
||||
'-map', `0:s:${i}`
|
||||
];
|
||||
|
||||
args.push('-c:s', 'copy');
|
||||
args.push('-y', outputPath);
|
||||
|
||||
console.log(` Comando: ffmpeg ${args.join(' ')}`);
|
||||
|
||||
const p = spawn(FFMPEG_PATH, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let errorOutput = '';
|
||||
|
||||
p.stderr.on('data', (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
p.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
try {
|
||||
|
||||
if (!fs.existsSync(outputPath)) {
|
||||
console.error(` ❌ Archivo no creado: ${outputFilename}`);
|
||||
|
||||
fs.writeFileSync(outputPath, '');
|
||||
} else {
|
||||
const stat = fs.statSync(outputPath);
|
||||
if (stat.size === 0) {
|
||||
console.error(` ⚠️ Subtítulo ${i} tiene 0 bytes`);
|
||||
console.error(` FFmpeg stderr:`, errorOutput.slice(-500));
|
||||
} else {
|
||||
console.log(` ✅ Subtítulo ${i} extraído: ${stat.size} bytes`);
|
||||
}
|
||||
}
|
||||
|
||||
createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration);
|
||||
resolve();
|
||||
} catch (e) {
|
||||
console.error(` Error procesando subtítulo ${i}:`, e);
|
||||
resolve();
|
||||
}
|
||||
} else {
|
||||
console.error(` ❌ Error extrayendo subtítulo ${i}. Exit code: ${code}`);
|
||||
console.error(` FFmpeg stderr:`, errorOutput.slice(-500));
|
||||
|
||||
fs.writeFileSync(outputPath, '');
|
||||
createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
p.on('error', (err) => {
|
||||
console.error(` Error spawn ffmpeg subs ${i}:`, err);
|
||||
fs.writeFileSync(outputPath, '');
|
||||
createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
console.log('✅ Proceso de subtítulos finalizado.');
|
||||
}
|
||||
|
||||
function createDummySubtitlePlaylist(playlistPath: string, subtitleFilename: string, duration: number) {
|
||||
const content = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:3',
|
||||
`#EXT-X-TARGETDURATION:${Math.ceil(duration)}`,
|
||||
'#EXT-X-MEDIA-SEQUENCE:0',
|
||||
'#EXT-X-PLAYLIST-TYPE:VOD',
|
||||
`#EXTINF:${duration.toFixed(6)},`,
|
||||
subtitleFilename,
|
||||
'#EXT-X-ENDLIST'
|
||||
].join('\n');
|
||||
|
||||
fs.writeFileSync(playlistPath, content);
|
||||
}
|
||||
|
||||
async function probeMediaFile(filePath: string): Promise<MediaInfo> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
'-v', 'quiet',
|
||||
'-print_format', 'json',
|
||||
'-show_streams',
|
||||
'-show_chapters',
|
||||
'-show_format',
|
||||
filePath
|
||||
];
|
||||
|
||||
const ffprobe = spawn(FFPROBE_PATH, args);
|
||||
let output = '';
|
||||
|
||||
ffprobe.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
ffprobe.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
return reject(new Error(`FFprobe failed with code ${code}`));
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(output);
|
||||
|
||||
const video: VideoStreamInfo[] = data.streams
|
||||
.filter((s: any) => s.codec_type === 'video')
|
||||
.map((s: any) => ({
|
||||
index: s.index,
|
||||
codec: s.codec_name,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
fps: eval(s.r_frame_rate) || 24
|
||||
}));
|
||||
|
||||
const audio: AudioStreamInfo[] = data.streams
|
||||
.filter((s: any) => s.codec_type === 'audio')
|
||||
.map((s: any) => ({
|
||||
index: s.index,
|
||||
codec: s.codec_name,
|
||||
language: s.tags?.language,
|
||||
title: s.tags?.title,
|
||||
channels: s.channels || 2,
|
||||
sampleRate: parseInt(s.sample_rate) || 48000
|
||||
}));
|
||||
|
||||
const subtitles: SubtitleStreamInfo[] = data.streams
|
||||
.filter((s: any) => s.codec_type === 'subtitle')
|
||||
.map((s: any) => ({
|
||||
index: s.index,
|
||||
codec: s.codec_name,
|
||||
language: s.tags?.language,
|
||||
title: s.tags?.title,
|
||||
duration: parseFloat(s.duration) || 0
|
||||
}));
|
||||
|
||||
const chapters: ChapterInfo[] = (data.chapters || []).map((c: any) => ({
|
||||
id: c.id,
|
||||
start: parseFloat(c.start_time),
|
||||
end: parseFloat(c.end_time),
|
||||
title: c.tags?.title || `Chapter ${c.id + 1}`
|
||||
}));
|
||||
|
||||
const duration = parseFloat(data.format?.duration) || 0;
|
||||
|
||||
console.log(`📊 Media info: ${video.length} video, ${audio.length} audio, ${subtitles.length} subs`);
|
||||
|
||||
resolve({ video, audio, subtitles, chapters, duration });
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
ffprobe.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getStreamingManifest(filePath: string) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hash = getStreamHash(filePath);
|
||||
|
||||
const formatSubtitles = (subs: SubtitleStreamInfo[]) => {
|
||||
return subs.map((s, i) => {
|
||||
const ext = SUBTITLE_EXTENSIONS[s.codec] || 'vtt';
|
||||
return {
|
||||
index: s.index,
|
||||
codec: s.codec,
|
||||
language: s.language || 'und',
|
||||
title: s.title || `Subtitle ${s.index}`,
|
||||
|
||||
url: `/api/library/hls/${hash}/s${i}_full.${ext}`
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const existing = activeStreams.get(hash);
|
||||
if (existing) {
|
||||
existing.lastAccessed = Date.now();
|
||||
|
||||
return {
|
||||
type: 'hls',
|
||||
hash,
|
||||
masterPlaylist: `/api/library/hls/${hash}/master.m3u8`,
|
||||
duration: existing.info.duration,
|
||||
isComplete: existing.isComplete,
|
||||
video: existing.info.video.map(v => ({
|
||||
index: v.index,
|
||||
codec: v.codec,
|
||||
resolution: `${v.width}x${v.height}`,
|
||||
fps: v.fps
|
||||
})),
|
||||
audio: existing.info.audio.map(a => ({
|
||||
index: a.index,
|
||||
codec: a.codec,
|
||||
language: a.language || 'und',
|
||||
title: a.title || `Audio ${a.index}`,
|
||||
channels: a.channels
|
||||
})),
|
||||
|
||||
subtitles: formatSubtitles(existing.info.subtitles),
|
||||
chapters: existing.info.chapters.map(c => ({
|
||||
id: c.id,
|
||||
start: c.start,
|
||||
end: c.end,
|
||||
title: c.title
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
const duplicateCheck = activeStreams.get(hash);
|
||||
|
||||
if (duplicateCheck) {
|
||||
duplicateCheck.lastAccessed = Date.now();
|
||||
return {
|
||||
type: 'hls',
|
||||
hash,
|
||||
masterPlaylist: `/api/library/hls/${hash}/master.m3u8`,
|
||||
duration: duplicateCheck.info.duration,
|
||||
isComplete: duplicateCheck.isComplete,
|
||||
video: duplicateCheck.info.video.map(v => ({
|
||||
index: v.index,
|
||||
codec: v.codec,
|
||||
resolution: `${v.width}x${v.height}`,
|
||||
fps: v.fps
|
||||
})),
|
||||
audio: duplicateCheck.info.audio.map(a => ({
|
||||
index: a.index,
|
||||
codec: a.codec,
|
||||
language: a.language || 'und',
|
||||
title: a.title || `Audio ${a.index}`,
|
||||
channels: a.channels
|
||||
})),
|
||||
subtitles: formatSubtitles(duplicateCheck.info.subtitles),
|
||||
chapters: duplicateCheck.info.chapters.map(c => ({
|
||||
id: c.id,
|
||||
start: c.start,
|
||||
end: c.end,
|
||||
title: c.title
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
const info = await probeMediaFile(filePath);
|
||||
return {
|
||||
type: 'hls',
|
||||
hash,
|
||||
masterPlaylist: `/api/library/hls/${hash}/master.m3u8`,
|
||||
duration: info.duration,
|
||||
isComplete: false,
|
||||
generating: true,
|
||||
video: info.video.map(v => ({
|
||||
index: v.index,
|
||||
codec: v.codec,
|
||||
resolution: `${v.width}x${v.height}`,
|
||||
fps: v.fps
|
||||
})),
|
||||
audio: info.audio.map(a => ({
|
||||
index: a.index,
|
||||
codec: a.codec,
|
||||
language: a.language || 'und',
|
||||
title: a.title || `Audio ${a.index}`,
|
||||
channels: a.channels
|
||||
})),
|
||||
subtitles: formatSubtitles(info.subtitles),
|
||||
chapters: info.chapters.map(c => ({
|
||||
id: c.id,
|
||||
start: c.start,
|
||||
end: c.end,
|
||||
title: c.title
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export function getSubtitleFileStream(hash: string, trackIndex: number) {
|
||||
const stream = activeStreams.get(hash);
|
||||
|
||||
if (!stream) {
|
||||
|
||||
const tempDir = path.join(require('os').tmpdir(), 'hls-streams', hash);
|
||||
if(fs.existsSync(tempDir)) {
|
||||
|
||||
const files = fs.readdirSync(tempDir);
|
||||
const subFile = files.find(f => f.startsWith(`s${trackIndex}_full.`));
|
||||
if (subFile) return fs.createReadStream(path.join(tempDir, subFile));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(stream.hlsDir)) return null;
|
||||
|
||||
const files = fs.readdirSync(stream.hlsDir);
|
||||
const subtitleFile = files.find(f => f.startsWith(`s${trackIndex}_full.`));
|
||||
|
||||
if (!subtitleFile) return null;
|
||||
|
||||
return fs.createReadStream(path.join(stream.hlsDir, subtitleFile));
|
||||
}
|
||||
|
||||
export async function getHLSFile(hash: string, filename: string) {
|
||||
const stream = activeStreams.get(hash);
|
||||
|
||||
if (!stream) {
|
||||
return null;
|
||||
}
|
||||
|
||||
stream.lastAccessed = Date.now();
|
||||
|
||||
const filePath = path.join(stream.hlsDir, filename);
|
||||
|
||||
const maxWait = 30000;
|
||||
|
||||
const startWait = Date.now();
|
||||
|
||||
while (!fs.existsSync(filePath)) {
|
||||
if (Date.now() - startWait > maxWait) {
|
||||
console.error(`⏱️ Timeout esperando archivo: ${filename}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stream.isComplete && !fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
stat: fs.statSync(filePath)
|
||||
};
|
||||
}
|
||||
|
||||
export function getActiveStreamsStatus() {
|
||||
return Array.from(activeStreams.values()).map(stream => ({
|
||||
hash: stream.hash,
|
||||
filePath: stream.filePath,
|
||||
isComplete: stream.isComplete,
|
||||
startedAt: stream.startedAt,
|
||||
lastAccessed: stream.lastAccessed,
|
||||
age: Date.now() - stream.startedAt,
|
||||
idle: Date.now() - stream.lastAccessed
|
||||
}));
|
||||
}
|
||||
158
docker/src/api/rooms/rooms.controller.ts
Normal file
158
docker/src/api/rooms/rooms.controller.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import * as roomService from './rooms.service';
|
||||
import { getUserById } from '../user/user.service';
|
||||
import { openTunnel } from "./tunnel.manager";
|
||||
|
||||
interface CreateRoomBody {
|
||||
name: string;
|
||||
password?: string;
|
||||
expose?: boolean;
|
||||
}
|
||||
|
||||
export async function createRoom(req: any, reply: FastifyReply) {
|
||||
try {
|
||||
const { name, password, expose } = req.body as CreateRoomBody;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: "Authentication required to create room" });
|
||||
}
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
return reply.code(400).send({ error: "Room name is required" });
|
||||
}
|
||||
|
||||
const user = await getUserById(userId);
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: "User not found" });
|
||||
}
|
||||
|
||||
const host = {
|
||||
id: `user_${userId}`,
|
||||
username: user.username,
|
||||
avatar: user.profile_picture_url || undefined,
|
||||
isHost: true,
|
||||
isGuest: false,
|
||||
userId
|
||||
};
|
||||
|
||||
let publicUrl: string | undefined;
|
||||
|
||||
if (expose) {
|
||||
publicUrl = await openTunnel();
|
||||
}
|
||||
|
||||
const room = roomService.createRoom(
|
||||
name,
|
||||
host,
|
||||
password,
|
||||
!!expose,
|
||||
publicUrl
|
||||
);
|
||||
|
||||
if (expose && publicUrl) {
|
||||
room.publicUrl = `${publicUrl}/room?id=${room.id}`;
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
room: {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
hasPassword: !!room.password,
|
||||
userCount: room.users.size,
|
||||
exposed: room.exposed,
|
||||
publicUrl: room.publicUrl
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Create Room Error:", err);
|
||||
return reply.code(500).send({ error: "Failed to create room" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRooms(req: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const rooms = roomService.getAllRooms();
|
||||
|
||||
const roomList = rooms.map((room) => ({
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
host: room.host.username,
|
||||
userCount: room.users.size,
|
||||
hasPassword: !!room.password,
|
||||
currentlyWatching: room.currentVideo ? {
|
||||
animeId: room.currentVideo.animeId,
|
||||
episode: room.currentVideo.episode
|
||||
} : null
|
||||
}));
|
||||
|
||||
return reply.send({ rooms: roomList });
|
||||
} catch (err) {
|
||||
console.error("Get Rooms Error:", err);
|
||||
return reply.code(500).send({ error: "Failed to retrieve rooms" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRoom(req: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { id } = req.params as { id: string };
|
||||
const room = roomService.getRoom(id);
|
||||
|
||||
if (!room) {
|
||||
return reply.code(404).send({ error: "Room not found" });
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
room: {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
host: {
|
||||
username: room.host.username,
|
||||
avatar: room.host.avatar
|
||||
},
|
||||
users: Array.from(room.users.values()).map(u => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
avatar: u.avatar,
|
||||
isHost: u.isHost,
|
||||
isGuest: u.isGuest
|
||||
})),
|
||||
hasPassword: !!room.password,
|
||||
currentVideo: room.currentVideo,
|
||||
exposed: room.exposed,
|
||||
publicUrl: room.publicUrl
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Get Room Error:", err);
|
||||
return reply.code(500).send({ error: "Failed to retrieve room" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRoom(req: any, reply: FastifyReply) {
|
||||
try {
|
||||
const { id } = req.params as { id: string };
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
const room = roomService.getRoom(id);
|
||||
if (!room) {
|
||||
return reply.code(404).send({ error: "Room not found" });
|
||||
}
|
||||
|
||||
if (room.host.userId !== userId) {
|
||||
return reply.code(403).send({ error: "Only the host can delete the room" });
|
||||
}
|
||||
|
||||
roomService.deleteRoom(id);
|
||||
|
||||
return reply.send({ success: true });
|
||||
} catch (err) {
|
||||
console.error("Delete Room Error:", err);
|
||||
return reply.code(500).send({ error: "Failed to delete room" });
|
||||
}
|
||||
}
|
||||
11
docker/src/api/rooms/rooms.routes.ts
Normal file
11
docker/src/api/rooms/rooms.routes.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import * as controller from './rooms.controller';
|
||||
|
||||
async function roomRoutes(fastify: FastifyInstance) {
|
||||
fastify.post('/rooms', controller.createRoom);
|
||||
fastify.get('/rooms', controller.getRooms);
|
||||
fastify.get('/rooms/:id', controller.getRoom);
|
||||
fastify.delete('/rooms/:id', controller.deleteRoom);
|
||||
}
|
||||
|
||||
export default roomRoutes;
|
||||
296
docker/src/api/rooms/rooms.service.ts
Normal file
296
docker/src/api/rooms/rooms.service.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import crypto from 'crypto';
|
||||
import { closeTunnelIfUnused } from "./tunnel.manager";
|
||||
|
||||
interface RoomPermissions {
|
||||
canControl: boolean;
|
||||
canManageQueue: boolean;
|
||||
}
|
||||
|
||||
interface RoomUser {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar?: string;
|
||||
isHost: boolean;
|
||||
isGuest: boolean;
|
||||
userId?: number;
|
||||
permissions?: RoomPermissions;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
interface SourceContext {
|
||||
animeId: string;
|
||||
episode: string | number;
|
||||
source: string;
|
||||
extension: string;
|
||||
server: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
uid: string;
|
||||
metadata: RoomMetadata;
|
||||
videoData: any;
|
||||
addedBy: string;
|
||||
}
|
||||
|
||||
interface RoomMetadata {
|
||||
id: string;
|
||||
title: string;
|
||||
episode: number;
|
||||
image?: string;
|
||||
source?: string;
|
||||
malId?: number;
|
||||
}
|
||||
|
||||
interface RoomData {
|
||||
id: string;
|
||||
name: string;
|
||||
host: RoomUser;
|
||||
users: Map<string, RoomUser>;
|
||||
createdAt: number;
|
||||
currentVideo: {
|
||||
animeId?: number;
|
||||
episode?: number;
|
||||
source?: string;
|
||||
videoData?: any;
|
||||
currentTime: number;
|
||||
isPlaying: boolean;
|
||||
context?: SourceContext;
|
||||
} | null;
|
||||
password?: string;
|
||||
metadata?: RoomMetadata | null;
|
||||
exposed: boolean;
|
||||
publicUrl?: string;
|
||||
queue: QueueItem[];
|
||||
bannedIPs: Set<string>;
|
||||
}
|
||||
|
||||
export const DEFAULT_GUEST_PERMISSIONS: RoomPermissions = {
|
||||
canControl: false,
|
||||
canManageQueue: false
|
||||
};
|
||||
|
||||
const rooms = new Map<string, RoomData>();
|
||||
|
||||
export function generateRoomId(): string {
|
||||
return crypto.randomBytes(8).toString('hex');
|
||||
}
|
||||
|
||||
export function createRoom(name: string, host: RoomUser, password?: string, exposed = false, publicUrl?: string): RoomData {
|
||||
const roomId = generateRoomId();
|
||||
|
||||
const room: RoomData = {
|
||||
id: roomId,
|
||||
name,
|
||||
host,
|
||||
users: new Map([[host.id, host]]),
|
||||
createdAt: Date.now(),
|
||||
currentVideo: null,
|
||||
password: password || undefined,
|
||||
metadata: null,
|
||||
exposed,
|
||||
publicUrl,
|
||||
queue: [],
|
||||
bannedIPs: new Set() // NUEVO
|
||||
};
|
||||
|
||||
rooms.set(roomId, room);
|
||||
return room;
|
||||
}
|
||||
|
||||
export function updateUserPermissions(roomId: string, userId: string, permissions: Partial<RoomPermissions>): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
const user: any = room.users.get(userId);
|
||||
if (!user) return false;
|
||||
|
||||
if (user.isHost) return false;
|
||||
|
||||
user.permissions = {
|
||||
...user.permissions,
|
||||
...permissions
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function banUserIP(roomId: string, ipAddress: string): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
room.bannedIPs.add(ipAddress);
|
||||
|
||||
// Remover a todos los usuarios con esa IP
|
||||
Array.from(room.users.values()).forEach(user => {
|
||||
if (user.ipAddress === ipAddress) {
|
||||
removeUserFromRoom(roomId, user.id);
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function unbanUserIP(roomId: string, ipAddress: string): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
return room.bannedIPs.delete(ipAddress);
|
||||
}
|
||||
|
||||
export function isIPBanned(roomId: string, ipAddress: string): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
return room.bannedIPs.has(ipAddress);
|
||||
}
|
||||
|
||||
export function getBannedIPs(roomId: string): string[] {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return [];
|
||||
return Array.from(room.bannedIPs);
|
||||
}
|
||||
|
||||
export function hasPermission(roomId: string, userId: string, permission: keyof RoomPermissions): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
const user = room.users.get(userId);
|
||||
if (!user) return false;
|
||||
|
||||
// El host siempre tiene todos los permisos
|
||||
if (user.isHost) return true;
|
||||
|
||||
// Si no tiene permisos definidos, usar defaults
|
||||
const userPerms = user.permissions || DEFAULT_GUEST_PERMISSIONS;
|
||||
return userPerms[permission] || false;
|
||||
}
|
||||
|
||||
export function getRoom(roomId: string): RoomData | null {
|
||||
return rooms.get(roomId) || null;
|
||||
}
|
||||
|
||||
export function getAllRooms(): RoomData[] {
|
||||
return Array.from(rooms.values()).map(room => ({
|
||||
...room,
|
||||
users: room.users
|
||||
}));
|
||||
}
|
||||
|
||||
export function addQueueItem(roomId: string, item: QueueItem): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
room.queue.push(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeQueueItem(roomId: string, itemUid: string): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
room.queue = room.queue.filter(i => i.uid !== itemUid);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getNextQueueItem(roomId: string): QueueItem | undefined {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room || room.queue.length === 0) return undefined;
|
||||
return room.queue.shift(); // Saca el primero y lo retorna
|
||||
}
|
||||
|
||||
export function getAndRemoveQueueItem(roomId: string, itemUid: string): QueueItem | undefined {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return undefined;
|
||||
|
||||
const index = room.queue.findIndex(i => i.uid === itemUid);
|
||||
if (index === -1) return undefined;
|
||||
|
||||
const [item] = room.queue.splice(index, 1);
|
||||
return item;
|
||||
}
|
||||
|
||||
export function moveQueueItem(roomId: string, itemUid: string, direction: 'up' | 'down'): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
const index = room.queue.findIndex(i => i.uid === itemUid);
|
||||
if (index === -1) return false;
|
||||
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
|
||||
if (newIndex < 0 || newIndex >= room.queue.length) return false;
|
||||
|
||||
const temp = room.queue[newIndex];
|
||||
room.queue[newIndex] = room.queue[index];
|
||||
room.queue[index] = temp;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function addUserToRoom(roomId: string, user: RoomUser): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
room.users.set(user.id, user);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeUserFromRoom(roomId: string, userId: string): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
room.users.delete(userId);
|
||||
|
||||
if (room.users.size === 0) {
|
||||
if (room.exposed) {
|
||||
closeTunnelIfUnused();
|
||||
}
|
||||
rooms.delete(roomId);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (room.host.id === userId && room.users.size > 0) {
|
||||
const newHost = Array.from(room.users.values())[0];
|
||||
newHost.isHost = true;
|
||||
room.host = newHost;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function updateRoomVideo(roomId: string, videoData: any): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
room.currentVideo = {
|
||||
...room.currentVideo,
|
||||
...videoData
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
export function deleteRoom(roomId: string): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
if (room.exposed) {
|
||||
closeTunnelIfUnused();
|
||||
}
|
||||
|
||||
return rooms.delete(roomId);
|
||||
}
|
||||
|
||||
export function verifyRoomPassword(roomId: string, password?: string): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
if (!room.password) return true;
|
||||
if (!password) return false;
|
||||
|
||||
return room.password === password;
|
||||
}
|
||||
|
||||
export function updateRoomMetadata(roomId: string, metadata: any): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
room.metadata = metadata;
|
||||
return true;
|
||||
}
|
||||
588
docker/src/api/rooms/rooms.websocket.ts
Normal file
588
docker/src/api/rooms/rooms.websocket.ts
Normal file
@@ -0,0 +1,588 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import * as roomService from './rooms.service';
|
||||
import { getUserById } from '../user/user.service';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import {getConfig} from '../../shared/config';
|
||||
const { values } = getConfig();
|
||||
const jwtSecret = values.server?.jwt_secret;
|
||||
|
||||
interface WSClient {
|
||||
socket: any;
|
||||
userId: string;
|
||||
username: string;
|
||||
roomId: string;
|
||||
isGuest: boolean;
|
||||
}
|
||||
|
||||
const clients = new Map<string, WSClient>();
|
||||
|
||||
|
||||
function getClientIP(req: any): string {
|
||||
return req.headers['x-forwarded-for']?.split(',')[0].trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection?.remoteAddress ||
|
||||
req.socket?.remoteAddress ||
|
||||
'unknown';
|
||||
}
|
||||
|
||||
export function setupRoomWebSocket(fastify: FastifyInstance) {
|
||||
// @ts-ignore
|
||||
fastify.get('/ws/room/:roomId', { websocket: true }, (connection: any, req: any) => {
|
||||
handleWebSocketConnection(connection, req).catch(err => {
|
||||
console.error('WebSocket error:', err);
|
||||
try {
|
||||
connection.socket.close();
|
||||
} catch (e) {
|
||||
// Socket already closed
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleWebSocketConnection(connection: any, req: any) {
|
||||
const socket = connection.socket || connection;
|
||||
const roomId = req.params.roomId;
|
||||
const token = req.query.token;
|
||||
const guestName = req.query.guestName;
|
||||
const password = req.query.password;
|
||||
const clientIP = getClientIP(req);
|
||||
|
||||
let userId: string;
|
||||
let username: string;
|
||||
let avatar: string | undefined;
|
||||
let isGuest = false;
|
||||
let realUserId: any;
|
||||
|
||||
const room = roomService.getRoom(roomId);
|
||||
|
||||
// 1. Validaciones básicas de existencia y Ban
|
||||
if (!room) {
|
||||
socket.send(JSON.stringify({ type: 'error', message: 'Room not found' }));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (roomService.isIPBanned(roomId, clientIP)) {
|
||||
socket.send(JSON.stringify({ type: 'error', message: 'You have been banned from this room' }));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. MOVIDO ARRIBA: Autenticar usuario PRIMERO para saber quién es
|
||||
if (token) {
|
||||
try {
|
||||
const decoded: any = jwt.verify(token, jwtSecret);
|
||||
realUserId = decoded.id;
|
||||
const user = await getUserById(realUserId);
|
||||
|
||||
if (user) {
|
||||
userId = `user_${realUserId}`;
|
||||
username = user.username;
|
||||
avatar = user.profile_picture_url || undefined;
|
||||
isGuest = false;
|
||||
} else {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
} catch (err) {
|
||||
socket.send(JSON.stringify({ type: 'error', message: 'Invalid token' }));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
} else if (guestName && guestName.trim()) {
|
||||
// ... (Lógica de Guest se mantiene igual) ...
|
||||
const nameToCheck = guestName.trim();
|
||||
const isNameTaken = Array.from(room.users.values()).some(
|
||||
u => u.username.toLowerCase() === nameToCheck.toLowerCase()
|
||||
);
|
||||
if (isNameTaken) {
|
||||
socket.send(JSON.stringify({ type: 'error', message: 'Username is already taken' }));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
userId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
username = nameToCheck;
|
||||
isGuest = true;
|
||||
} else {
|
||||
socket.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Determinar si es Host
|
||||
const isHost = room.host.userId === realUserId || room.host.id === userId;
|
||||
|
||||
// 4. MOVIDO ABAJO: Validar contraseña SOLO SI NO ES HOST
|
||||
if (room.password && !isHost) {
|
||||
if (!password || !roomService.verifyRoomPassword(roomId, password)) {
|
||||
socket.send(JSON.stringify({ type: 'error', message: 'Invalid password' }));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const userInRoom = {
|
||||
id: userId,
|
||||
username,
|
||||
avatar,
|
||||
isHost: isHost,
|
||||
isGuest,
|
||||
userId: realUserId,
|
||||
ipAddress: clientIP, // NUEVO
|
||||
permissions: isHost ? undefined : { ...roomService.DEFAULT_GUEST_PERMISSIONS }
|
||||
};
|
||||
|
||||
roomService.addUserToRoom(roomId, userInRoom);
|
||||
|
||||
// Registrar cliente
|
||||
clients.set(userId, {
|
||||
socket: socket,
|
||||
userId,
|
||||
username,
|
||||
roomId,
|
||||
isGuest
|
||||
});
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
type: 'init',
|
||||
userId,
|
||||
username,
|
||||
isGuest,
|
||||
isHost, // NUEVO: Enviar explícitamente
|
||||
room: {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
users: Array.from(room.users.values()).map(u => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
avatar: u.avatar,
|
||||
isHost: u.isHost,
|
||||
isGuest: u.isGuest,
|
||||
permissions: u.permissions // NUEVO
|
||||
})),
|
||||
currentVideo: room.currentVideo,
|
||||
queue: room.queue || []
|
||||
}
|
||||
}));
|
||||
|
||||
// Notificar a otros usuarios
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'user_joined',
|
||||
user: {
|
||||
id: userId,
|
||||
username,
|
||||
avatar,
|
||||
isGuest
|
||||
}
|
||||
}, userId);
|
||||
|
||||
// Manejar mensajes
|
||||
socket.on('message', (message: Buffer) => {
|
||||
try {
|
||||
const data = JSON.parse(message.toString());
|
||||
handleMessage(roomId, userId, data);
|
||||
} catch (err) {
|
||||
console.error('WebSocket message error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// Manejar desconexión
|
||||
socket.on('close', () => {
|
||||
clients.delete(userId);
|
||||
roomService.removeUserFromRoom(roomId, userId);
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'user_left',
|
||||
user: { userId, username }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleMessage(roomId: string, userId: string, data: any) {
|
||||
const room = roomService.getRoom(roomId);
|
||||
if (!room) return;
|
||||
|
||||
const user = room.users.get(userId);
|
||||
if (!user) return;
|
||||
|
||||
console.log('Handling message:', data.type, 'from user:', userId, 'isHost:', room.host.id === userId);
|
||||
|
||||
switch (data.type) {
|
||||
case 'chat':
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'chat',
|
||||
userId,
|
||||
username: room.users.get(userId)?.username || 'Unknown',
|
||||
avatar: room.users.get(userId)?.avatar,
|
||||
message: data.message,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
break;
|
||||
|
||||
case 'update_permissions':
|
||||
if (!user.isHost) {
|
||||
console.warn('Non-host attempted to update permissions');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = roomService.updateUserPermissions(
|
||||
roomId,
|
||||
data.targetUserId,
|
||||
data.permissions
|
||||
);
|
||||
|
||||
if (success) {
|
||||
const updatedRoom = roomService.getRoom(roomId);
|
||||
if (updatedRoom) {
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'permissions_updated',
|
||||
users: Array.from(updatedRoom.users.values()).map(u => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
avatar: u.avatar,
|
||||
isHost: u.isHost,
|
||||
isGuest: u.isGuest,
|
||||
permissions: u.permissions
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
// NUEVO: Baneo de usuarios (solo host)
|
||||
case 'ban_user':
|
||||
if (!user.isHost) {
|
||||
console.warn('Non-host attempted to ban user');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUser = room.users.get(data.targetUserId);
|
||||
if (targetUser && targetUser.ipAddress) {
|
||||
roomService.banUserIP(roomId, targetUser.ipAddress);
|
||||
|
||||
// Cerrar conexión del usuario baneado
|
||||
const targetClient = clients.get(data.targetUserId);
|
||||
if (targetClient && targetClient.socket) {
|
||||
targetClient.socket.send(JSON.stringify({
|
||||
type: 'banned',
|
||||
message: 'You have been banned from this room'
|
||||
}));
|
||||
targetClient.socket.close();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
case 'queue_play_item':
|
||||
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
|
||||
console.warn('User lacks permission for queue management');
|
||||
return;
|
||||
}
|
||||
|
||||
const itemToPlay = roomService.getAndRemoveQueueItem(roomId, data.itemUid);
|
||||
if (itemToPlay) {
|
||||
const videoPayload = {
|
||||
videoData: itemToPlay.videoData.videoData,
|
||||
subtitles: itemToPlay.videoData.subtitles,
|
||||
currentTime: 0,
|
||||
isPlaying: true
|
||||
};
|
||||
|
||||
roomService.updateRoomVideo(roomId, videoPayload);
|
||||
roomService.updateRoomMetadata(roomId, itemToPlay.metadata);
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'video_update',
|
||||
video: videoPayload,
|
||||
metadata: itemToPlay.metadata
|
||||
});
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'queue_update',
|
||||
queue: room.queue
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'queue_move':
|
||||
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const moved = roomService.moveQueueItem(roomId, data.itemUid, data.direction);
|
||||
if (moved) {
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'queue_update',
|
||||
queue: room.queue
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'request_sync':
|
||||
// Cualquier usuario puede pedir sync
|
||||
const host = clients.get(room.host.id);
|
||||
if (host && host.socket && host.socket.readyState === 1) {
|
||||
console.log(`[Sync Request] User ${userId} requested sync from host`);
|
||||
|
||||
host.socket.send(JSON.stringify({
|
||||
type: 'sync_requested',
|
||||
requestedBy: userId,
|
||||
username: room.users.get(userId)?.username
|
||||
}));
|
||||
} else {
|
||||
console.warn(`[Sync Request] Host not available for user ${userId}`);
|
||||
|
||||
if (room.currentVideo) {
|
||||
const client = clients.get(userId);
|
||||
if (client && client.socket && client.socket.readyState === 1) {
|
||||
console.log(`[Sync Request] Sending cached video state to ${userId}`);
|
||||
client.socket.send(JSON.stringify({
|
||||
type: 'sync',
|
||||
currentTime: room.currentVideo.currentTime || 0,
|
||||
isPlaying: room.currentVideo.isPlaying || false
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'video_update':
|
||||
const canUpdateVideo = user.isHost ||
|
||||
roomService.hasPermission(roomId, userId, 'canControl') ||
|
||||
roomService.hasPermission(roomId, userId, 'canManageQueue');
|
||||
|
||||
if (!canUpdateVideo) {
|
||||
console.warn('User lacks permissions to update video');
|
||||
return;
|
||||
}
|
||||
|
||||
roomService.updateRoomVideo(roomId, data.video);
|
||||
roomService.updateRoomMetadata(roomId, data.metadata);
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'video_update',
|
||||
video: data.video,
|
||||
metadata: data.metadata
|
||||
});
|
||||
break;
|
||||
|
||||
case 'queue_add_batch':
|
||||
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(data.items)) {
|
||||
data.items.forEach((item: any, i: number) => {
|
||||
const newItem = {
|
||||
uid: `q_${Date.now()}_${i}_${Math.random().toString(36).substr(2, 5)}`,
|
||||
metadata: item.metadata,
|
||||
videoData: item.video,
|
||||
addedBy: room.users.get(userId)?.username || 'Unknown'
|
||||
};
|
||||
roomService.addQueueItem(roomId, newItem);
|
||||
});
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'queue_update',
|
||||
queue: room.queue
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'sync':
|
||||
if (room.host.id !== userId) return;
|
||||
|
||||
if (room.currentVideo) {
|
||||
room.currentVideo.currentTime = data.currentTime;
|
||||
room.currentVideo.isPlaying = data.isPlaying;
|
||||
}
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'sync',
|
||||
currentTime: data.currentTime,
|
||||
isPlaying: data.isPlaying
|
||||
}, userId);
|
||||
break;
|
||||
|
||||
case 'request_users':
|
||||
const currentRoom = roomService.getRoom(roomId);
|
||||
if (currentRoom) {
|
||||
const client = clients.get(userId);
|
||||
if (client && client.socket && client.socket.readyState === 1) {
|
||||
client.socket.send(JSON.stringify({
|
||||
type: 'users_update', // Nuevo tipo de respuesta
|
||||
users: Array.from(currentRoom.users.values()).map(u => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
avatar: u.avatar,
|
||||
isHost: u.isHost,
|
||||
isGuest: u.isGuest
|
||||
}))
|
||||
}));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'play':
|
||||
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canControl')) {
|
||||
console.warn('User lacks control permissions');
|
||||
return;
|
||||
}
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'play',
|
||||
currentTime: data.currentTime,
|
||||
timestamp: Date.now()
|
||||
}, userId);
|
||||
break;
|
||||
|
||||
case 'pause':
|
||||
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canControl')) {
|
||||
console.warn('User lacks control permissions for pause');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Broadcasting pause event to room:', roomId);
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'pause',
|
||||
currentTime: data.currentTime,
|
||||
timestamp: Date.now()
|
||||
}, userId);
|
||||
break;
|
||||
|
||||
case 'seek':
|
||||
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canControl')) {
|
||||
console.warn('User lacks control permissions for seek');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Broadcasting seek event to room:', roomId);
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'seek',
|
||||
currentTime: data.currentTime,
|
||||
timestamp: Date.now()
|
||||
}, userId);
|
||||
break;
|
||||
|
||||
case 'request_state':
|
||||
const client = clients.get(userId);
|
||||
if (client && client.socket && client.socket.readyState === 1) {
|
||||
const updatedRoom = roomService.getRoom(roomId);
|
||||
if (updatedRoom) {
|
||||
client.socket.send(JSON.stringify({
|
||||
type: 'init',
|
||||
userId,
|
||||
username: client.username,
|
||||
isGuest: client.isGuest,
|
||||
room: {
|
||||
id: updatedRoom.id,
|
||||
name: updatedRoom.name,
|
||||
users: Array.from(updatedRoom.users.values()).map(u => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
avatar: u.avatar,
|
||||
isHost: u.isHost,
|
||||
isGuest: u.isGuest
|
||||
})),
|
||||
currentVideo: room.currentVideo,
|
||||
metadata: room.metadata
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'queue_add':
|
||||
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newItem = {
|
||||
uid: `q_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
|
||||
metadata: data.metadata,
|
||||
videoData: data.video,
|
||||
addedBy: room.users.get(userId)?.username || 'Unknown'
|
||||
};
|
||||
|
||||
roomService.addQueueItem(roomId, newItem);
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'queue_update',
|
||||
queue: room.queue
|
||||
});
|
||||
break;
|
||||
|
||||
case 'queue_remove':
|
||||
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
|
||||
return;
|
||||
}
|
||||
|
||||
roomService.removeQueueItem(roomId, data.itemUid);
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'queue_update',
|
||||
queue: room.queue
|
||||
});
|
||||
break;
|
||||
|
||||
case 'play_next':
|
||||
if (room.host.id !== userId) return;
|
||||
|
||||
const nextItem = roomService.getNextQueueItem(roomId);
|
||||
if (nextItem) {
|
||||
const videoPayload = {
|
||||
videoData: nextItem.videoData.videoData,
|
||||
subtitles: nextItem.videoData.subtitles,
|
||||
currentTime: 0,
|
||||
isPlaying: true
|
||||
};
|
||||
|
||||
roomService.updateRoomVideo(roomId, videoPayload);
|
||||
roomService.updateRoomMetadata(roomId, nextItem.metadata);
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'video_update',
|
||||
video: videoPayload,
|
||||
metadata: nextItem.metadata
|
||||
});
|
||||
|
||||
broadcastToRoom(roomId, {
|
||||
type: 'queue_update',
|
||||
queue: room.queue
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Unknown message type:', data.type);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastToRoom(roomId: string, message: any, excludeUserId?: string) {
|
||||
const room = roomService.getRoom(roomId);
|
||||
if (!room) return;
|
||||
|
||||
const messageStr = JSON.stringify(message);
|
||||
|
||||
console.log('Broadcasting to room:', roomId, 'message type:', message.type, 'excluding:', excludeUserId);
|
||||
|
||||
let sentCount = 0;
|
||||
room.users.forEach((user) => {
|
||||
if (user.id !== excludeUserId) {
|
||||
const client = clients.get(user.id);
|
||||
if (client && client.socket && client.socket.readyState === 1) {
|
||||
try {
|
||||
client.socket.send(messageStr);
|
||||
sentCount++;
|
||||
console.log('Sent to user:', user.id, user.username);
|
||||
} catch (err) {
|
||||
console.error('Error sending message to user:', user.id, err);
|
||||
}
|
||||
} else {
|
||||
console.warn('User socket not ready:', user.id, 'readyState:', client?.socket?.readyState);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Broadcast complete: sent to ${sentCount} users`);
|
||||
}
|
||||
111
docker/src/api/rooms/tunnel.manager.ts
Normal file
111
docker/src/api/rooms/tunnel.manager.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
import { getConfig as loadConfig } from '../../shared/config';
|
||||
const { values } = loadConfig();
|
||||
const CLOUDFLARED_PATH = values.paths?.cloudflared || 'cloudflared';
|
||||
|
||||
let tunnelProcess: ChildProcess | null = null;
|
||||
let exposedRooms = 0;
|
||||
let publicUrl: string | null = null;
|
||||
let tunnelPromise: Promise<string> | null = null;
|
||||
|
||||
export function openTunnel(): Promise<string> {
|
||||
if (tunnelProcess && publicUrl) {
|
||||
exposedRooms++;
|
||||
return Promise.resolve(publicUrl);
|
||||
}
|
||||
|
||||
if (tunnelPromise) {
|
||||
return tunnelPromise;
|
||||
}
|
||||
|
||||
tunnelPromise = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Timeout esperando URL del túnel (30s)"));
|
||||
}, 30000);
|
||||
|
||||
tunnelProcess = spawn(CLOUDFLARED_PATH, [
|
||||
"tunnel",
|
||||
"--url",
|
||||
"http://localhost:54322",
|
||||
"--no-autoupdate"
|
||||
]);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
tunnelPromise = null;
|
||||
};
|
||||
|
||||
let outputBuffer = "";
|
||||
|
||||
const processOutput = (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
outputBuffer += text;
|
||||
|
||||
const match = outputBuffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
||||
if (match && !publicUrl) {
|
||||
publicUrl = match[0];
|
||||
exposedRooms = 1;
|
||||
cleanup();
|
||||
resolve(publicUrl);
|
||||
}
|
||||
};
|
||||
|
||||
tunnelProcess.stdout?.on("data", (data) => {
|
||||
processOutput(data);
|
||||
});
|
||||
|
||||
tunnelProcess.stderr?.on("data", (data) => {
|
||||
processOutput(data);
|
||||
});
|
||||
|
||||
tunnelProcess.on("error", (error) => {
|
||||
console.error("[Cloudflared Process Error]", error);
|
||||
cleanup();
|
||||
tunnelProcess = null;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
tunnelProcess.on("exit", (code, signal) => {
|
||||
tunnelProcess = null;
|
||||
publicUrl = null;
|
||||
exposedRooms = 0;
|
||||
|
||||
if (!publicUrl) {
|
||||
cleanup();
|
||||
reject(new Error(`Proceso cloudflared terminó antes de obtener URL (código: ${code})`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return tunnelPromise;
|
||||
}
|
||||
|
||||
export function closeTunnelIfUnused() {
|
||||
exposedRooms--;
|
||||
console.log(`[Tunnel Manager] Rooms expuestas: ${exposedRooms}`);
|
||||
|
||||
if (exposedRooms <= 0 && tunnelProcess) {
|
||||
console.log("[Tunnel Manager] Cerrando túnel...");
|
||||
tunnelProcess.kill();
|
||||
tunnelProcess = null;
|
||||
publicUrl = null;
|
||||
exposedRooms = 0;
|
||||
tunnelPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTunnelUrl(): string | null {
|
||||
return publicUrl;
|
||||
}
|
||||
|
||||
export function forceTunnelClose() {
|
||||
if (tunnelProcess) {
|
||||
console.log("[Tunnel Manager] Forzando cierre del túnel...");
|
||||
tunnelProcess.kill();
|
||||
tunnelProcess = null;
|
||||
publicUrl = null;
|
||||
exposedRooms = 0;
|
||||
tunnelPromise = null;
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ export interface ExtensionSearchOptions {
|
||||
}
|
||||
|
||||
export interface ExtensionSearchResult {
|
||||
url: string;
|
||||
format: string;
|
||||
headers: any;
|
||||
id: string;
|
||||
@@ -98,7 +99,7 @@ export interface Extension {
|
||||
mediaType?: 'manga' | 'ln';
|
||||
search?: (options: ExtensionSearchOptions) => Promise<ExtensionSearchResult[]>;
|
||||
findEpisodes?: (id: string) => Promise<Episode[]>;
|
||||
findEpisodeServer?: (episode: Episode, server: string) => Promise<any>;
|
||||
findEpisodeServer?: (s: any, server1: string | undefined, category: string | undefined) => Promise<any>;
|
||||
findChapters?: (id: string) => Promise<Chapter[]>;
|
||||
findChapterPages?: (chapterId: string) => Promise<any>;
|
||||
getSettings?: () => ExtensionSettings;
|
||||
@@ -158,6 +159,7 @@ export interface WatchStreamQuery {
|
||||
server?: string;
|
||||
category?: string;
|
||||
ext: string;
|
||||
extensionAnimeId?: string;
|
||||
}
|
||||
|
||||
export interface BookParams {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import * as userService from './user.service';
|
||||
import {queryOne} from '../../shared/database';
|
||||
import jwt from "jsonwebtoken";
|
||||
import { getConfig } from '../../shared/config';
|
||||
const { values } = getConfig();
|
||||
const jwtSecret = values.server?.jwt_secret;
|
||||
|
||||
|
||||
interface UserIdParams { id: string; }
|
||||
interface CreateUserBody {
|
||||
@@ -75,7 +79,7 @@ export async function login(req: FastifyRequest, reply: FastifyReply) {
|
||||
|
||||
const token = jwt.sign(
|
||||
{ id: userId },
|
||||
process.env.JWT_SECRET!,
|
||||
jwtSecret,
|
||||
{ expiresIn: "7d" }
|
||||
);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
152
docker/src/scripts/anime/subtitle-renderer.js
Normal file
152
docker/src/scripts/anime/subtitle-renderer.js
Normal file
@@ -0,0 +1,152 @@
|
||||
const BASE_PATH = '/src/scripts/jassub/';
|
||||
class SubtitleRenderer {
|
||||
constructor(video, canvas) {
|
||||
this.video = video;
|
||||
this.canvas = canvas;
|
||||
this.instance = null;
|
||||
this.currentUrl = null;
|
||||
}
|
||||
async init(subtitleUrl) {
|
||||
if (!this.video || !this.canvas) return;
|
||||
this.dispose();
|
||||
const finalUrl = subtitleUrl.includes('/api/proxy')
|
||||
? subtitleUrl
|
||||
: `/api/proxy?url=${encodeURIComponent(subtitleUrl)}`;
|
||||
this.currentUrl = finalUrl;
|
||||
try {
|
||||
this.instance = new JASSUB({
|
||||
video: this.video,
|
||||
canvas: this.canvas,
|
||||
subUrl: finalUrl,
|
||||
workerUrl: `${BASE_PATH}jassub-worker.js`,
|
||||
wasmUrl: `${BASE_PATH}jassub-worker.wasm`,
|
||||
modernWasmUrl: `${BASE_PATH}jassub-worker-modern.wasm`,
|
||||
blendMode: 'js',
|
||||
asyncRender: true,
|
||||
onDemand: true,
|
||||
targetFps: 60,
|
||||
debug: false
|
||||
});
|
||||
console.log('JASSUB initialized for:', finalUrl);
|
||||
} catch (e) {
|
||||
console.error("JASSUB Init Error:", e);
|
||||
}
|
||||
}
|
||||
resize() {
|
||||
if (this.instance && this.instance.resize) {
|
||||
this.instance.resize();
|
||||
}
|
||||
}
|
||||
setTrack(url) {
|
||||
const finalUrl = url.includes('/api/proxy')
|
||||
? url
|
||||
: `/api/proxy?url=${encodeURIComponent(url)}`;
|
||||
if (this.instance) {
|
||||
this.instance.setTrackByUrl(finalUrl);
|
||||
this.currentUrl = finalUrl;
|
||||
} else {
|
||||
this.init(url);
|
||||
}
|
||||
}
|
||||
dispose() {
|
||||
if (this.instance) {
|
||||
try {
|
||||
this.instance.destroy();
|
||||
} catch (e) {
|
||||
console.warn("Error destroying JASSUB:", e);
|
||||
}
|
||||
this.instance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
class SimpleSubtitleRenderer {
|
||||
constructor(video, canvas) {
|
||||
this.video = video;
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
this.cues = [];
|
||||
this.destroyed = false;
|
||||
this.setupCanvas();
|
||||
this.video.addEventListener('timeupdate', () => this.render());
|
||||
}
|
||||
setupCanvas() {
|
||||
const updateSize = () => {
|
||||
if (!this.video || !this.canvas) return;
|
||||
const rect = this.video.getBoundingClientRect();
|
||||
this.canvas.width = rect.width;
|
||||
this.canvas.height = rect.height;
|
||||
};
|
||||
updateSize();
|
||||
window.addEventListener('resize', updateSize);
|
||||
this.resizeHandler = updateSize;
|
||||
}
|
||||
async loadSubtitles(url) {
|
||||
try {
|
||||
let finalUrl = url;
|
||||
const isLocal = url.startsWith('/');
|
||||
const isAlreadyProxied = url.includes('/api/proxy');
|
||||
if (!isLocal && !isAlreadyProxied && (url.startsWith('http:') || url.startsWith('https:'))) {
|
||||
finalUrl = `/api/proxy?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
console.log('Fetching subtitles from:', finalUrl);
|
||||
const response = await fetch(finalUrl);
|
||||
if (!response.ok) throw new Error(`Status: ${response.status}`);
|
||||
const text = await response.text();
|
||||
this.cues = this.parseSRT(text);
|
||||
} catch (error) {
|
||||
console.error('Failed to load subtitles:', error);
|
||||
}
|
||||
}
|
||||
setTrack(url) {
|
||||
this.cues = [];
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.loadSubtitles(url);
|
||||
}
|
||||
parseSRT(srtText) {
|
||||
const normalizedText = srtText.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const blocks = normalizedText.trim().split('\n\n');
|
||||
return blocks.map(block => {
|
||||
const lines = block.split('\n');
|
||||
if (lines.length < 3) return null;
|
||||
const timeMatch = lines[1].match(/(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})/);
|
||||
if (!timeMatch) return null;
|
||||
const start = parseInt(timeMatch[1]) * 3600 + parseInt(timeMatch[2]) * 60 + parseInt(timeMatch[3]) + parseInt(timeMatch[4]) / 1000;
|
||||
const end = parseInt(timeMatch[5]) * 3600 + parseInt(timeMatch[6]) * 60 + parseInt(timeMatch[7]) + parseInt(timeMatch[8]) / 1000;
|
||||
let text = lines.slice(2).join('\n');
|
||||
text = text.replace(/<[^>]*>/g, '');
|
||||
text = text.replace(/\{[^}]*\}/g, '');
|
||||
return { start, end, text };
|
||||
}).filter(Boolean);
|
||||
}
|
||||
render() {
|
||||
if (this.destroyed) return;
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
const currentTime = this.video.currentTime;
|
||||
const cue = this.cues.find(c => currentTime >= c.start && currentTime <= c.end);
|
||||
if (cue) this.drawSubtitle(cue.text);
|
||||
}
|
||||
drawSubtitle(text) {
|
||||
const lines = text.split('\n');
|
||||
const fontSize = Math.max(20, this.canvas.height * 0.04);
|
||||
this.ctx.font = `bold ${fontSize}px Arial, sans-serif`;
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.textBaseline = 'bottom';
|
||||
const lineHeight = fontSize * 1.2;
|
||||
const startY = this.canvas.height - 60;
|
||||
lines.reverse().forEach((line, index) => {
|
||||
const y = startY - (index * lineHeight);
|
||||
this.ctx.strokeStyle = 'black';
|
||||
this.ctx.lineWidth = 4;
|
||||
this.ctx.strokeText(line, this.canvas.width / 2, y);
|
||||
this.ctx.fillStyle = 'white';
|
||||
this.ctx.fillText(line, this.canvas.width / 2, y);
|
||||
});
|
||||
}
|
||||
dispose() {
|
||||
this.destroyed = true;
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
if (this.resizeHandler) window.removeEventListener('resize', this.resizeHandler);
|
||||
}
|
||||
}
|
||||
window.SubtitleRenderer = SubtitleRenderer;
|
||||
window.SimpleSubtitleRenderer = SimpleSubtitleRenderer;
|
||||
@@ -115,4 +115,66 @@ function setupDropdown() {
|
||||
})
|
||||
}
|
||||
|
||||
loadMeUI()
|
||||
loadMeUI()
|
||||
|
||||
const mobileToggle = document.getElementById('mobile-menu-toggle');
|
||||
const navCenter = document.querySelector('.nav-center');
|
||||
|
||||
let overlay = document.querySelector('.menu-overlay');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.className = 'menu-overlay';
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
navCenter.classList.toggle('open');
|
||||
overlay.classList.toggle('active');
|
||||
|
||||
const isOpen = navCenter.classList.contains('open');
|
||||
mobileToggle.innerHTML = isOpen
|
||||
? '<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>'
|
||||
: '<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/></svg>';
|
||||
}
|
||||
|
||||
mobileToggle.addEventListener('click', toggleMenu);
|
||||
|
||||
overlay.addEventListener('click', () => {
|
||||
if (navCenter.classList.contains('open')) toggleMenu();
|
||||
});
|
||||
|
||||
navCenter.querySelectorAll('.nav-button').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (navCenter.classList.contains('open')) toggleMenu();
|
||||
});
|
||||
});
|
||||
|
||||
const searchWrapper = document.querySelector('.search-wrapper');
|
||||
const searchInput = document.getElementById('search-input');
|
||||
|
||||
searchWrapper.addEventListener('click', (e) => {
|
||||
if (window.innerWidth <= 768) {
|
||||
searchWrapper.classList.add('active-mobile');
|
||||
searchInput.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Cerrar el buscador si se hace clic fuera
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!searchWrapper.contains(e.target)) {
|
||||
searchWrapper.classList.remove('active-mobile');
|
||||
}
|
||||
});
|
||||
|
||||
const createRoomModal = new CreateRoomModal();
|
||||
|
||||
const createBtn = document.getElementById('nav-create-party');
|
||||
if (createBtn) {
|
||||
createBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const dropdown = document.getElementById('nav-dropdown');
|
||||
if(dropdown) dropdown.classList.remove('active');
|
||||
|
||||
createRoomModal.open();
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ let isLocal = false;
|
||||
let currentLanguage = null;
|
||||
let uniqueLanguages = [];
|
||||
let isSortAscending = true;
|
||||
let manualExtensionBookId = null;
|
||||
|
||||
const chapterPagination = Object.create(PaginationManager);
|
||||
chapterPagination.init(6, () => renderChapterList());
|
||||
@@ -36,6 +37,37 @@ async function init() {
|
||||
await loadChapters();
|
||||
await setupAddToListButton();
|
||||
|
||||
document.getElementById('manual-match-btn')?.addEventListener('click', () => {
|
||||
const select = document.getElementById('provider-filter');
|
||||
const provider = select.value;
|
||||
|
||||
// Obtener título para prellenar
|
||||
const currentTitle = bookData?.title?.romaji || bookData?.title?.english || '';
|
||||
|
||||
MatchModal.open({
|
||||
provider: provider,
|
||||
initialQuery: currentTitle,
|
||||
// Define CÓMO buscar
|
||||
onSearch: async (query, prov) => {
|
||||
const res = await fetch(`/api/search/books/${prov}?q=${encodeURIComponent(query)}`);
|
||||
const data = await res.json();
|
||||
return data.results || [];
|
||||
},
|
||||
// Define QUÉ hacer al seleccionar
|
||||
onSelect: (item) => {
|
||||
console.log("Selected Book ID:", item.id);
|
||||
manualExtensionBookId = item.id;
|
||||
|
||||
// Lógica existente de tu book.js para recargar caps
|
||||
loadChapters(provider);
|
||||
|
||||
// Feedback visual en el botón
|
||||
const btn = document.getElementById('manual-match-btn');
|
||||
if(btn) btn.style.color = '#22c55e';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error("Init Error:", err);
|
||||
showError("Error loading book");
|
||||
@@ -170,7 +202,7 @@ function renderRelations(edges) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'relation-card-horizontal';
|
||||
|
||||
const img = node.coverImage?.large || node.coverImage?.medium || '/public/assets/no-image.png';
|
||||
const img = node.coverImage?.large || node.coverImage?.medium || '/public/assets/placeholder.svg';
|
||||
const title = node.title?.romaji || node.title?.english || node.title?.native || 'Unknown';
|
||||
const type = edge.relationType ? edge.relationType.replace(/_/g, ' ') : 'Related';
|
||||
|
||||
@@ -183,7 +215,8 @@ function renderRelations(edges) {
|
||||
`;
|
||||
|
||||
el.onclick = () => {
|
||||
const targetType = node.type === 'ANIME' ? 'anime' : 'book';
|
||||
const imgUrl = node.coverImage?.medium || '';
|
||||
const targetType = imgUrl.includes('/manga/') ? 'book' : 'anime';
|
||||
window.location.href = `/${targetType}/${node.id}`;
|
||||
};
|
||||
|
||||
@@ -311,6 +344,9 @@ async function loadChapters(targetProvider = null) {
|
||||
const source = extensionName || 'anilist';
|
||||
fetchUrl = `/api/book/${bookId}/chapters?source=${source}`;
|
||||
if (targetProvider !== 'all') fetchUrl += `&provider=${targetProvider}`;
|
||||
if (manualExtensionBookId && targetProvider !== 'all') {
|
||||
fetchUrl += `&extensionBookId=${manualExtensionBookId}`;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(fetchUrl);
|
||||
@@ -515,6 +551,8 @@ async function loadAvailableExtensions() {
|
||||
|
||||
function setupProviderFilter() {
|
||||
const select = document.getElementById('provider-filter');
|
||||
const manualBtn = document.getElementById('manual-match-btn'); // NUEVO
|
||||
|
||||
if (!select) return;
|
||||
select.style.display = 'inline-block';
|
||||
select.innerHTML = '';
|
||||
@@ -538,11 +576,32 @@ function setupProviderFilter() {
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
// Lógica de selección inicial
|
||||
if (isLocal) select.value = 'local';
|
||||
else if (extensionName && availableExtensions.includes(extensionName)) select.value = extensionName;
|
||||
else if (availableExtensions.length > 0) select.value = availableExtensions[0];
|
||||
|
||||
select.onchange = () => loadChapters(select.value);
|
||||
// Visibilidad inicial del botón manual
|
||||
updateManualButtonVisibility(select.value);
|
||||
|
||||
select.onchange = () => {
|
||||
// Al cambiar de proveedor, reseteamos la selección manual para evitar conflictos
|
||||
manualExtensionBookId = null;
|
||||
updateManualButtonVisibility(select.value);
|
||||
loadChapters(select.value);
|
||||
};
|
||||
}
|
||||
|
||||
function updateManualButtonVisibility(provider) {
|
||||
const btn = document.getElementById('manual-match-btn');
|
||||
if (!btn) return;
|
||||
|
||||
// Solo mostrar si es un proveedor específico (no 'all' ni 'local')
|
||||
if (provider !== 'all' && provider !== 'local') {
|
||||
btn.style.display = 'flex';
|
||||
} else {
|
||||
btn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function updateExtensionPill() {
|
||||
@@ -569,9 +628,9 @@ function updateCustomAddButton() {
|
||||
}
|
||||
|
||||
function setupModalClickOutside() {
|
||||
const modal = document.getElementById('add-list-modal');
|
||||
if (modal) {
|
||||
modal.addEventListener('click', (e) => {
|
||||
const addListModal = document.getElementById('add-list-modal');
|
||||
if (addListModal) {
|
||||
addListModal.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'add-list-modal') ListModalManager.close();
|
||||
});
|
||||
}
|
||||
|
||||
BIN
docker/src/scripts/jassub/default.woff2
Normal file
BIN
docker/src/scripts/jassub/default.woff2
Normal file
Binary file not shown.
BIN
docker/src/scripts/jassub/jassub-worker-modern.wasm
Normal file
BIN
docker/src/scripts/jassub/jassub-worker-modern.wasm
Normal file
Binary file not shown.
11
docker/src/scripts/jassub/jassub-worker.js
Normal file
11
docker/src/scripts/jassub/jassub-worker.js
Normal file
File diff suppressed because one or more lines are too long
BIN
docker/src/scripts/jassub/jassub-worker.wasm
Normal file
BIN
docker/src/scripts/jassub/jassub-worker.wasm
Normal file
Binary file not shown.
@@ -122,13 +122,16 @@ const DashboardApp = {
|
||||
headerBadge.title = `Connected as ${data.anilistUserId}`;
|
||||
}
|
||||
if (statusEl) {
|
||||
statusEl.textContent = `Connected as ID: ${data.anilistUserId}`;
|
||||
statusEl.style.color = 'var(--color-success)';
|
||||
// CAMBIO: Mostrar fecha de expiración si existe
|
||||
const expiresDate = data.expiresAt ? new Date(data.expiresAt).toLocaleDateString() : 'Unknown';
|
||||
statusEl.innerHTML = `
|
||||
<span style="color:var(--color-success)">Connected as: <b>${data.anilistUserId}</b></span>
|
||||
<span style="display:block; font-size:0.75rem; color:#71717a">Expires: ${expiresDate}</span>
|
||||
`;
|
||||
}
|
||||
if (btn) {
|
||||
btn.textContent = 'Disconnect';
|
||||
btn.className = 'btn-stream-outline link-danger';
|
||||
|
||||
btn.onclick = () => this.disconnectAniList(userId);
|
||||
}
|
||||
} else {
|
||||
@@ -140,7 +143,7 @@ const DashboardApp = {
|
||||
if (btn) {
|
||||
btn.textContent = 'Connect';
|
||||
btn.className = 'btn-stream-outline';
|
||||
btn.onclick = () => this.redirectToAniListLogin();
|
||||
btn.onclick = () => this.openAniListModal();
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -154,6 +157,83 @@ const DashboardApp = {
|
||||
window.location.href = `https://anilist.co/api/v2/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&state=${state}`;
|
||||
} catch (err) { console.error(err); alert('Error starting AniList login'); }
|
||||
},
|
||||
openAniListModal: function() {
|
||||
const modal = document.getElementById('anilist-connect-modal');
|
||||
const body = document.getElementById('anilist-modal-body');
|
||||
const clientId = 32898; // Tu Client ID
|
||||
|
||||
// Generamos el HTML del modal dinámicamente
|
||||
body.innerHTML = `
|
||||
<p class="modal-description">Connect your AniList account to sync your progress automatically.</p>
|
||||
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<label style="display:block; font-size:0.85rem; font-weight:600; color:#a1a1aa; margin-bottom:0.5rem">Step 1: Get Token</label>
|
||||
<a href="https://anilist.co/api/v2/oauth/authorize?client_id=${clientId}&response_type=token"
|
||||
target="_blank"
|
||||
class="btn-blur"
|
||||
style="width:100%; text-align:center; box-sizing:border-box; display:block;">
|
||||
Open AniList Login ↗
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>Step 2: Paste Token</label>
|
||||
<input type="text" id="manual-anilist-token" class="stream-input" placeholder="Paste the long access token here..." autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="modal-footer" style="padding:0; background:transparent;">
|
||||
<button class="btn-primary" style="width:100%" onclick="DashboardApp.User.submitAniListToken()">Verify & Connect</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
},
|
||||
|
||||
closeAniListModal: function() {
|
||||
document.getElementById('anilist-connect-modal').classList.add('hidden');
|
||||
},
|
||||
|
||||
submitAniListToken: async function() {
|
||||
const tokenInput = document.getElementById('manual-anilist-token');
|
||||
const token = tokenInput.value.trim();
|
||||
const userId = DashboardApp.State.currentUserId;
|
||||
|
||||
if (!token) {
|
||||
alert('Please paste the AniList token first');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmBtn = document.querySelector('#anilist-connect-modal .btn-primary');
|
||||
const originalText = confirmBtn.textContent;
|
||||
confirmBtn.textContent = "Verifying...";
|
||||
confirmBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/anilist/store`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: userId,
|
||||
accessToken: token
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to verify token');
|
||||
|
||||
this.closeAniListModal();
|
||||
await this.checkIntegrations(userId);
|
||||
alert('AniList connected successfully!');
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(err.message || 'Invalid Token');
|
||||
} finally {
|
||||
confirmBtn.textContent = originalText;
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
},
|
||||
|
||||
disconnectAniList: async function(userId) {
|
||||
if(!confirm("Disconnect AniList?")) return;
|
||||
@@ -387,6 +467,96 @@ const DashboardApp = {
|
||||
},
|
||||
|
||||
Library: {
|
||||
tempMatchContext: null,
|
||||
pollInterval: null,
|
||||
updateDownloadStatus: async function() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/library/downloads/status`, {
|
||||
headers: window.AuthUtils.getSimpleAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
this.renderDownloadMonitor(data);
|
||||
|
||||
// Si hay descargas completadas nuevas, podríamos recargar la lista de archivos
|
||||
// (Opcional: lógica para detectar cambios y llamar a loadContent)
|
||||
|
||||
} catch (e) {
|
||||
console.error("Error polling downloads:", e);
|
||||
}
|
||||
},
|
||||
|
||||
renderDownloadMonitor: function(data) {
|
||||
const monitor = document.getElementById('downloads-monitor');
|
||||
const listContainer = document.getElementById('downloads-list-container');
|
||||
const activeCountEl = document.getElementById('dl-stat-active');
|
||||
|
||||
// Datos por defecto
|
||||
const downloads = data.downloads || { list: [], active: 0, failed: 0 };
|
||||
|
||||
// Actualizar contadores cabecera
|
||||
if(activeCountEl) activeCountEl.textContent = `${downloads.active} Active / ${downloads.list.length} Total`;
|
||||
|
||||
// Ocultar si vacío
|
||||
if (downloads.list.length === 0) {
|
||||
monitor.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
monitor.classList.remove('hidden');
|
||||
|
||||
listContainer.innerHTML = downloads.list.map(item => {
|
||||
const fileName = item.fileName || `Unknown_File_${item.unitNumber}`;
|
||||
const folderName = item.folderName || 'Unsorted';
|
||||
const status = item.status || 'pending';
|
||||
const progress = item.progress || 0;
|
||||
const speed = item.speed || '0 KB/s';
|
||||
|
||||
const isCompleted = status === 'completed';
|
||||
const isFailed = status === 'failed';
|
||||
|
||||
let statusText = `${progress}%`;
|
||||
if (isCompleted) statusText = 'Done';
|
||||
if (isFailed) statusText = 'Failed';
|
||||
|
||||
const folderIcon = `<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>`;
|
||||
|
||||
// ESTRUCTURA NUEVA: Más plana para permitir Flexbox horizontal
|
||||
return `
|
||||
<div class="dl-item compact">
|
||||
<div class="dl-left-col">
|
||||
<div class="dl-filename" title="${fileName}">${fileName}</div>
|
||||
<div class="dl-folder" title="${folderName}">${folderIcon} ${folderName}</div>
|
||||
</div>
|
||||
|
||||
<div class="dl-right-col">
|
||||
<div class="dl-meta-info">
|
||||
<span class="dl-speed">${isCompleted ? '' : speed}</span>
|
||||
<span class="dl-status-text ${status}">${statusText}</span>
|
||||
</div>
|
||||
<div class="dl-progress-track">
|
||||
<div class="dl-progress-fill ${status}" style="width: ${progress}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
},
|
||||
|
||||
startPolling: function() {
|
||||
if (this.pollInterval) clearInterval(this.pollInterval);
|
||||
this.updateDownloadStatus(); // Primera llamada inmediata
|
||||
this.pollInterval = setInterval(() => this.updateDownloadStatus(), 2000); // Cada 2 segundos
|
||||
},
|
||||
|
||||
stopPolling: function() {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
},
|
||||
loadStats: async function() {
|
||||
const types = ['anime', 'manga', 'novels'];
|
||||
const elements = { 'anime': 'local-anime-count', 'manga': 'local-manga-count', 'novels': 'local-novel-count' };
|
||||
@@ -446,7 +616,7 @@ const DashboardApp = {
|
||||
const meta = entry.metadata || {};
|
||||
let poster = meta.coverImage?.large || '/public/assets/placeholder.svg';
|
||||
|
||||
let title = isMatched ? (meta.title?.english || meta.title?.romaji) : entry.folder_name;
|
||||
let title = isMatched ? (meta.title?.english || meta.title?.romaji) : entry.path;
|
||||
if (!isMatched) title = title.replace(/\[.*?\]|\(.*?\)|\.mkv|\.mp4/g, '').trim();
|
||||
|
||||
const url = isMatched ? (type === 'anime' ? `/anime/${meta.id}` : `/book/${meta.id}`) : '#';
|
||||
@@ -459,12 +629,12 @@ const DashboardApp = {
|
||||
${!isMatched ? `<div class="unmatched-badge"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg> UNMATCHED</div>` : ''}
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<h3 class="item-title" title="${entry.folder_name}">${title}</h3>
|
||||
<h3 class="item-title" title="${entry.path}">${title}</h3>
|
||||
<div class="item-meta">
|
||||
<span class="meta-pill type-pill">${entry.files ? entry.files.length : 0} FILES</span>
|
||||
<span class="meta-pill type-pill">${entry.files} FILES</span>
|
||||
${isMatched ? '<span class="meta-pill status-pill">MATCHED</span>' : ''}
|
||||
</div>
|
||||
<div class="folder-path-tooltip">${entry.folder_name}</div>
|
||||
<div class="folder-path-tooltip">${entry.path}</div>
|
||||
</div>
|
||||
<button class="edit-icon-btn" onclick="DashboardApp.Library.openManualMatch('${entry.id}', '${type}')" title="${isMatched ? 'Rematch' : 'Fix Match'}">
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
@@ -505,16 +675,67 @@ const DashboardApp = {
|
||||
},
|
||||
|
||||
openManualMatch: function(id, type) {
|
||||
const newId = prompt("Enter AniList ID to force match:");
|
||||
if (newId) {
|
||||
fetch(`${API_BASE}/library/${type}/${id}/match`, {
|
||||
const item = DashboardApp.State.localLibraryData.find(x => x.id === id);
|
||||
const pathName = item ? item.path : 'Unknown path';
|
||||
|
||||
this.tempMatchContext = { id, type };
|
||||
|
||||
document.getElementById('manual-match-path').textContent = pathName;
|
||||
document.getElementById('manual-match-id').value = '';
|
||||
|
||||
const modal = document.getElementById('manual-match-modal');
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
setTimeout(() => document.getElementById('manual-match-id').focus(), 100);
|
||||
},
|
||||
|
||||
closeManualMatch: function() {
|
||||
document.getElementById('manual-match-modal').classList.add('hidden');
|
||||
this.tempMatchContext = null;
|
||||
},
|
||||
|
||||
submitManualMatch: async function() {
|
||||
if (!this.tempMatchContext) return;
|
||||
|
||||
const newId = document.getElementById('manual-match-id').value;
|
||||
if (!newId) {
|
||||
alert("Please enter a valid ID");
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, type } = this.tempMatchContext;
|
||||
|
||||
const confirmBtn = document.querySelector('#manual-match-modal .btn-primary');
|
||||
const originalText = confirmBtn.textContent;
|
||||
confirmBtn.textContent = "Matching...";
|
||||
confirmBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/library/${type}/${id}/match`, {
|
||||
method: 'POST',
|
||||
headers: { ...window.AuthUtils.getSimpleAuthHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source: 'anilist', matched_id: parseInt(newId) })
|
||||
}).then(res => {
|
||||
if(res.ok) { alert("Matched! Refreshing..."); this.loadContent(type); }
|
||||
else { alert("Failed to match."); }
|
||||
headers: {
|
||||
...window.AuthUtils.getSimpleAuthHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source: 'anilist',
|
||||
matched_id: parseInt(newId)
|
||||
})
|
||||
});
|
||||
|
||||
if(res.ok) {
|
||||
this.closeManualMatch();
|
||||
this.loadContent(type);
|
||||
} else {
|
||||
const errData = await res.json();
|
||||
alert("Failed to match: " + (errData.error || "Unknown error"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Connection error");
|
||||
} finally {
|
||||
confirmBtn.textContent = originalText;
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -532,6 +753,7 @@ const DashboardApp = {
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
// Gestión de clases activas
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
|
||||
@@ -541,9 +763,13 @@ const DashboardApp = {
|
||||
if (sec.id === targetId) sec.classList.add('active');
|
||||
});
|
||||
|
||||
// Lógica específica por pestaña
|
||||
if (tab.dataset.target === 'local') {
|
||||
DashboardApp.Library.loadStats();
|
||||
DashboardApp.Library.loadContent('anime');
|
||||
DashboardApp.Library.loadContent(DashboardApp.State.currentLocalType || 'anime');
|
||||
DashboardApp.Library.startPolling(); // <--- INICIAR POLLING
|
||||
} else {
|
||||
DashboardApp.Library.stopPolling(); // <--- DETENER POLLING AL SALIR
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
131
docker/src/scripts/room-modal.js
Normal file
131
docker/src/scripts/room-modal.js
Normal file
@@ -0,0 +1,131 @@
|
||||
class CreateRoomModal {
|
||||
constructor() {
|
||||
this.modalId = 'cr-modal-overlay';
|
||||
this.isRendered = false;
|
||||
this.render(); // Crear el HTML en el DOM al instanciar
|
||||
}
|
||||
|
||||
render() {
|
||||
if (document.getElementById(this.modalId)) return;
|
||||
|
||||
const modalHtml = `
|
||||
<div class="cr-modal-overlay" id="${this.modalId}">
|
||||
<div class="cr-modal-content">
|
||||
<button class="cr-modal-close" id="cr-close">✕</button>
|
||||
<h2 class="cr-modal-title">Create Watch Party</h2>
|
||||
|
||||
<form id="cr-form">
|
||||
<div class="cr-form-group">
|
||||
<label>Room Name</label>
|
||||
<input type="text" class="cr-input" name="name" placeholder="My Awesome Room" required maxlength="50" />
|
||||
</div>
|
||||
|
||||
<div class="cr-form-group">
|
||||
<label>Password (Optional)</label>
|
||||
<input type="password" class="cr-input" name="password" placeholder="Leave empty for public" maxlength="50" />
|
||||
</div>
|
||||
|
||||
<div class="cr-form-group cr-checkbox-group">
|
||||
<label class="cr-checkbox">
|
||||
<input type="checkbox" name="expose" />
|
||||
<span>Generate public link (via tunnel)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="cr-actions">
|
||||
<button type="button" class="cr-btn-cancel" id="cr-cancel">Cancel</button>
|
||||
<button type="submit" class="cr-btn-confirm">Create Room</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
this.bindEvents();
|
||||
this.isRendered = true;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
const modal = document.getElementById(this.modalId);
|
||||
const closeBtn = document.getElementById('cr-close');
|
||||
const cancelBtn = document.getElementById('cr-cancel');
|
||||
const form = document.getElementById('cr-form');
|
||||
|
||||
const close = () => this.close();
|
||||
|
||||
closeBtn.onclick = close;
|
||||
cancelBtn.onclick = close;
|
||||
|
||||
// Cerrar si clicamos fuera del contenido
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) close();
|
||||
};
|
||||
|
||||
form.onsubmit = (e) => this.handleSubmit(e);
|
||||
}
|
||||
|
||||
open() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
// Aquí puedes disparar tu modal de login o redirigir
|
||||
alert('You must be logged in to create a room');
|
||||
window.location.href = '/login'; // Opcional
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.getElementById(this.modalId);
|
||||
modal.classList.add('show');
|
||||
document.querySelector('#cr-form input[name="name"]').focus();
|
||||
}
|
||||
|
||||
close() {
|
||||
const modal = document.getElementById(this.modalId);
|
||||
modal.classList.remove('show');
|
||||
document.getElementById('cr-form').reset();
|
||||
}
|
||||
|
||||
async handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('button[type="submit"]');
|
||||
const originalText = btn.textContent;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Creating...';
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
const expose = formData.get('expose') === 'on';
|
||||
const name = formData.get('name').trim();
|
||||
const password = formData.get('password').trim();
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/rooms', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
password: password || undefined,
|
||||
expose
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to create room');
|
||||
|
||||
this.close();
|
||||
|
||||
window.open(`/room?id=${data.room.id}`, '_blank', 'noopener,noreferrer');
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.CreateRoomModal = CreateRoomModal;
|
||||
2274
docker/src/scripts/room.js
Normal file
2274
docker/src/scripts/room.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -838,8 +838,7 @@ function openAniListModal(userId) {
|
||||
modalUserActions.classList.remove('active');
|
||||
modalEditUser.classList.remove('active');
|
||||
|
||||
aniListContent.innerHTML = `<div style="text-align: center; padding: 2rem;">Loading integration status...</div>`;
|
||||
|
||||
// Estado de carga inicial
|
||||
modalAniList.innerHTML = `
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-content">
|
||||
@@ -860,12 +859,15 @@ function openAniListModal(userId) {
|
||||
|
||||
modalAniList.classList.add('active');
|
||||
|
||||
// Verificar si ya está conectado
|
||||
getIntegrationStatus(userId).then(integration => {
|
||||
const content = document.getElementById('aniListContent');
|
||||
const clientId = 32898; // Tu Client ID de AniList
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="anilist-status">
|
||||
${integration.connected ? `
|
||||
if (integration.connected) {
|
||||
// VISTA: YA CONECTADO
|
||||
content.innerHTML = `
|
||||
<div class="anilist-status">
|
||||
<div class="anilist-connected">
|
||||
<div class="anilist-icon">
|
||||
<img src="https://anilist.co/img/icons/icon.svg" alt="AniList" style="width:40px; height:40px;">
|
||||
@@ -879,24 +881,43 @@ function openAniListModal(userId) {
|
||||
<button class="btn-disconnect" onclick="handleDisconnectAniList()">
|
||||
Disconnect AniList
|
||||
</button>
|
||||
` : `
|
||||
<div style="text-align: center; padding: 1rem;">
|
||||
<h3 style="margin-bottom: 0.5rem;">Connect with AniList</h3>
|
||||
<p style="color: var(--color-text-secondary); margin-bottom: 1.5rem;">
|
||||
Sync your anime list by logging in with AniList.
|
||||
</p>
|
||||
<div style="display:flex; justify-content:center;">
|
||||
<button class="btn-connect" onclick="redirectToAniListLogin()">
|
||||
Login with AniList
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// VISTA: NO CONECTADO (Formulario Manual)
|
||||
content.innerHTML = `
|
||||
<div class="anilist-status">
|
||||
<div style="text-align: left; padding: 0.5rem;">
|
||||
<h3 style="margin-bottom: 1rem; font-size: 1.1rem;">How to connect:</h3>
|
||||
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<p style="color: var(--color-text-secondary); margin-bottom: 0.5rem; font-size: 0.9rem;">
|
||||
1. Open the authorization page in a new tab:
|
||||
</p>
|
||||
<a href="https://anilist.co/api/v2/oauth/authorize?client_id=${clientId}&response_type=token"
|
||||
target="_blank"
|
||||
class="btn-secondary"
|
||||
style="display: inline-block; text-decoration: none; text-align: center; width: 100%; padding: 0.8rem;">
|
||||
Open AniList Login ↗
|
||||
</a>
|
||||
</div>
|
||||
<p style="font-size:0.85rem; margin-top:1rem; color:var(--color-text-secondary)">
|
||||
You will be redirected and then returned here.
|
||||
</p>
|
||||
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<p style="color: var(--color-text-secondary); margin-bottom: 0.5rem; font-size: 0.9rem;">
|
||||
2. Authorize the app, then copy the <b>Access Token</b> provided and paste it here:
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<input type="text" id="manualAniListToken" placeholder="Paste your Access Token here..." autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-connect" onclick="handleManualAniListToken()">
|
||||
Verify & Save Token
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
const content = document.getElementById('aniListContent');
|
||||
@@ -904,6 +925,49 @@ function openAniListModal(userId) {
|
||||
});
|
||||
}
|
||||
|
||||
// Nueva función para manejar el token pegado manualmente
|
||||
async function handleManualAniListToken() {
|
||||
const tokenInput = document.getElementById('manualAniListToken');
|
||||
const token = tokenInput.value.trim();
|
||||
|
||||
if (!token) {
|
||||
showUserToast('Please paste the AniList token first', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const submitBtn = document.querySelector('.btn-connect');
|
||||
const originalText = submitBtn.textContent;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Verifying...';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/anilist/store`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: currentUserId,
|
||||
accessToken: token,
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Failed to verify token');
|
||||
}
|
||||
|
||||
showUserToast('AniList connected successfully!', 'success');
|
||||
// Recargar el modal para mostrar el estado "Conectado"
|
||||
openAniListModal(currentUserId);
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showUserToast(err.message || 'Invalid Token', 'error');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
async function redirectToAniListLogin() {
|
||||
try {
|
||||
const res = await fetch(`/api/login`, {
|
||||
@@ -918,15 +982,10 @@ async function redirectToAniListLogin() {
|
||||
localStorage.setItem('token', data.token);
|
||||
|
||||
const clientId = 32898;
|
||||
const redirectUri = encodeURIComponent(window.location.origin + '/api/anilist');
|
||||
const state = encodeURIComponent(currentUserId);
|
||||
|
||||
window.location.href =
|
||||
`https://anilist.co/api/v2/oauth/authorize` +
|
||||
`?client_id=${clientId}` +
|
||||
`&response_type=code` +
|
||||
`&redirect_uri=${redirectUri}` +
|
||||
`&state=${state}`;
|
||||
window.open(
|
||||
`https://anilist.co/api/v2/oauth/authorize?client_id=${clientId}&response_type=token`,
|
||||
'_blank'
|
||||
);
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
171
docker/src/scripts/utils/match-modal.js
Normal file
171
docker/src/scripts/utils/match-modal.js
Normal file
@@ -0,0 +1,171 @@
|
||||
const MatchModal = (function() {
|
||||
let _config = {
|
||||
onSearch: async (query, provider) => [], // Debe devolver Array de objetos
|
||||
onSelect: (item, provider) => {},
|
||||
provider: 'generic'
|
||||
};
|
||||
|
||||
let elements = {};
|
||||
let searchTimeout = null;
|
||||
|
||||
function init() {
|
||||
if (document.getElementById('waifu-match-modal')) return;
|
||||
|
||||
// Inyectar HTML
|
||||
const modalHTML = `
|
||||
<div class="match-modal-overlay" id="waifu-match-modal">
|
||||
<div class="match-modal-content">
|
||||
<div class="match-header">
|
||||
<h3 class="match-title">Manual Match <span id="match-provider-badge" style="opacity:0.6; font-size:0.8em; margin-left:8px;"></span></h3>
|
||||
<button class="match-close-btn" id="match-close-btn">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="match-search-container">
|
||||
<input type="text" id="match-input" class="match-input" placeholder="Search title..." autocomplete="off">
|
||||
<button id="match-btn-action" class="match-search-btn">Search</button>
|
||||
</div>
|
||||
|
||||
<div class="match-results-body" id="match-results-container">
|
||||
<div class="match-msg">Type to start searching...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHTML);
|
||||
|
||||
// Cachear elementos
|
||||
elements = {
|
||||
overlay: document.getElementById('waifu-match-modal'),
|
||||
input: document.getElementById('match-input'),
|
||||
results: document.getElementById('match-results-container'),
|
||||
badge: document.getElementById('match-provider-badge'),
|
||||
closeBtn: document.getElementById('match-close-btn'),
|
||||
searchBtn: document.getElementById('match-btn-action')
|
||||
};
|
||||
|
||||
// Event Listeners
|
||||
elements.closeBtn.onclick = close;
|
||||
elements.overlay.onclick = (e) => { if(e.target === elements.overlay) close(); };
|
||||
|
||||
// Búsqueda al hacer clic
|
||||
elements.searchBtn.onclick = () => performSearch(elements.input.value);
|
||||
|
||||
// Búsqueda al escribir (Debounce)
|
||||
elements.input.addEventListener('input', (e) => {
|
||||
clearTimeout(searchTimeout);
|
||||
if(e.target.value.trim().length === 0) return;
|
||||
searchTimeout = setTimeout(() => performSearch(e.target.value), 600);
|
||||
});
|
||||
|
||||
// Enter key
|
||||
elements.input.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') performSearch(elements.input.value);
|
||||
});
|
||||
}
|
||||
|
||||
function open(options) {
|
||||
init(); // Asegurar que el DOM existe
|
||||
|
||||
_config = { ..._config, ...options };
|
||||
|
||||
// Resetear UI
|
||||
elements.input.value = options.initialQuery || '';
|
||||
elements.results.innerHTML = '<div class="match-msg">Search above to find matches...</div>';
|
||||
elements.badge.innerText = options.provider ? `(${options.provider})` : '';
|
||||
|
||||
// Mostrar Modal
|
||||
elements.overlay.classList.add('active');
|
||||
|
||||
// Auto-search si hay query inicial
|
||||
if (options.initialQuery) {
|
||||
performSearch(options.initialQuery);
|
||||
}
|
||||
|
||||
setTimeout(() => elements.input.focus(), 100);
|
||||
}
|
||||
|
||||
function close() {
|
||||
if(elements.overlay) elements.overlay.classList.remove('active');
|
||||
}
|
||||
|
||||
async function performSearch(query) {
|
||||
if (!query || query.trim().length < 2) return;
|
||||
|
||||
elements.results.innerHTML = '<div class="match-spinner"></div>';
|
||||
|
||||
try {
|
||||
// Ejecutar la función de búsqueda pasada en la config
|
||||
const results = await _config.onSearch(query, _config.provider);
|
||||
renderResults(results);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
elements.results.innerHTML = '<div class="match-msg error">Error searching provider.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderResults(results) {
|
||||
elements.results.innerHTML = '';
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
elements.results.innerHTML = '<div class="match-msg">No matches found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'match-list-grid';
|
||||
|
||||
results.forEach(item => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'match-item';
|
||||
|
||||
// Normalización de datos para asegurar compatibilidad con Anime/Libros
|
||||
const img = item.coverImage?.large || item.coverImage || item.image || '/public/assets/no-image.png';
|
||||
const title = item.title?.english || item.title?.romaji || item.title || 'Unknown Title';
|
||||
const meta = item.releaseDate || item.year || item.startDate?.year || '';
|
||||
const url = item.url || item.externalUrl || null;
|
||||
|
||||
el.innerHTML = `
|
||||
<img src="${img}" class="match-poster" loading="lazy">
|
||||
<div class="match-info">
|
||||
<div class="match-item-title">${title}</div>
|
||||
<div class="match-item-meta">${meta}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Botón de enlace externo (si existe URL)
|
||||
if (url) {
|
||||
const linkBtn = document.createElement('a');
|
||||
linkBtn.href = url;
|
||||
linkBtn.target = "_blank";
|
||||
linkBtn.className = "match-link-btn";
|
||||
linkBtn.title = "View Source";
|
||||
linkBtn.innerHTML = `
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
</svg>
|
||||
`;
|
||||
// Evitar que el click en el enlace dispare el select
|
||||
linkBtn.onclick = (e) => e.stopPropagation();
|
||||
el.appendChild(linkBtn);
|
||||
}
|
||||
|
||||
// Click en la tarjeta selecciona
|
||||
el.onclick = () => {
|
||||
_config.onSelect(item);
|
||||
close();
|
||||
};
|
||||
|
||||
grid.appendChild(el);
|
||||
});
|
||||
|
||||
elements.results.appendChild(grid);
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
close
|
||||
};
|
||||
})();
|
||||
@@ -2,6 +2,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import yaml from 'js-yaml';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const BASE_DIR = path.join(os.homedir(), 'WaifuBoards');
|
||||
const CONFIG_PATH = path.join(BASE_DIR, 'config.yaml');
|
||||
@@ -14,7 +15,12 @@ const DEFAULT_CONFIG = {
|
||||
},
|
||||
paths: {
|
||||
mpv: null,
|
||||
ffmpeg: null
|
||||
ffmpeg: null,
|
||||
ffprobe: null,
|
||||
cloudflared: null,
|
||||
},
|
||||
server: {
|
||||
jwt_secret: null
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,7 +32,9 @@ export const CONFIG_SCHEMA = {
|
||||
},
|
||||
paths: {
|
||||
mpv: { description: "Required to open anime episodes in mpv on desktop version." },
|
||||
ffmpeg: { description: "Required for downloading anime episodes." }
|
||||
ffmpeg: { description: "Required for downloading anime episodes." },
|
||||
ffprobe: { description: "Required for watching local anime episodes." },
|
||||
cloudflared: { description: "Required for creating pubic rooms." }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,13 +43,31 @@ function ensureConfigFile() {
|
||||
fs.mkdirSync(BASE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
let configExists = fs.existsSync(CONFIG_PATH);
|
||||
|
||||
if (!configExists) {
|
||||
fs.writeFileSync(
|
||||
CONFIG_PATH,
|
||||
yaml.dump(DEFAULT_CONFIG),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const loaded = yaml.load(raw) || {};
|
||||
|
||||
if (!loaded.server) loaded.server = {};
|
||||
if (!loaded.server.jwt_secret) {
|
||||
loaded.server.jwt_secret = crypto.randomBytes(32).toString('hex');
|
||||
fs.writeFileSync(CONFIG_PATH, yaml.dump(deepMerge(structuredClone(DEFAULT_CONFIG), loaded)), 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
export function getPublicConfig() {
|
||||
const { values } = getConfig();
|
||||
const publicConfig = structuredClone(values);
|
||||
if (publicConfig.server) delete publicConfig.server.jwt_secret;
|
||||
return publicConfig;
|
||||
}
|
||||
|
||||
export function getConfig() {
|
||||
|
||||
@@ -130,6 +130,12 @@ async function viewsRoutes(fastify: FastifyInstance) {
|
||||
reply.type('text/html').send(html);
|
||||
});
|
||||
|
||||
fastify.get('/room', (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', 'room.html');
|
||||
const html = fs.readFileSync(htmlPath, 'utf-8');
|
||||
reply.type('text/html').send(html);
|
||||
});
|
||||
|
||||
fastify.setNotFoundHandler((req, reply) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', '404.html');
|
||||
const html = fs.readFileSync(htmlPath, 'utf-8');
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon" />
|
||||
<title>WaifuBoard</title>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
|
||||
<script src="https://cdn.plyr.io/3.7.8/plyr.js"></script>
|
||||
|
||||
<link rel="stylesheet" href="/views/css/globals.css" />
|
||||
<script type="module">
|
||||
import JASSUB from 'https://cdn.jsdelivr.net/npm/jassub@1.8.8/dist/jassub.es.js';
|
||||
window.JASSUB = JASSUB;
|
||||
</script> <link rel="stylesheet" href="/views/css/globals.css" />
|
||||
<link rel="stylesheet" href="/views/css/components/anilist-modal.css" />
|
||||
<link rel="stylesheet" href="/views/css/anime/anime.css" />
|
||||
<link rel="stylesheet" href="/views/css/anime/player.css" />
|
||||
<link rel="stylesheet" href="/views/css/components/match-modal.css">
|
||||
</head>
|
||||
<body>
|
||||
<a href="/anime" class="back-btn">
|
||||
@@ -40,18 +40,27 @@
|
||||
|
||||
<div class="player-wrapper" id="player-wrapper" style="display: none;">
|
||||
<div class="player-container">
|
||||
<!-- Side Navigation Buttons -->
|
||||
<button id="prev-ep-btn" class="side-nav-btn left" title="Previous Episode">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 18l-6-6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button id="next-ep-btn" class="side-nav-btn right" title="Next Episode">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 18l6-6-6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Header Controls -->
|
||||
<div class="player-header">
|
||||
<div class="header-left">
|
||||
<button class="btn-icon-glass" id="close-player-btn" title="Close Player">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5"/>
|
||||
<path d="M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="episode-info">
|
||||
<span class="ep-label">Watching</span>
|
||||
@@ -61,13 +70,21 @@
|
||||
|
||||
<div class="header-right">
|
||||
<button class="btn-icon-glass" id="download-btn" title="Download Episode" style="display: none;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="settings-group">
|
||||
<button class="btn-icon-glass" id="manual-match-btn" title="Manual Match" style="display: none;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="sd-toggle" id="sd-toggle" data-state="sub">
|
||||
<div class="sd-bg"></div>
|
||||
<div class="sd-option active" id="opt-sub">Sub</div>
|
||||
@@ -80,40 +97,82 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Frame -->
|
||||
<div class="video-frame">
|
||||
<video id="player" controls crossorigin playsinline></video>
|
||||
<video id="player" crossorigin playsinline></video>
|
||||
<canvas id="subtitles-canvas"></canvas>
|
||||
|
||||
<div id="player-loading" class="player-loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
<p id="player-loading-text">Loading Stream...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-overlay" id="download-modal">
|
||||
<div class="modal-content download-settings-content">
|
||||
<button class="modal-close" id="close-download-modal">✕</button>
|
||||
<h2 class="modal-title">Download Settings</h2>
|
||||
|
||||
<div class="download-sections-wrapper">
|
||||
<div id="dl-quality-section" class="dl-section" style="display:none;">
|
||||
<h3>Video Quality</h3>
|
||||
<div class="dl-list" id="dl-quality-list"></div>
|
||||
<!-- Skip Overlay Button -->
|
||||
<button id="skip-overlay-btn">
|
||||
Skip Intro
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<path d="M13 17l5-5-5-5M6 17l5-5-5-5"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Custom Controls -->
|
||||
<div class="custom-controls">
|
||||
<div class="controls-gradient"></div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="progress-container">
|
||||
<div class="progress-buffer"></div>
|
||||
<div class="progress-played"></div>
|
||||
<div class="progress-handle"></div>
|
||||
</div>
|
||||
|
||||
<div id="dl-audio-section" class="dl-section" style="display:none;">
|
||||
<h3>Audio Tracks</h3>
|
||||
<div class="dl-list" id="dl-audio-list"></div>
|
||||
</div>
|
||||
<!-- Controls Row -->
|
||||
<div class="controls-row">
|
||||
<div class="controls-left">
|
||||
<button class="control-btn play-pause" id="play-pause-btn" title="Play/Pause (Space)">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div id="dl-subs-section" class="dl-section">
|
||||
<h3>Subtitles</h3>
|
||||
<div class="dl-list" id="dl-subs-list"></div>
|
||||
<div class="volume-control">
|
||||
<button class="control-btn" id="volume-btn" title="Mute (M)">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="volume-slider-container">
|
||||
<input type="range" class="volume-slider" id="volume-slider" min="0" max="100" value="100">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="time-display" id="time-display">0:00 / 0:00</span>
|
||||
</div>
|
||||
|
||||
<div class="controls-center">
|
||||
<!-- Center space for future controls -->
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
<button class="control-btn" id="settings-btn" title="Settings">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="control-btn" id="fullscreen-btn" title="Fullscreen (F)">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dl-actions">
|
||||
<button class="btn-cancel" id="cancel-dl-btn">Cancel</button>
|
||||
<button class="btn-confirm" id="confirm-dl-btn">Start Download</button>
|
||||
<!-- Settings Panel -->
|
||||
<div class="settings-panel" id="settings-panel">
|
||||
<!-- Populated dynamically by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,12 +248,44 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="download-modal">
|
||||
<div class="modal-content download-settings-content">
|
||||
<button class="modal-close" id="close-download-modal">✕</button>
|
||||
<h2 class="modal-title">Download Settings</h2>
|
||||
|
||||
<div class="download-sections-wrapper">
|
||||
<div id="dl-quality-section" class="dl-section" style="display:none;">
|
||||
<h3>Video Quality</h3>
|
||||
<div class="dl-list" id="dl-quality-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="dl-audio-section" class="dl-section" style="display:none;">
|
||||
<h3>Audio Tracks</h3>
|
||||
<div class="dl-list" id="dl-audio-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="dl-subs-section" class="dl-section">
|
||||
<h3>Subtitles</h3>
|
||||
<div class="dl-list" id="dl-subs-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dl-actions">
|
||||
<button class="btn-cancel" id="cancel-dl-btn">Cancel</button>
|
||||
<button class="btn-confirm" id="confirm-dl-btn">Start Download</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/src/scripts/utils/auth-utils.js"></script>
|
||||
<script src="/src/scripts/auth-guard.js"></script>
|
||||
<script src="/src/scripts/utils/url-utils.js"></script>
|
||||
<script src="/src/scripts/utils/pagination-manager.js"></script>
|
||||
<script src="/src/scripts/utils/media-metadata-utils.js"></script>
|
||||
<script src="/src/scripts/utils/youtube-player-utils.js"></script>
|
||||
<script src="/src/scripts/utils/list-modal-manager.js"></script>
|
||||
<script src="/src/scripts/utils/match-modal.js"></script>
|
||||
<script src="/src/scripts/anime/subtitle-renderer.js"></script>
|
||||
|
||||
<script src="/src/scripts/anime/player.js"></script>
|
||||
<script src="/src/scripts/anime/entry.js"></script>
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
<link rel="stylesheet" href="/views/css/globals.css">
|
||||
<link rel="stylesheet" href="/views/css/components/navbar.css">
|
||||
<link rel="stylesheet" href="/views/css/components/hero.css">
|
||||
<link rel="stylesheet" href="/views/css/home.css">
|
||||
<link rel="stylesheet" href="/views/css/components/anilist-modal.css">
|
||||
<link rel="stylesheet" href="/views/css/components/updateNotifier.css">
|
||||
<link rel="stylesheet" href="/views/css/components/create-room.css"/>
|
||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
|
||||
</head>
|
||||
<body>
|
||||
@@ -111,6 +113,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<script src="/src/scripts/utils/auth-utils.js"></script>
|
||||
<script src="/src/scripts/room-modal.js"></script>
|
||||
<script src="/src/scripts/utils/notification-utils.js"></script>
|
||||
<script src="/src/scripts/utils/search-manager.js"></script>
|
||||
<script src="/src/scripts/utils/list-modal-manager.js"></script>
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
<title>WaifuBoard Book</title>
|
||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/views/css/globals.css">
|
||||
<link rel="stylesheet" href="/views/css/components/anilist-modal.css">
|
||||
<link rel="stylesheet" href="/views/css/books/book.css">
|
||||
<link rel="stylesheet" href="/views/css/components/anilist-modal.css">
|
||||
<link rel="stylesheet" href="/views/css/components/updateNotifier.css">
|
||||
<link rel="stylesheet" href="/views/css/components/match-modal.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -80,6 +81,12 @@
|
||||
<h2>Chapters</h2>
|
||||
|
||||
<div class="chapter-controls">
|
||||
<button id="manual-match-btn" class="glass-btn-icon" style="display: none;" title="Manual Match">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<select id="provider-filter" class="glass-select" style="display: none;">
|
||||
<option value="all">All Providers</option>
|
||||
</select>
|
||||
@@ -109,9 +116,6 @@
|
||||
<button class="page-btn" id="next-page">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -139,6 +143,7 @@
|
||||
<script src="/src/scripts/utils/pagination-manager.js"></script>
|
||||
<script src="/src/scripts/utils/media-metadata-utils.js"></script>
|
||||
<script src="/src/scripts/utils/list-modal-manager.js"></script>
|
||||
<script src="/src/scripts/utils/match-modal.js"></script>
|
||||
<script src="/src/scripts/books/book.js"></script>
|
||||
<script src="/src/scripts/auth-guard.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
<link rel="stylesheet" href="/views/css/globals.css">
|
||||
<link rel="stylesheet" href="/views/css/components/navbar.css">
|
||||
<link rel="stylesheet" href="/views/css/components/hero.css">
|
||||
<link rel="stylesheet" href="/views/css/home.css">
|
||||
<link rel="stylesheet" href="/views/css/components/anilist-modal.css">
|
||||
<link rel="stylesheet" href="/views/css/components/updateNotifier.css">
|
||||
<link rel="stylesheet" href="/views/css/components/local-library.css">
|
||||
<link rel="stylesheet" href="/views/css/components/create-room.css"/>
|
||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
|
||||
</head>
|
||||
<body>
|
||||
@@ -82,6 +84,7 @@
|
||||
</div>
|
||||
|
||||
<script src="/src/scripts/utils/auth-utils.js"></script>
|
||||
<script src="/src/scripts/room-modal.js"></script>
|
||||
<script src="/src/scripts/utils/notification-utils.js"></script>
|
||||
<script src="/src/scripts/utils/search-manager.js"></script>
|
||||
<script src="/src/scripts/utils/list-modal-manager.js"></script>
|
||||
|
||||
@@ -53,6 +53,13 @@
|
||||
<span>Profile</span>
|
||||
</a>
|
||||
|
||||
<button class="dropdown-item" id="nav-create-party">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
||||
</svg>
|
||||
<span>Watchparty</span>
|
||||
</button>
|
||||
|
||||
<button class="dropdown-item" id="nav-settings">
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
@@ -71,5 +78,10 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="mobile-menu-toggle" id="mobile-menu-toggle">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user