Compare commits

...

10 Commits

Author SHA1 Message Date
6507d13325 fix(server): tür derlemesi ve derleme yapılandırmasını düzelt
TypeScript derleme hatalarını çöz, Docker yapılandırmasını güncelle ve tip güvenliğini iyileştir

- tsconfig.json'a noImplicitAny: false ekle
- auth.middleware.ts'de Express tip tanımlamalarını kaldır
- torrent.generator.ts ve enforcement.worker.ts'de tip açıklamaları ekle
- loop.routes.ts'de torrentFilePath için null kontrolü ekle
- qbit.types.ts'ye added_on alanı ekle
- Dockerfile'da --prod=false bayrağını ekle ve node_modules kopyalamasını düzelt
- docker-compose.yml'de hizmet adını q-buffer olarak güncelle ve çevre değişkenlerini ekle
- .env.example'a WEB_ORIGIN değişkenini ekle
2026-02-01 21:22:02 +03:00
967eb2d2a4 fix(server): hata yönetimini ve dayanıklılığı iyileştir
Loop scheduler ve timer worker için hata yakalama ekle. qBit client'ta
geçici ağ hatalarını tanıyarak login durumunu sıfırla. Scheduler
hatalarında durum güncellemesi gönder ve timer worker crash önle.
2026-01-31 11:25:51 +03:00
9b495b7bf7 fix(server): hata yönetimini iyileştir
Zamanlayıcı ve qbit istemcisi bileşenlerinde hata işleme yeteneklerini
güçlendirir.

- loop.scheduler: qbit hatalarında sistem durumunu ve sağlık bilgisini
  güncelleme ekler.
- qbit.client: geçici ağ hatalarını (EAI_AGAIN vb.) algılayarak oturum
  durumunu sıfırlar.
- timer.worker: global hata yakalama ekleyerek işleyicinin çökmesini
  engeller ve hataları günlüğe kaydeder.
2026-01-31 11:04:31 +03:00
075780435c fix(timer): süre hesaplamasını iyileştir
Torrentların eklenme zamanını kullanarak geçen süreyi daha doğru hesapla
ve kural oluşturulma tarihi kontrolünü kaldır.
2026-01-17 17:19:17 +03:00
ef1f8438e6 style(ui): space grotesk fontunu kaldır ve sistem fontuna geç 2026-01-10 13:15:40 +03:00
79068c0faa refactor(ui): seçim bileşeninin görsel etkileşimini modernize et. 2026-01-09 18:26:46 +03:00
8089a816ad feat(ui): select bileşenini geliştir
Scroll butonları, metin kısaltma ve genişlik ayarlamaları ekleyerek
Select bileşenini daha kullanıcı dostu hale getirildi. TimerPage
düzenlemesi yeni özelliklere uyum sağlamak için güncellendi.
2026-01-09 16:52:09 +03:00
f6d54ca623 feat(timer): sıralama özelliği ekle 2026-01-09 16:22:01 +03:00
dcd66fdd11 feat(timer): diskten dosya silme seçeneği ekle
Timer kurallarına torrent silinirken dosyaların diskten de
silinip silinmeyeceğini belirleyen `deleteFiles` alanı eklendi.
Web arayüzüne ilgili ayar checkbox'ı eklendi. Varsayılan değer
dosyaları silmek (`true`) olarak ayarlandı. Torrent listesinde
eklenme tarihi görünümü eklendi.
2026-01-09 12:47:34 +03:00
ce1693cf4e docs: WEB_ALLOWED_HOSTS örneği güncelle 2026-01-09 12:46:24 +03:00
28 changed files with 6841 additions and 439 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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ı

View File

@@ -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 };
}
}

View File

@@ -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[] = [];

View File

@@ -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;

View File

@@ -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,

View File

@@ -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);
}; };

View File

@@ -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)

View File

@@ -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;
} }

View File

@@ -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];

View File

@@ -1,4 +1,5 @@
export interface TimerRuleInput { export interface TimerRuleInput {
tags: string[]; tags: string[];
seedLimitSeconds: number; seedLimitSeconds: number;
deleteFiles?: boolean;
} }

View File

@@ -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);
}; };

View File

@@ -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();

View File

@@ -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
View 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 };
}
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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"
} }

View 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";

View 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";

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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;
} }

View File

@@ -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")]
}; };

View File

@@ -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

File diff suppressed because it is too large Load Diff

5610
pnpm-lock_.yaml Normal file

File diff suppressed because it is too large Load Diff