Compare commits
10 Commits
dc5f7fa5c8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6507d13325 | |||
| 967eb2d2a4 | |||
| 9b495b7bf7 | |||
| 075780435c | |||
| ef1f8438e6 | |||
| 79068c0faa | |||
| 8089a816ad | |||
| f6d54ca623 | |||
| dcd66fdd11 | |||
| ce1693cf4e |
@@ -7,6 +7,8 @@ JWT_SECRET=replace_me
|
|||||||
APP_HOST=localhost
|
APP_HOST=localhost
|
||||||
SERVER_PORT=3001
|
SERVER_PORT=3001
|
||||||
WEB_PORT=5173
|
WEB_PORT=5173
|
||||||
|
WEB_ORIGIN=http://localhost:5173
|
||||||
|
WEB_ALLOWED_ORIGINS=http://192.168.1.205:5175,http://qbuffer.bee
|
||||||
POLL_INTERVAL_MS=3000
|
POLL_INTERVAL_MS=3000
|
||||||
ENFORCE_INTERVAL_MS=2000
|
ENFORCE_INTERVAL_MS=2000
|
||||||
DEFAULT_DELAY_MS=3000
|
DEFAULT_DELAY_MS=3000
|
||||||
@@ -14,4 +16,3 @@ MAX_LOOP_LIMIT=1000
|
|||||||
STALLED_RECOVERY_MS=300000
|
STALLED_RECOVERY_MS=300000
|
||||||
TIMER_POLL_MS=60000
|
TIMER_POLL_MS=60000
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
WEB_ALLOWED_ORIGINS=http://192.168.1.205:5175,http://qbuffer.bee
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ FROM base AS deps
|
|||||||
COPY package.json pnpm-workspace.yaml ./
|
COPY package.json pnpm-workspace.yaml ./
|
||||||
COPY apps/server/package.json apps/server/package.json
|
COPY apps/server/package.json apps/server/package.json
|
||||||
COPY apps/web/package.json apps/web/package.json
|
COPY apps/web/package.json apps/web/package.json
|
||||||
RUN pnpm install --frozen-lockfile=false
|
RUN pnpm install --frozen-lockfile=false --prod=false
|
||||||
|
|
||||||
FROM deps AS build
|
FROM deps AS build
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -17,6 +17,7 @@ FROM base AS prod
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules /app/node_modules
|
COPY --from=deps /app/node_modules /app/node_modules
|
||||||
|
COPY --from=deps /app/apps/server/node_modules /app/apps/server/node_modules
|
||||||
COPY --from=build /app/apps/server/dist /app/apps/server/dist
|
COPY --from=build /app/apps/server/dist /app/apps/server/dist
|
||||||
COPY --from=build /app/apps/server/package.json /app/apps/server/package.json
|
COPY --from=build /app/apps/server/package.json /app/apps/server/package.json
|
||||||
COPY --from=build /app/apps/server/public /app/apps/server/public
|
COPY --from=build /app/apps/server/public /app/apps/server/public
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ Ardından http://localhost:3001
|
|||||||
- `QBIT_BASE_URL`, `QBIT_USERNAME`, `QBIT_PASSWORD`
|
- `QBIT_BASE_URL`, `QBIT_USERNAME`, `QBIT_PASSWORD`
|
||||||
- `APP_USERNAME`, `APP_PASSWORD`, `JWT_SECRET`
|
- `APP_USERNAME`, `APP_PASSWORD`, `JWT_SECRET`
|
||||||
- `POLL_INTERVAL_MS`, `ENFORCE_INTERVAL_MS`, `DEFAULT_DELAY_MS`, `MAX_LOOP_LIMIT`
|
- `POLL_INTERVAL_MS`, `ENFORCE_INTERVAL_MS`, `DEFAULT_DELAY_MS`, `MAX_LOOP_LIMIT`
|
||||||
- `WEB_ALLOWED_HOSTS` (ör: `localhost,qbuffer.bee`)
|
- `WEB_ALLOWED_HOSTS` (ör: `localhost,qbuffer.bee,qbuffer.panda`)
|
||||||
|
|
||||||
## Klasör Yapısı
|
## Klasör Yapısı
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { verifyToken } from "./auth.service"
|
import { verifyToken } from "./auth.service"
|
||||||
|
|
||||||
export const requireAuth = (req: Request, res: Response, next: NextFunction) => {
|
export const requireAuth = (req: any, res: any, next: any) => {
|
||||||
const cookieToken = req.cookies?.["qbuffer_token"];
|
const cookieToken = req.cookies?.["qbuffer_token"];
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
const bearer = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined;
|
const bearer = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined;
|
||||||
@@ -17,9 +16,3 @@ export const requireAuth = (req: Request, res: Response, next: NextFunction) =>
|
|||||||
return res.status(401).json({ error: "Unauthorized" });
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
declare module "express-serve-static-core" {
|
|
||||||
interface Request {
|
|
||||||
user?: { username: string };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ export const startEnforcementWorker = (intervalMs: number) => {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const peers = Object.values(peersResponse.peers || {});
|
const peers = Object.values(peersResponse.peers || {}) as Array<{
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
}>;
|
||||||
let allowIpConnected = false;
|
let allowIpConnected = false;
|
||||||
const banned: string[] = [];
|
const banned: string[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import { config, isDev } from "./config"
|
import { config, isDev } from "./config"
|
||||||
@@ -25,12 +26,39 @@ import { startEnforcementWorker } from "./enforcement/enforcement.worker"
|
|||||||
import { startTimerWorker } from "./timer/timer.worker"
|
import { startTimerWorker } from "./timer/timer.worker"
|
||||||
import { logger } from "./utils/logger"
|
import { logger } from "./utils/logger"
|
||||||
|
|
||||||
|
const crashLogPath = "/app/data/crash.log";
|
||||||
|
|
||||||
|
const appendCrashLog = async (label: string, detail: unknown) => {
|
||||||
|
try {
|
||||||
|
const payload =
|
||||||
|
detail instanceof Error
|
||||||
|
? { message: detail.message, stack: detail.stack }
|
||||||
|
: { detail };
|
||||||
|
const line = `${new Date().toISOString()} ${label} ${JSON.stringify(payload)}\n`;
|
||||||
|
await fs.appendFile(crashLogPath, line, "utf-8");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to append crash log");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
process.on("unhandledRejection", (reason) => {
|
process.on("unhandledRejection", (reason) => {
|
||||||
logger.error({ reason }, "Unhandled promise rejection");
|
logger.error({ reason }, "Unhandled promise rejection");
|
||||||
|
appendCrashLog("unhandledRejection", reason);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
logger.error({ error }, "Uncaught exception");
|
logger.error({ error }, "Uncaught exception");
|
||||||
|
appendCrashLog("uncaughtException", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
logger.warn("Received SIGTERM, shutting down");
|
||||||
|
appendCrashLog("SIGTERM", { pid: process.pid });
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
logger.warn("Received SIGINT, shutting down");
|
||||||
|
appendCrashLog("SIGINT", { pid: process.pid });
|
||||||
});
|
});
|
||||||
|
|
||||||
let serverStarted = false;
|
let serverStarted = false;
|
||||||
|
|||||||
@@ -48,8 +48,14 @@ router.post("/start", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const torrentFilePath = archive?.torrentFilePath;
|
||||||
|
if (!torrentFilePath) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Arşiv dosyası bulunamadı. Lütfen tekrar yükleyin.",
|
||||||
|
});
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await fs.access(archive.torrentFilePath);
|
await fs.access(torrentFilePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: "Arşiv dosyası bulunamadı. Lütfen tekrar yükleyin.",
|
error: "Arşiv dosyası bulunamadı. Lütfen tekrar yükleyin.",
|
||||||
@@ -60,7 +66,7 @@ router.post("/start", async (req, res) => {
|
|||||||
name: torrent.name,
|
name: torrent.name,
|
||||||
sizeBytes: torrent.size,
|
sizeBytes: torrent.size,
|
||||||
magnet: undefined,
|
magnet: undefined,
|
||||||
torrentFilePath: archive?.torrentFilePath,
|
torrentFilePath,
|
||||||
allowIp,
|
allowIp,
|
||||||
targetLoops,
|
targetLoops,
|
||||||
delayMs,
|
delayMs,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { QbitClient } from "../qbit/qbit.client"
|
import { QbitClient } from "../qbit/qbit.client"
|
||||||
import { tickLoopJobs } from "./loop.engine"
|
import { tickLoopJobs } from "./loop.engine"
|
||||||
import { getStatusSnapshot, refreshJobsStatus, setTorrentsStatus } from "../status/status.service"
|
import { getStatusSnapshot, refreshJobsStatus, setQbitStatus, setTorrentsStatus } from "../status/status.service"
|
||||||
import { emitStatusUpdate } from "../realtime/emitter"
|
import { emitQbitHealth, emitStatusUpdate } from "../realtime/emitter"
|
||||||
import { logger } from "../utils/logger"
|
import { logger } from "../utils/logger"
|
||||||
|
|
||||||
export const startLoopScheduler = (qbit: QbitClient, intervalMs: number) => {
|
export const startLoopScheduler = (qbit: QbitClient, intervalMs: number) => {
|
||||||
@@ -20,7 +20,19 @@ export const startLoopScheduler = (qbit: QbitClient, intervalMs: number) => {
|
|||||||
jobs,
|
jobs,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
logger.error({ error }, "Loop scheduler tick failed");
|
logger.error({ error }, "Loop scheduler tick failed");
|
||||||
|
setQbitStatus({ ok: false, lastError: message });
|
||||||
|
emitQbitHealth({ ok: false, lastError: message });
|
||||||
|
try {
|
||||||
|
const current = await getStatusSnapshot();
|
||||||
|
emitStatusUpdate({
|
||||||
|
...current,
|
||||||
|
qbit: { ...current.qbit, ok: false, lastError: message },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Swallow secondary status errors to keep scheduler alive.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ export class QbitClient {
|
|||||||
}
|
}
|
||||||
return await fn();
|
return await fn();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const code = error.code ?? "";
|
||||||
|
const transient =
|
||||||
|
code === "EAI_AGAIN" ||
|
||||||
|
code === "ENOTFOUND" ||
|
||||||
|
code === "ECONNREFUSED" ||
|
||||||
|
code === "ECONNRESET" ||
|
||||||
|
code === "ETIMEDOUT";
|
||||||
|
if (transient) {
|
||||||
|
this.loggedIn = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
axios.isAxiosError(error) &&
|
axios.isAxiosError(error) &&
|
||||||
(error.response?.status === 401 || error.response?.status === 403)
|
(error.response?.status === 401 || error.response?.status === 403)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface QbitTorrentInfo {
|
|||||||
tags?: string;
|
tags?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
tracker?: string;
|
tracker?: string;
|
||||||
|
added_on?: number;
|
||||||
seeding_time?: number;
|
seeding_time?: number;
|
||||||
uploaded?: number;
|
uploaded?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const router = Router();
|
|||||||
const ruleSchema = z.object({
|
const ruleSchema = z.object({
|
||||||
tags: z.array(z.string().min(1)).min(1),
|
tags: z.array(z.string().min(1)).min(1),
|
||||||
seedLimitSeconds: z.number().int().min(60).max(60 * 60 * 24 * 365),
|
seedLimitSeconds: z.number().int().min(60).max(60 * 60 * 24 * 365),
|
||||||
|
deleteFiles: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/rules", async (_req, res) => {
|
router.get("/rules", async (_req, res) => {
|
||||||
@@ -27,6 +28,7 @@ router.post("/rules", async (req, res) => {
|
|||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
tags: parsed.data.tags,
|
tags: parsed.data.tags,
|
||||||
seedLimitSeconds: parsed.data.seedLimitSeconds,
|
seedLimitSeconds: parsed.data.seedLimitSeconds,
|
||||||
|
deleteFiles: parsed.data.deleteFiles ?? true,
|
||||||
createdAt: nowIso(),
|
createdAt: nowIso(),
|
||||||
};
|
};
|
||||||
db.timerRules = [...(db.timerRules ?? []), rule];
|
db.timerRules = [...(db.timerRules ?? []), rule];
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export interface TimerRuleInput {
|
export interface TimerRuleInput {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
seedLimitSeconds: number;
|
seedLimitSeconds: number;
|
||||||
|
deleteFiles?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { readDb, writeDb } from "../storage/jsondb";
|
|||||||
import { TimerLog, TimerSummary } from "../types";
|
import { TimerLog, TimerSummary } from "../types";
|
||||||
import { emitTimerLog, emitTimerSummary } from "../realtime/emitter";
|
import { emitTimerLog, emitTimerSummary } from "../realtime/emitter";
|
||||||
import { nowIso } from "../utils/time";
|
import { nowIso } from "../utils/time";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
const MAX_LOGS = 2000;
|
const MAX_LOGS = 2000;
|
||||||
|
|
||||||
@@ -17,78 +18,80 @@ const normalizeTags = (tags?: string, category?: string) => {
|
|||||||
|
|
||||||
export const startTimerWorker = (qbit: QbitClient, intervalMs: number) => {
|
export const startTimerWorker = (qbit: QbitClient, intervalMs: number) => {
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
const db = await readDb();
|
try {
|
||||||
const rules = db.timerRules ?? [];
|
const db = await readDb();
|
||||||
if (rules.length === 0) {
|
const rules = db.timerRules ?? [];
|
||||||
return;
|
if (rules.length === 0) {
|
||||||
}
|
return;
|
||||||
const torrents = await qbit.getTorrentsInfo();
|
}
|
||||||
let summary: TimerSummary =
|
const torrents = await qbit.getTorrentsInfo();
|
||||||
db.timerSummary ?? {
|
let summary: TimerSummary =
|
||||||
totalDeleted: 0,
|
db.timerSummary ?? {
|
||||||
totalSeededSeconds: 0,
|
totalDeleted: 0,
|
||||||
totalUploadedBytes: 0,
|
totalSeededSeconds: 0,
|
||||||
updatedAt: nowIso(),
|
totalUploadedBytes: 0,
|
||||||
};
|
updatedAt: nowIso(),
|
||||||
|
};
|
||||||
|
|
||||||
const logs: TimerLog[] = [];
|
const logs: TimerLog[] = [];
|
||||||
|
|
||||||
for (const torrent of torrents) {
|
for (const torrent of torrents) {
|
||||||
const tags = normalizeTags(torrent.tags, torrent.category);
|
const tags = normalizeTags(torrent.tags, torrent.category);
|
||||||
const addedOnMs = Number(torrent.added_on ?? 0) * 1000;
|
const matchingRules = rules.filter((rule) => {
|
||||||
const matchingRules = rules.filter((rule) => {
|
return rule.tags.some((tag) => tags.includes(tag.toLowerCase()));
|
||||||
const ruleCreatedAtMs = Date.parse(rule.createdAt);
|
});
|
||||||
if (Number.isFinite(ruleCreatedAtMs) && addedOnMs > 0) {
|
if (matchingRules.length === 0) {
|
||||||
if (addedOnMs < ruleCreatedAtMs) {
|
continue;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return rule.tags.some((tag) => tags.includes(tag.toLowerCase()));
|
const matchingRule = matchingRules.reduce((best, current) =>
|
||||||
});
|
current.seedLimitSeconds < best.seedLimitSeconds ? current : best
|
||||||
if (matchingRules.length === 0) {
|
);
|
||||||
continue;
|
const addedOnMs = Number(torrent.added_on ?? 0) * 1000;
|
||||||
}
|
const elapsedSeconds =
|
||||||
const matchingRule = matchingRules.reduce((best, current) =>
|
addedOnMs > 0
|
||||||
current.seedLimitSeconds < best.seedLimitSeconds ? current : best
|
? Math.max(0, (Date.now() - addedOnMs) / 1000)
|
||||||
);
|
: Number(torrent.seeding_time ?? 0);
|
||||||
const seedingSeconds = Number(torrent.seeding_time ?? 0);
|
const seedingSeconds = Number(torrent.seeding_time ?? 0);
|
||||||
if (seedingSeconds < matchingRule.seedLimitSeconds) {
|
if (elapsedSeconds < matchingRule.seedLimitSeconds) {
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await qbit.deleteTorrent(torrent.hash, matchingRule.deleteFiles ?? true);
|
||||||
|
} catch (error) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logEntry: TimerLog = {
|
||||||
|
id: randomUUID(),
|
||||||
|
hash: torrent.hash,
|
||||||
|
name: torrent.name,
|
||||||
|
sizeBytes: torrent.size,
|
||||||
|
tracker: torrent.tracker,
|
||||||
|
tags,
|
||||||
|
category: torrent.category,
|
||||||
|
seedingTimeSeconds: seedingSeconds,
|
||||||
|
uploadedBytes: torrent.uploaded ?? 0,
|
||||||
|
deletedAt: nowIso(),
|
||||||
|
};
|
||||||
|
logs.push(logEntry);
|
||||||
|
summary = {
|
||||||
|
totalDeleted: summary.totalDeleted + 1,
|
||||||
|
totalSeededSeconds: summary.totalSeededSeconds + seedingSeconds,
|
||||||
|
totalUploadedBytes: summary.totalUploadedBytes + (torrent.uploaded ?? 0),
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
};
|
||||||
|
emitTimerLog(logEntry);
|
||||||
|
emitTimerSummary(summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (logs.length > 0) {
|
||||||
await qbit.deleteTorrent(torrent.hash, true);
|
db.timerLogs = [...(db.timerLogs ?? []), ...logs].slice(-MAX_LOGS);
|
||||||
} catch (error) {
|
db.timerSummary = summary;
|
||||||
continue;
|
await writeDb(db);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
const logEntry: TimerLog = {
|
logger.error({ error }, "Timer worker tick failed");
|
||||||
id: randomUUID(),
|
|
||||||
hash: torrent.hash,
|
|
||||||
name: torrent.name,
|
|
||||||
sizeBytes: torrent.size,
|
|
||||||
tracker: torrent.tracker,
|
|
||||||
tags,
|
|
||||||
category: torrent.category,
|
|
||||||
seedingTimeSeconds: seedingSeconds,
|
|
||||||
uploadedBytes: torrent.uploaded ?? 0,
|
|
||||||
deletedAt: nowIso(),
|
|
||||||
};
|
|
||||||
logs.push(logEntry);
|
|
||||||
summary = {
|
|
||||||
totalDeleted: summary.totalDeleted + 1,
|
|
||||||
totalSeededSeconds: summary.totalSeededSeconds + seedingSeconds,
|
|
||||||
totalUploadedBytes: summary.totalUploadedBytes + (torrent.uploaded ?? 0),
|
|
||||||
updatedAt: nowIso(),
|
|
||||||
};
|
|
||||||
emitTimerLog(logEntry);
|
|
||||||
emitTimerSummary(summary);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (logs.length > 0) {
|
|
||||||
db.timerLogs = [...(db.timerLogs ?? []), ...logs].slice(-MAX_LOGS);
|
|
||||||
db.timerSummary = summary;
|
|
||||||
await writeDb(db);
|
|
||||||
}
|
}
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const generateTorrentFile = async (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
torrent.on("error", (error) => {
|
torrent.on("error", (error: unknown) => {
|
||||||
logger.error({ error }, "Torrent metadata error");
|
logger.error({ error }, "Torrent metadata error");
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
client.destroy();
|
client.destroy();
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export interface TimerRule {
|
|||||||
id: string;
|
id: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
seedLimitSeconds: number;
|
seedLimitSeconds: number;
|
||||||
|
deleteFiles: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
apps/server/src/types/shims.d.ts
vendored
Normal file
22
apps/server/src/types/shims.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
declare module "tough-cookie" {
|
||||||
|
export class CookieJar {
|
||||||
|
constructor(...args: any[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "webtorrent" {
|
||||||
|
export default class WebTorrent {
|
||||||
|
add(...args: any[]): any;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "parse-torrent" {
|
||||||
|
export default function parseTorrent(...args: any[]): any;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "express-serve-static-core" {
|
||||||
|
interface Request {
|
||||||
|
user?: { username: string };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
<title>q-buffer</title>
|
<title>q-buffer</title>
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<link rel="apple-touch-icon" sizes="152x152" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="152x152" href="/apple-touch-icon.png">
|
||||||
<link rel="stylesheet"
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" />
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||||
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"postcss": "^8.4.39",
|
"postcss": "^8.4.39",
|
||||||
"tailwindcss": "^3.4.6",
|
"tailwindcss": "^3.4.6",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"vite": "^5.3.3"
|
"vite": "^5.3.3"
|
||||||
}
|
}
|
||||||
|
|||||||
27
apps/web/src/components/ui/ScrollArea.tsx
Normal file
27
apps/web/src/components/ui/ScrollArea.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
export const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={clsx("relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full overflow-y-auto rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollAreaPrimitive.Scrollbar
|
||||||
|
orientation="vertical"
|
||||||
|
className="flex w-2.5 touch-none select-none rounded-full bg-transparent p-0.5 opacity-100"
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Thumb className="relative flex-1 rounded-full bg-slate-200" />
|
||||||
|
</ScrollAreaPrimitive.Scrollbar>
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
));
|
||||||
|
|
||||||
|
ScrollArea.displayName = "ScrollArea";
|
||||||
177
apps/web/src/components/ui/Select.tsx
Normal file
177
apps/web/src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
const ChevronDown = ({ className }: { className?: string }) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
className={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.17l3.71-3.94a.75.75 0 1 1 1.08 1.04l-4.24 4.5a.75.75 0 0 1-1.08 0l-4.24-4.5a.75.75 0 0 1 .02-1.06Z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChevronUp = ({ className }: { className?: string }) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
className={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M14.77 12.79a.75.75 0 0 1-1.06-.02L10 8.83l-3.71 3.94a.75.75 0 1 1-1.08-1.04l4.24-4.5a.75.75 0 0 1 1.08 0l4.24 4.5a.75.75 0 0 1-.02 1.06Z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Check = ({ className }: { className?: string }) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
className={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.7 5.29a1 1 0 0 1 .01 1.42l-7.5 7.5a1 1 0 0 1-1.42 0l-3.5-3.5a1 1 0 1 1 1.42-1.42l2.79 2.8 6.79-6.8a1 1 0 0 1 1.41 0Z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
export const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
|
export const SelectValue = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Value>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Value>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Value
|
||||||
|
ref={ref}
|
||||||
|
className={clsx("block truncate", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectValue.displayName = "SelectValue";
|
||||||
|
|
||||||
|
export const SelectLabel = SelectPrimitive.Label;
|
||||||
|
|
||||||
|
export const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(
|
||||||
|
"inline-flex h-10 flex-shrink-0 items-center justify-between gap-2 whitespace-nowrap rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm transition focus:outline-none focus:ring-2 focus:ring-slate-300 data-[placeholder]:text-slate-400 disabled:cursor-not-allowed disabled:opacity-50 [&_[data-radix-select-trigger-icon-wrapper]]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = "SelectTrigger";
|
||||||
|
|
||||||
|
export const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(
|
||||||
|
"flex cursor-default items-center justify-center py-1 text-slate-400",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
SelectScrollUpButton.displayName = "SelectScrollUpButton";
|
||||||
|
|
||||||
|
export const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(
|
||||||
|
"flex cursor-default items-center justify-center py-1 text-slate-400",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
SelectScrollDownButton.displayName = "SelectScrollDownButton";
|
||||||
|
|
||||||
|
export const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(
|
||||||
|
"relative z-50 max-h-72 overflow-hidden rounded-md border border-slate-200 bg-white text-slate-700 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"min-w-[var(--radix-select-trigger-width)] w-[var(--radix-select-trigger-width)] data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={clsx(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = "SelectContent";
|
||||||
|
|
||||||
|
export const SelectSeparator = SelectPrimitive.Separator;
|
||||||
|
|
||||||
|
export const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-3.5 w-3.5 text-slate-700" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = "SelectItem";
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
:root {
|
:root {
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
background: #f1f5f9;
|
background: #f1f5f9;
|
||||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
font-family: "Helvetica", "Helvetica Neue", Arial, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root.dark {
|
:root.dark {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from "react";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/Card";
|
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/Card";
|
||||||
import { Button } from "../components/ui/Button";
|
import { Button } from "../components/ui/Button";
|
||||||
import { Input } from "../components/ui/Input";
|
import { Input } from "../components/ui/Input";
|
||||||
|
import { ScrollArea } from "../components/ui/ScrollArea";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { useAppStore } from "../store/useAppStore";
|
import { useAppStore } from "../store/useAppStore";
|
||||||
import { useUiStore } from "../store/useUiStore";
|
import { useUiStore } from "../store/useUiStore";
|
||||||
@@ -16,6 +17,13 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "../components/ui/AlertDialog";
|
} from "../components/ui/AlertDialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../components/ui/Select";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import {
|
import {
|
||||||
faClockRotateLeft,
|
faClockRotateLeft,
|
||||||
@@ -33,6 +41,14 @@ const unitOptions = [
|
|||||||
{ label: "Hafta", value: "weeks", seconds: 604800 },
|
{ label: "Hafta", value: "weeks", seconds: 604800 },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
{ label: "İsim", value: "name" },
|
||||||
|
{ label: "Boyut", value: "size" },
|
||||||
|
{ label: "Geri Sayım", value: "countdown" },
|
||||||
|
{ label: "Tracker", value: "tracker" },
|
||||||
|
{ label: "Eklenme Tarihi", value: "addedOn" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
const formatBytes = (value: number) => {
|
const formatBytes = (value: number) => {
|
||||||
if (!Number.isFinite(value)) return "0 B";
|
if (!Number.isFinite(value)) return "0 B";
|
||||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
@@ -67,6 +83,11 @@ const formatCountdown = (seconds: number) => {
|
|||||||
return `${days}g ${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
|
return `${days}g ${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatAddedOn = (addedOn?: number) => {
|
||||||
|
if (!Number.isFinite(addedOn)) return "Bilinmiyor";
|
||||||
|
return new Date(addedOn * 1000).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
const trackerLabel = (tracker?: string) => {
|
const trackerLabel = (tracker?: string) => {
|
||||||
if (!tracker) return "Bilinmiyor";
|
if (!tracker) return "Bilinmiyor";
|
||||||
try {
|
try {
|
||||||
@@ -91,6 +112,10 @@ export const TimerPage = () => {
|
|||||||
const [seedUnit, setSeedUnit] = useState<(typeof unitOptions)[number]["value"]>(
|
const [seedUnit, setSeedUnit] = useState<(typeof unitOptions)[number]["value"]>(
|
||||||
"weeks"
|
"weeks"
|
||||||
);
|
);
|
||||||
|
const [sortKey, setSortKey] =
|
||||||
|
useState<(typeof sortOptions)[number]["value"]>("countdown");
|
||||||
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||||
|
const [deleteFiles, setDeleteFiles] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const pushAlert = useUiStore((s) => s.pushAlert);
|
const pushAlert = useUiStore((s) => s.pushAlert);
|
||||||
const [nowTick, setNowTick] = useState(() => Date.now());
|
const [nowTick, setNowTick] = useState(() => Date.now());
|
||||||
@@ -136,11 +161,7 @@ export const TimerPage = () => {
|
|||||||
const rule = matchingRules.reduce((best, current) =>
|
const rule = matchingRules.reduce((best, current) =>
|
||||||
current.seedLimitSeconds < best.seedLimitSeconds ? current : best
|
current.seedLimitSeconds < best.seedLimitSeconds ? current : best
|
||||||
);
|
);
|
||||||
const ruleCreatedAtMs = Date.parse(rule.createdAt);
|
const baseMs = addedOnMs || nowTick;
|
||||||
let baseMs = addedOnMs || nowTick;
|
|
||||||
if (Number.isFinite(ruleCreatedAtMs) && ruleCreatedAtMs > baseMs) {
|
|
||||||
baseMs = ruleCreatedAtMs;
|
|
||||||
}
|
|
||||||
const elapsedSeconds = Math.max(0, (nowTick - baseMs) / 1000);
|
const elapsedSeconds = Math.max(0, (nowTick - baseMs) / 1000);
|
||||||
const remainingSeconds = rule.seedLimitSeconds - elapsedSeconds;
|
const remainingSeconds = rule.seedLimitSeconds - elapsedSeconds;
|
||||||
return {
|
return {
|
||||||
@@ -156,6 +177,47 @@ export const TimerPage = () => {
|
|||||||
}>;
|
}>;
|
||||||
}, [timerRules, torrents, nowTick]);
|
}, [timerRules, torrents, nowTick]);
|
||||||
|
|
||||||
|
const sortedMatchingTorrents = useMemo(() => {
|
||||||
|
const direction = sortDirection === "asc" ? 1 : -1;
|
||||||
|
const withFallback = (value: number | string | undefined, fallback: number | string) =>
|
||||||
|
value === undefined || value === null || value === "" ? fallback : value;
|
||||||
|
|
||||||
|
return [...matchingTorrents].sort((a, b) => {
|
||||||
|
switch (sortKey) {
|
||||||
|
case "name":
|
||||||
|
return (
|
||||||
|
String(withFallback(a.torrent.name, ""))
|
||||||
|
.localeCompare(String(withFallback(b.torrent.name, "")), "tr") *
|
||||||
|
direction
|
||||||
|
);
|
||||||
|
case "size":
|
||||||
|
return (
|
||||||
|
(Number(withFallback(a.torrent.size, 0)) -
|
||||||
|
Number(withFallback(b.torrent.size, 0))) *
|
||||||
|
direction
|
||||||
|
);
|
||||||
|
case "tracker":
|
||||||
|
return (
|
||||||
|
trackerLabel(a.torrent.tracker)
|
||||||
|
.localeCompare(trackerLabel(b.torrent.tracker), "tr") * direction
|
||||||
|
);
|
||||||
|
case "addedOn":
|
||||||
|
return (
|
||||||
|
(Number(withFallback(a.torrent.added_on, 0)) -
|
||||||
|
Number(withFallback(b.torrent.added_on, 0))) *
|
||||||
|
direction
|
||||||
|
);
|
||||||
|
case "countdown":
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
(Number(withFallback(a.remainingSeconds, 0)) -
|
||||||
|
Number(withFallback(b.remainingSeconds, 0))) *
|
||||||
|
direction
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [matchingTorrents, sortDirection, sortKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -225,9 +287,11 @@ export const TimerPage = () => {
|
|||||||
const response = await api.post("/api/timer/rules", {
|
const response = await api.post("/api/timer/rules", {
|
||||||
tags: selectedTags,
|
tags: selectedTags,
|
||||||
seedLimitSeconds,
|
seedLimitSeconds,
|
||||||
|
deleteFiles,
|
||||||
});
|
});
|
||||||
setTimerRules([response.data, ...timerRules]);
|
setTimerRules([response.data, ...timerRules]);
|
||||||
setSelectedTags([]);
|
setSelectedTags([]);
|
||||||
|
setDeleteFiles(true);
|
||||||
pushAlert({
|
pushAlert({
|
||||||
title: "Kural kaydedildi",
|
title: "Kural kaydedildi",
|
||||||
description: "Timer kuralı aktif edildi.",
|
description: "Timer kuralı aktif edildi.",
|
||||||
@@ -278,15 +342,56 @@ export const TimerPage = () => {
|
|||||||
<FontAwesomeIcon icon={faHourglassHalf} className="text-slate-400" />
|
<FontAwesomeIcon icon={faHourglassHalf} className="text-slate-400" />
|
||||||
Zamanlayıcı Torrentleri
|
Zamanlayıcı Torrentleri
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2 text-xs font-semibold text-slate-500">
|
||||||
|
<span>Sıralama</span>
|
||||||
|
<Select
|
||||||
|
value={sortKey}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value !== sortKey) {
|
||||||
|
setSortKey(value as typeof sortKey);
|
||||||
|
setSortDirection("asc");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-[180px] flex-shrink-0 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sortOptions.map((option) => (
|
||||||
|
<SelectItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
onPointerDown={() => {
|
||||||
|
if (option.value === sortKey) {
|
||||||
|
setSortDirection((current) =>
|
||||||
|
current === "asc" ? "desc" : "asc"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (option.value !== sortKey) return;
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
setSortDirection((current) =>
|
||||||
|
current === "asc" ? "desc" : "asc"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{matchingTorrents.length === 0 ? (
|
{sortedMatchingTorrents.length === 0 ? (
|
||||||
<div className="text-sm text-slate-500">
|
<div className="text-sm text-slate-500">
|
||||||
Bu kurallara bağlı aktif torrent bulunamadı.
|
Bu kurallara bağlı aktif torrent bulunamadı.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{matchingTorrents.map(({ torrent, rule, remainingSeconds }) => (
|
{sortedMatchingTorrents.map(({ torrent, rule, remainingSeconds }) => (
|
||||||
<div
|
<div
|
||||||
key={torrent.hash}
|
key={torrent.hash}
|
||||||
className="rounded-lg border border-slate-200 bg-white px-3 py-2"
|
className="rounded-lg border border-slate-200 bg-white px-3 py-2"
|
||||||
@@ -322,6 +427,9 @@ export const TimerPage = () => {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(", ") || "-"}
|
.join(", ") || "-"}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="truncate">
|
||||||
|
Added: {formatAddedOn(torrent.added_on)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -336,39 +444,43 @@ export const TimerPage = () => {
|
|||||||
Silinen Torrent Logları
|
Silinen Torrent Logları
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3 overflow-hidden">
|
||||||
{timerLogs.length === 0 ? (
|
{timerLogs.length === 0 ? (
|
||||||
<div className="text-sm text-slate-500">Henüz log yok.</div>
|
<div className="text-sm text-slate-500">Henüz log yok.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="overflow-hidden" style={{ height: 560 }}>
|
||||||
{timerLogs.map((log) => (
|
<ScrollArea className="h-full w-full" type="always">
|
||||||
<div
|
<div className="space-y-3 pr-3">
|
||||||
key={log.id}
|
{timerLogs.map((log) => (
|
||||||
className="rounded-lg border border-slate-200 bg-white px-3 py-2"
|
<div
|
||||||
>
|
key={log.id}
|
||||||
<div className="flex items-start justify-between gap-3">
|
className="rounded-lg border border-slate-200 bg-white px-3 py-2"
|
||||||
<div>
|
>
|
||||||
<div className="text-sm font-semibold text-slate-900">
|
<div className="flex items-start justify-between gap-3">
|
||||||
{log.name}
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-slate-900">
|
||||||
|
{log.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
{formatBytes(log.sizeBytes)} •{" "}
|
||||||
|
{trackerLabel(log.tracker)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
{new Date(log.deletedAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500">
|
<div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-600">
|
||||||
{formatBytes(log.sizeBytes)} •{" "}
|
<span>Seed: {formatDuration(log.seedingTimeSeconds)}</span>
|
||||||
{trackerLabel(log.tracker)}
|
<span>Upload: {formatBytes(log.uploadedBytes)}</span>
|
||||||
|
{log.tags?.length ? (
|
||||||
|
<span>Tags: {log.tags.join(", ")}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-400">
|
))}
|
||||||
{new Date(log.deletedAt).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-600">
|
|
||||||
<span>Seed: {formatDuration(log.seedingTimeSeconds)}</span>
|
|
||||||
<span>Upload: {formatBytes(log.uploadedBytes)}</span>
|
|
||||||
{log.tags?.length ? (
|
|
||||||
<span>Tags: {log.tags.join(", ")}</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -439,6 +551,20 @@ export const TimerPage = () => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="flex items-start gap-2 text-sm text-slate-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={deleteFiles}
|
||||||
|
onChange={(event) => setDeleteFiles(event.target.checked)}
|
||||||
|
className="mt-1 h-4 w-4 rounded border-slate-300 text-slate-900"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Dosyayı Disk'ten Kaldır
|
||||||
|
<span className="block text-xs text-slate-500">
|
||||||
|
İşaretli değilse torrent sadece qBittorrent'tan kaldırılır, dosya disk üzerinde kalır.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<Button onClick={handleSaveRule} disabled={busy}>
|
<Button onClick={handleSaveRule} disabled={busy}>
|
||||||
{busy ? "Kaydediliyor..." : "Kuralı Kaydet"}
|
{busy ? "Kaydediliyor..." : "Kuralı Kaydet"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -464,6 +590,10 @@ export const TimerPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500">
|
<div className="text-xs text-slate-500">
|
||||||
Seed limiti: {formatDuration(rule.seedLimitSeconds)}
|
Seed limiti: {formatDuration(rule.seedLimitSeconds)}
|
||||||
|
{" • "}
|
||||||
|
{rule.deleteFiles ?? true
|
||||||
|
? "Silme: Disk + qBittorrent"
|
||||||
|
: "Silme: Sadece qBittorrent"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export interface TimerRule {
|
|||||||
id: string;
|
id: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
seedLimitSeconds: number;
|
seedLimitSeconds: number;
|
||||||
|
deleteFiles?: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,22 @@ module.exports = {
|
|||||||
fog: "#e2e8f0",
|
fog: "#e2e8f0",
|
||||||
mint: "#14b8a6",
|
mint: "#14b8a6",
|
||||||
steel: "#94a3b8"
|
steel: "#94a3b8"
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: "0" },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" }
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: "0" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: []
|
plugins: [require("tailwindcss-animate")]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
version: "3.9"
|
version: "3.9"
|
||||||
services:
|
services:
|
||||||
server:
|
q-buffer:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${SERVER_PORT:-3001}:3001"
|
- "${SERVER_PORT:-3001}:3001"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -16,6 +17,9 @@ services:
|
|||||||
- APP_USERNAME=${APP_USERNAME}
|
- APP_USERNAME=${APP_USERNAME}
|
||||||
- APP_PASSWORD=${APP_PASSWORD}
|
- APP_PASSWORD=${APP_PASSWORD}
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- WEB_PORT=${WEB_PORT}
|
||||||
|
- WEB_ORIGIN=${WEB_ORIGIN}
|
||||||
|
- WEB_ALLOWED_ORIGINS=${WEB_ALLOWED_ORIGINS}
|
||||||
- POLL_INTERVAL_MS=${POLL_INTERVAL_MS}
|
- POLL_INTERVAL_MS=${POLL_INTERVAL_MS}
|
||||||
- ENFORCE_INTERVAL_MS=${ENFORCE_INTERVAL_MS}
|
- ENFORCE_INTERVAL_MS=${ENFORCE_INTERVAL_MS}
|
||||||
- DEFAULT_DELAY_MS=${DEFAULT_DELAY_MS}
|
- DEFAULT_DELAY_MS=${DEFAULT_DELAY_MS}
|
||||||
|
|||||||
985
pnpm-lock.yaml
generated
985
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
5610
pnpm-lock_.yaml
Normal file
5610
pnpm-lock_.yaml
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user