feat(loop): aynı torrent için birden fazla iş desteği ekle

Aynı torrent hash'i için oluşturulan yeni loop işleri, mevcut aktif işleri
otomatik olarak durdurur. Stop-by-hash endpoint'i tüm ilgili işleri
durduracak şekilde güncellendi. TorrentTable bileşeni çoklu işleri
doğru şekilde işleyecek ve profil adını en güncel aktif işten
alacak şekilde yeniden yazıldı. LoopJob arayüzüne createdAt ve updatedAt
alanları eklendi.
This commit is contained in:
2026-01-05 21:27:05 +03:00
parent a1ae6566bd
commit 584d6cc319
6 changed files with 68 additions and 15 deletions

View File

@@ -66,6 +66,16 @@ export const createLoopJob = async (
updatedAt: now,
};
const db = await readDb();
const active = db.loopJobs.filter((j) => j.torrentHash === job.torrentHash && j.status !== "COMPLETED" && j.status !== "STOPPED");
for (const existing of active) {
existing.status = "STOPPED";
existing.nextRunAt = undefined;
existing.currentRun = undefined;
existing.updatedAt = nowIso();
}
if (active.length) {
await writeDb(db);
}
db.loopJobs.push(job);
await writeDb(db);
await logJob(job.id, "INFO", `Loop job started for ${job.name}`, "JOB_STARTED");

View File

@@ -91,18 +91,24 @@ router.post("/stop-by-hash", async (req, res) => {
return res.status(400).json({ error: "Missing hash" });
}
const db = await readDb();
const job = db.loopJobs.find((j) => j.torrentHash === hash);
if (!job) {
const jobs = db.loopJobs.filter((j) => j.torrentHash === hash);
if (!jobs.length) {
return res.status(404).json({ error: "Job not found" });
}
const stopped = await stopLoopJob(job.id);
const stopped = [] as any[];
for (const job of jobs) {
const result = await stopLoopJob(job.id);
if (result) {
stopped.push(result);
}
}
try {
const qbit = getQbitClient();
await qbit.deleteTorrent(hash, true);
} catch (error) {
// Best-effort delete
}
res.json(stopped);
res.json({ ok: true, stopped });
});
router.post("/dry-run", async (req, res) => {

View File

@@ -10,6 +10,8 @@ export const loopStartSchema = z.object({
allowIp: allowIpSchema,
targetLoops: z.number().int().min(1).max(1000),
delayMs: z.number().int().min(0).max(86_400_000),
profileName: z.string().trim().min(1).max(64).optional(),
profileId: z.string().trim().min(1).optional(),
});
export const dryRunSchema = z.object({

View File

@@ -112,6 +112,26 @@ export const LoopSetupCard = () => {
}
};
const stopLoop = async () => {
if (!selectedHash) {
return;
}
try {
await api.post("/api/loop/stop-by-hash", { hash: selectedHash });
pushAlert({
title: "Loop durduruldu",
description: "Seçili torrent için loop durduruldu.",
variant: "success",
});
} catch (error) {
pushAlert({
title: "Durdurma başarısız",
description: "Loop durdurulamadı.",
variant: "error",
});
}
};
const applyProfile = async (profile: Profile) => {
if (!selectedHash) {
pushAlert({

View File

@@ -75,23 +75,36 @@ export const TorrentTable = () => {
};
const getProfileName = (hash: string) => {
const job = jobs.find((j) => j.torrentHash === hash);
if (!job) return null;
if (job.profileId) {
const profileById = profiles.find((p) => p.id === job.profileId);
const hashKey = hash.toLowerCase();
const related = jobs.filter((j) => j.torrentHash?.toLowerCase() === hashKey);
if (related.length === 0) return null;
const active = related.find((j) => j.status === "RUNNING" || j.status === "WAITING_DELAY");
const latest = related.reduce((current, next) => {
const currentAt = Date.parse(current.updatedAt ?? current.createdAt ?? "");
const nextAt = Date.parse(next.updatedAt ?? next.createdAt ?? "");
if (Number.isFinite(nextAt) && Number.isFinite(currentAt)) {
return nextAt >= currentAt ? next : current;
}
if (Number.isFinite(nextAt)) return next;
return current;
}, related[0]);
const candidate = active ?? latest;
if (candidate.profileId) {
const profileById = profiles.find((p) => p.id === candidate.profileId);
if (profileById?.name) {
return profileById.name;
}
}
if (job.profileName) {
return job.profileName;
if (candidate.profileName) {
return candidate.profileName;
}
const profile = profiles.find((p) =>
p.allowIp === job.allowIp &&
p.delayMs === job.delayMs &&
p.targetLoops === job.targetLoops
const fallback = profiles.find(
(profile) =>
profile.allowIp === candidate.allowIp &&
profile.delayMs === candidate.delayMs &&
profile.targetLoops === candidate.targetLoops
);
return profile?.name ?? null;
return fallback?.name ?? null;
};
const filtered = useMemo(() => {

View File

@@ -32,6 +32,8 @@ export interface LoopJob {
nextRunAt?: string;
lastError?: string;
totals?: { totalDownloadedBytes: number };
createdAt?: string;
updatedAt?: string;
}
export interface StatusSnapshot {