import React, { useEffect, useMemo, useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/Card"; import { Button } from "../components/ui/Button"; import { Input } from "../components/ui/Input"; import { ScrollArea } from "../components/ui/ScrollArea"; import { api } from "../api/client"; import { useAppStore } from "../store/useAppStore"; import { useUiStore } from "../store/useUiStore"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "../components/ui/AlertDialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../components/ui/Select"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faClockRotateLeft, faClock, faTags, faTrash, faChartBar, faPlus, faHourglassHalf, } from "@fortawesome/free-solid-svg-icons"; const unitOptions = [ { label: "Saat", value: "hours", seconds: 3600 }, { label: "Gün", value: "days", seconds: 86400 }, { label: "Hafta", value: "weeks", seconds: 604800 }, ] 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) => { if (!Number.isFinite(value)) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; let size = value; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex += 1; } return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unitIndex]}`; }; const formatDuration = (seconds: number) => { if (!Number.isFinite(seconds)) return "0 sn"; if (seconds >= 86400) { return `${(seconds / 86400).toFixed(1)} gün`; } if (seconds >= 3600) { return `${(seconds / 3600).toFixed(1)} saat`; } return `${Math.max(1, Math.round(seconds / 60))} dk`; }; const formatCountdown = (seconds: number) => { if (!Number.isFinite(seconds)) return "—"; const clamped = Math.max(0, Math.floor(seconds)); const days = Math.floor(clamped / 86400); const hours = Math.floor((clamped % 86400) / 3600); const minutes = Math.floor((clamped % 3600) / 60); const secs = clamped % 60; const pad = (value: number) => value.toString().padStart(2, "0"); 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) => { if (!tracker) return "Bilinmiyor"; try { const host = new URL(tracker).hostname; return host.replace(/^www\./, ""); } catch { return tracker; } }; export const TimerPage = () => { const torrents = useAppStore((s) => s.torrents); const timerRules = useAppStore((s) => s.timerRules); const timerLogs = useAppStore((s) => s.timerLogs); const timerSummary = useAppStore((s) => s.timerSummary); const setTimerRules = useAppStore((s) => s.setTimerRules); const setTimerLogs = useAppStore((s) => s.setTimerLogs); const setTimerSummary = useAppStore((s) => s.setTimerSummary); const [selectedTags, setSelectedTags] = useState([]); const [seedValue, setSeedValue] = useState(2); const [seedUnit, setSeedUnit] = useState<(typeof unitOptions)[number]["value"]>( "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 pushAlert = useUiStore((s) => s.pushAlert); const [nowTick, setNowTick] = useState(() => Date.now()); const tagOptions = useMemo(() => { const tags = new Set(); torrents.forEach((torrent) => { const tagList = torrent.tags ? torrent.tags.split(",").map((tag) => tag.trim()) : []; tagList.filter(Boolean).forEach((tag) => tags.add(tag)); if (torrent.category) { tags.add(torrent.category); } }); return Array.from(tags.values()).sort((a, b) => a.localeCompare(b)); }, [torrents]); const rulesForDisplay = useMemo(() => { return [...timerRules].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); }, [timerRules]); const matchingTorrents = useMemo(() => { if (timerRules.length === 0) { return []; } return torrents .map((torrent) => { const tags = (torrent.tags ?? "") .split(",") .map((tag) => tag.trim().toLowerCase()) .filter(Boolean); if (torrent.category) { tags.push(torrent.category.toLowerCase()); } const addedOnMs = Number(torrent.added_on ?? 0) * 1000; const matchingRules = timerRules.filter((rule) => { return rule.tags.some((tag) => tags.includes(tag.toLowerCase())); }); if (matchingRules.length === 0) { return null; } const rule = matchingRules.reduce((best, current) => current.seedLimitSeconds < best.seedLimitSeconds ? current : best ); const baseMs = addedOnMs || nowTick; const elapsedSeconds = Math.max(0, (nowTick - baseMs) / 1000); const remainingSeconds = rule.seedLimitSeconds - elapsedSeconds; return { torrent, rule, remainingSeconds, }; }) .filter(Boolean) as Array<{ torrent: typeof torrents[number]; rule: typeof timerRules[number]; remainingSeconds: number; }>; }, [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(() => { let active = true; const load = async () => { try { const [rulesRes, logsRes, summaryRes] = await Promise.all([ api.get("/api/timer/rules"), api.get("/api/timer/logs"), api.get("/api/timer/summary"), ]); if (!active) return; setTimerRules(rulesRes.data ?? []); setTimerLogs((logsRes.data ?? []).reverse()); if (summaryRes.data) { setTimerSummary(summaryRes.data); } } catch (err) { if (active) { pushAlert({ title: "Timer verileri alınamadı", description: "Bağlantıyı kontrol edip tekrar deneyin.", variant: "error", }); } } }; load(); return () => { active = false; }; }, [setTimerLogs, setTimerRules, setTimerSummary]); useEffect(() => { const interval = setInterval(() => setNowTick(Date.now()), 1000); return () => clearInterval(interval); }, []); const toggleTag = (tag: string) => { setSelectedTags((current) => current.includes(tag) ? current.filter((value) => value !== tag) : [...current, tag] ); }; const handleSaveRule = async () => { const unit = unitOptions.find((item) => item.value === seedUnit); if (!unit) return; if (selectedTags.length === 0) { pushAlert({ title: "Etiket seçilmedi", description: "En az bir etiket seçmelisiniz.", variant: "warn", }); return; } if (!Number.isFinite(seedValue) || seedValue <= 0) { pushAlert({ title: "Seed süresi geçersiz", description: "Seed süresi 0’dan büyük olmalı.", variant: "warn", }); return; } const seedLimitSeconds = Math.round(seedValue * unit.seconds); setBusy(true); try { const response = await api.post("/api/timer/rules", { tags: selectedTags, seedLimitSeconds, deleteFiles, }); setTimerRules([response.data, ...timerRules]); setSelectedTags([]); setDeleteFiles(true); pushAlert({ title: "Kural kaydedildi", description: "Timer kuralı aktif edildi.", variant: "success", }); } catch (err) { pushAlert({ title: "Kural kaydedilemedi", description: "Lütfen daha sonra tekrar deneyin.", variant: "error", }); } finally { setBusy(false); } }; const handleDeleteRule = async (ruleId: string) => { try { await api.delete(`/api/timer/rules/${ruleId}`); setTimerRules(timerRules.filter((rule) => rule.id !== ruleId)); pushAlert({ title: "Kural silindi", description: "Timer kuralı kaldırıldı.", variant: "success", }); } catch (err) { pushAlert({ title: "Kural silinemedi", description: "Lütfen daha sonra tekrar deneyin.", variant: "error", }); } }; const summary = timerSummary ?? { totalDeleted: 0, totalSeededSeconds: 0, totalUploadedBytes: 0, updatedAt: "", }; return (
Zamanlayıcı Torrentleri
Sıralama
{sortedMatchingTorrents.length === 0 ? (
Bu kurallara bağlı aktif torrent bulunamadı.
) : (
{sortedMatchingTorrents.map(({ torrent, rule, remainingSeconds }) => (
{torrent.name}
{formatBytes(torrent.size)} • {trackerLabel(torrent.tracker)}
{formatCountdown(remainingSeconds)}
Kural: {formatDuration(rule.seedLimitSeconds)}
Hash: {torrent.hash.slice(0, 12)}...
Etiket:{" "} {(torrent.tags || torrent.category || "-") .split(",") .map((tag) => tag.trim()) .filter(Boolean) .join(", ") || "-"}
Added: {formatAddedOn(torrent.added_on)}
))}
)}
Silinen Torrent Logları {timerLogs.length === 0 ? (
Henüz log yok.
) : (
{timerLogs.map((log) => (
{log.name}
{formatBytes(log.sizeBytes)} •{" "} {trackerLabel(log.tracker)}
{new Date(log.deletedAt).toLocaleString()}
Seed: {formatDuration(log.seedingTimeSeconds)} Upload: {formatBytes(log.uploadedBytes)} {log.tags?.length ? ( Tags: {log.tags.join(", ")} ) : null}
))}
)}
Timer Kuralı Oluştur
Etiketler
{tagOptions.length === 0 ? (
Henüz etiket bulunamadı.
) : ( tagOptions.map((tag) => ( )) )}
Seed Süresi
setSeedValue(Number(event.target.value))} />
Eklenen Kurallar
{rulesForDisplay.length === 0 ? (
Henüz kural yok.
) : (
{rulesForDisplay.map((rule) => (
{rule.tags.join(", ")}
Seed limiti: {formatDuration(rule.seedLimitSeconds)} {" • "} {rule.deleteFiles ?? true ? "Silme: Disk + qBittorrent" : "Silme: Sadece qBittorrent"}
Kural silinsin mi? Bu kural kaldırılınca zamanlayıcı artık bu etiketleri izlemeyecek. İptal handleDeleteRule(rule.id)}> Sil
))}
)}
Timer Özeti
Silinen dosya sayısı {summary.totalDeleted}
Toplam seed süresi {formatDuration(summary.totalSeededSeconds)}
Toplam upload {formatBytes(summary.totalUploadedBytes)}
); };