Compare commits
2 Commits
2c60c669c0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d0b5f4c10f | |||
| 0dd8f60626 |
@@ -16,7 +16,7 @@ export interface RealTaCandidate {
|
|||||||
releaseHints: string[];
|
releaseHints: string[];
|
||||||
isHI: boolean;
|
isHI: boolean;
|
||||||
isForced: boolean;
|
isForced: boolean;
|
||||||
strategy?: 'exact' | 'token' | 'fallback' | 'default' | 'package_fallback';
|
strategy?: 'exact' | 'token' | 'fps' | 'fallback' | 'default' | 'package_fallback';
|
||||||
isPackage?: boolean;
|
isPackage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +113,34 @@ function normalizeReleaseHints(raw: string): string[] {
|
|||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeFpsText(raw?: string | number | null): string | null {
|
||||||
|
if (raw === undefined || raw === null) return null;
|
||||||
|
const s = String(raw).trim().replace(/,/g, '.').replace(/\s+/g, '');
|
||||||
|
if (!s) return null;
|
||||||
|
|
||||||
|
if (s.includes('/')) {
|
||||||
|
const [a, b] = s.split('/');
|
||||||
|
const n = Number(a);
|
||||||
|
const d = Number(b);
|
||||||
|
if (!Number.isFinite(n) || !Number.isFinite(d) || d === 0) return null;
|
||||||
|
const v = Number((n / d).toFixed(3));
|
||||||
|
return Number.isInteger(v) ? String(v) : String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const v = Number(s);
|
||||||
|
if (!Number.isFinite(v)) return null;
|
||||||
|
const rounded = Number(v.toFixed(3));
|
||||||
|
return Number.isInteger(rounded) ? String(rounded) : String(rounded);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fpsEquals(a?: string | null, b?: string | null): boolean {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
const an = Number(a);
|
||||||
|
const bn = Number(b);
|
||||||
|
if (!Number.isFinite(an) || !Number.isFinite(bn)) return false;
|
||||||
|
return Math.abs(an - bn) <= 0.01;
|
||||||
|
}
|
||||||
|
|
||||||
function abs(base: string, maybeRelative: string): string {
|
function abs(base: string, maybeRelative: string): string {
|
||||||
return new URL(maybeRelative, base).toString();
|
return new URL(maybeRelative, base).toString();
|
||||||
}
|
}
|
||||||
@@ -268,7 +296,8 @@ function pickSubPageFromMovieDetail(
|
|||||||
title: string;
|
title: string;
|
||||||
releaseHints: string[];
|
releaseHints: string[];
|
||||||
isHI: boolean;
|
isHI: boolean;
|
||||||
strategy: 'exact' | 'token' | 'fallback' | 'default' | 'package_fallback';
|
strategy: 'exact' | 'token' | 'fps' | 'fallback' | 'default' | 'package_fallback';
|
||||||
|
fps?: string | null;
|
||||||
isPackage?: boolean;
|
isPackage?: boolean;
|
||||||
};
|
};
|
||||||
noMatchReason?: 'episode_not_matched' | 'release_not_matched' | 'no_sub_rows';
|
noMatchReason?: 'episode_not_matched' | 'release_not_matched' | 'no_sub_rows';
|
||||||
@@ -276,6 +305,11 @@ function pickSubPageFromMovieDetail(
|
|||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
const wantedRelease = normalizeText(params.release || '');
|
const wantedRelease = normalizeText(params.release || '');
|
||||||
const wantedReleaseTokens = wantedRelease.split(/\s+/).filter(Boolean);
|
const wantedReleaseTokens = wantedRelease.split(/\s+/).filter(Boolean);
|
||||||
|
const wantedFps = normalizeFpsText(
|
||||||
|
params.mediaInfo?.video?.fps ??
|
||||||
|
params.mediaInfo?.video?.avg_frame_rate ??
|
||||||
|
params.mediaInfo?.video?.r_frame_rate
|
||||||
|
);
|
||||||
const wantedSeason = params.type === 'tv' ? params.season : undefined;
|
const wantedSeason = params.type === 'tv' ? params.season : undefined;
|
||||||
const wantedEpisode = params.type === 'tv' ? params.episode : undefined;
|
const wantedEpisode = params.type === 'tv' ? params.episode : undefined;
|
||||||
const rows = $('[class*="altsonsez"]');
|
const rows = $('[class*="altsonsez"]');
|
||||||
@@ -292,6 +326,7 @@ function pickSubPageFromMovieDetail(
|
|||||||
season?: number;
|
season?: number;
|
||||||
episode?: number;
|
episode?: number;
|
||||||
isPackage: boolean;
|
isPackage: boolean;
|
||||||
|
fps?: string | null;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
rows.each((_, row) => {
|
rows.each((_, row) => {
|
||||||
@@ -307,6 +342,7 @@ function pickSubPageFromMovieDetail(
|
|||||||
const isTr = $(row).find('.flagtr').length > 0;
|
const isTr = $(row).find('.flagtr').length > 0;
|
||||||
const indirmeRaw = ($(row).find('.alindirme').text() || '').replace(/\./g, '').replace(/,/g, '').trim();
|
const indirmeRaw = ($(row).find('.alindirme').text() || '').replace(/\./g, '').replace(/,/g, '').trim();
|
||||||
const downloadCount = Number(indirmeRaw.replace(/[^\d]/g, '')) || 0;
|
const downloadCount = Number(indirmeRaw.replace(/[^\d]/g, '')) || 0;
|
||||||
|
const fps = normalizeFpsText(($(row).find('.alfps').text() || '').trim());
|
||||||
const { season, episode, isPackage } = parseSeasonEpisodeFromRow($, row);
|
const { season, episode, isPackage } = parseSeasonEpisodeFromRow($, row);
|
||||||
|
|
||||||
if (params.type === 'tv') {
|
if (params.type === 'tv') {
|
||||||
@@ -341,7 +377,8 @@ function pickSubPageFromMovieDetail(
|
|||||||
downloadCount,
|
downloadCount,
|
||||||
season,
|
season,
|
||||||
episode,
|
episode,
|
||||||
isPackage
|
isPackage,
|
||||||
|
fps
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -366,14 +403,7 @@ function pickSubPageFromMovieDetail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!wantedRelease || forcedStrategy === 'package_fallback') {
|
if (wantedRelease && forcedStrategy !== 'package_fallback') {
|
||||||
const picked = selectedPool.sort((a, b) => b.score - a.score || b.downloadCount - a.downloadCount)[0];
|
|
||||||
if (forcedStrategy === 'package_fallback') {
|
|
||||||
return { picked: { ...picked, strategy: 'package_fallback', isPackage: true } };
|
|
||||||
}
|
|
||||||
return { picked: { ...picked, strategy: 'default' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const exact = selectedPool
|
const exact = selectedPool
|
||||||
.filter((c) => c.releaseExact)
|
.filter((c) => c.releaseExact)
|
||||||
.sort((a, b) => b.score - a.score || b.downloadCount - a.downloadCount)[0];
|
.sort((a, b) => b.score - a.score || b.downloadCount - a.downloadCount)[0];
|
||||||
@@ -387,6 +417,24 @@ function pickSubPageFromMovieDetail(
|
|||||||
if (token) {
|
if (token) {
|
||||||
return { picked: { ...token, strategy: 'token' } };
|
return { picked: { ...token, strategy: 'token' } };
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fpsMatched = wantedFps
|
||||||
|
? selectedPool.find((c) => fpsEquals(c.fps || null, wantedFps))
|
||||||
|
: undefined;
|
||||||
|
if (fpsMatched) {
|
||||||
|
return { picked: { ...fpsMatched, strategy: 'fps' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forcedStrategy === 'package_fallback') {
|
||||||
|
const picked = selectedPool.sort((a, b) => b.score - a.score || b.downloadCount - a.downloadCount)[0];
|
||||||
|
return { picked: { ...picked, strategy: 'package_fallback', isPackage: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wantedRelease) {
|
||||||
|
const picked = selectedPool.sort((a, b) => b.score - a.score || b.downloadCount - a.downloadCount)[0];
|
||||||
|
return { picked: { ...picked, strategy: 'default' } };
|
||||||
|
}
|
||||||
|
|
||||||
if (params.type === 'tv') {
|
if (params.type === 'tv') {
|
||||||
// TV'de once bolum dogrulugu, sonra release gelir. Release bulunamasa da en iyi bolum satirini kullan.
|
// TV'de once bolum dogrulugu, sonra release gelir. Release bulunamasa da en iyi bolum satirini kullan.
|
||||||
@@ -502,6 +550,12 @@ export async function searchTurkceAltyaziReal(params: SearchParams): Promise<Rea
|
|||||||
subUrl: pickedSub.subUrl,
|
subUrl: pickedSub.subUrl,
|
||||||
releaseHints: pickedSub.releaseHints,
|
releaseHints: pickedSub.releaseHints,
|
||||||
strategy: pickedSub.strategy,
|
strategy: pickedSub.strategy,
|
||||||
|
fps: pickedSub.fps,
|
||||||
|
wantedFps: normalizeFpsText(
|
||||||
|
params.mediaInfo?.video?.fps ??
|
||||||
|
params.mediaInfo?.video?.avg_frame_rate ??
|
||||||
|
params.mediaInfo?.video?.r_frame_rate
|
||||||
|
),
|
||||||
isPackage: pickedSub.isPackage === true
|
isPackage: pickedSub.isPackage === true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,25 @@ import { promisify } from 'node:util';
|
|||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
function parseFraction(raw?: string): number | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
const parts = raw.split('/');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const n = Number(parts[0]);
|
||||||
|
const d = Number(parts[1]);
|
||||||
|
if (!Number.isFinite(n) || !Number.isFinite(d) || d === 0) return null;
|
||||||
|
return n / d;
|
||||||
|
}
|
||||||
|
const v = Number(raw);
|
||||||
|
return Number.isFinite(v) ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFpsValue(raw?: string): number | null {
|
||||||
|
const v = parseFraction(raw);
|
||||||
|
if (v === null) return null;
|
||||||
|
return Number(v.toFixed(3));
|
||||||
|
}
|
||||||
|
|
||||||
export async function analyzeWithFfprobe(path: string): Promise<any> {
|
export async function analyzeWithFfprobe(path: string): Promise<any> {
|
||||||
const { stdout } = await execFileAsync('ffprobe', [
|
const { stdout } = await execFileAsync('ffprobe', [
|
||||||
'-v',
|
'-v',
|
||||||
@@ -25,7 +44,8 @@ export async function analyzeWithFfprobe(path: string): Promise<any> {
|
|||||||
codec_name: video.codec_name,
|
codec_name: video.codec_name,
|
||||||
width: video.width,
|
width: video.width,
|
||||||
height: video.height,
|
height: video.height,
|
||||||
r_frame_rate: video.r_frame_rate
|
r_frame_rate: video.r_frame_rate,
|
||||||
|
fps: normalizeFpsValue(video.avg_frame_rate || video.r_frame_rate)
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
audio,
|
audio,
|
||||||
@@ -39,7 +59,7 @@ export async function analyzeWithFfprobe(path: string): Promise<any> {
|
|||||||
|
|
||||||
export function fallbackMediaInfo(): any {
|
export function fallbackMediaInfo(): any {
|
||||||
return {
|
return {
|
||||||
video: { codec_name: 'unknown', width: 1920, height: 1080, r_frame_rate: '24/1' },
|
video: { codec_name: 'unknown', width: 1920, height: 1080, r_frame_rate: '24/1', fps: 24 },
|
||||||
audio: [{ codec_name: 'unknown', channels: 2, language: 'und' }],
|
audio: [{ codec_name: 'unknown', channels: 2, language: 'und' }],
|
||||||
format: { duration: '0', bit_rate: '0', format_name: 'matroska' }
|
format: { duration: '0', bit_rate: '0', format_name: 'matroska' }
|
||||||
};
|
};
|
||||||
|
|||||||
1283
services/ui/package-lock.json
generated
1283
services/ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,14 @@
|
|||||||
"start": "serve -s dist -l 3000"
|
"start": "serve -s dist -l 3000"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@mantine/core": "^8.3.15",
|
||||||
|
"@mantine/hooks": "^8.3.15",
|
||||||
|
"@mantine/modals": "^8.3.15",
|
||||||
|
"@mantine/notifications": "^8.3.15",
|
||||||
|
"@tabler/icons-react": "^3.37.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.13.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { Route, Routes, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { Layout, Tab } from './components/Layout';
|
import { Alert, Button, Stack } from '@mantine/core';
|
||||||
|
import { IconArrowLeft, IconAlertTriangle } from '@tabler/icons-react';
|
||||||
|
import { Layout } from './components/Layout';
|
||||||
import { DashboardPage } from './pages/DashboardPage';
|
import { DashboardPage } from './pages/DashboardPage';
|
||||||
import { JobsPage } from './pages/JobsPage';
|
import { JobsPage } from './pages/JobsPage';
|
||||||
import { JobDetailPage } from './pages/JobDetailPage';
|
import { JobDetailPage } from './pages/JobDetailPage';
|
||||||
@@ -8,27 +10,44 @@ import { SettingsPage } from './pages/SettingsPage';
|
|||||||
import { WatchedPathsPage } from './pages/WatchedPathsPage';
|
import { WatchedPathsPage } from './pages/WatchedPathsPage';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [tab, setTab] = useState<Tab>('dashboard');
|
const navigate = useNavigate();
|
||||||
const [selectedJob, setSelectedJob] = useState<string | null>(null);
|
|
||||||
|
const handleSelectJob = (jobId: string) => {
|
||||||
|
navigate(`/jobs/${jobId}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout tab={tab} setTab={setTab}>
|
<Layout>
|
||||||
{selectedJob && (
|
<Routes>
|
||||||
<div style={{ marginBottom: 12 }}>
|
<Route path="/" element={<DashboardPage onSelectJob={handleSelectJob} />} />
|
||||||
<button onClick={() => setSelectedJob(null)}>Job listesine don</button>
|
<Route path="/jobs" element={<JobsPage onSelectJob={handleSelectJob} />} />
|
||||||
</div>
|
<Route path="/jobs/:id" element={<JobDetailRoute />} />
|
||||||
)}
|
<Route path="/review" element={<ReviewPage onSelectJob={handleSelectJob} />} />
|
||||||
{selectedJob ? (
|
<Route path="/paths" element={<WatchedPathsPage />} />
|
||||||
<JobDetailPage jobId={selectedJob} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
) : (
|
</Routes>
|
||||||
<>
|
|
||||||
{tab === 'dashboard' && <DashboardPage onSelectJob={setSelectedJob} />}
|
|
||||||
{tab === 'jobs' && <JobsPage onSelectJob={setSelectedJob} />}
|
|
||||||
{tab === 'review' && <ReviewPage onSelectJob={setSelectedJob} />}
|
|
||||||
{tab === 'settings' && <SettingsPage />}
|
|
||||||
{tab === 'paths' && <WatchedPathsPage />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function JobDetailRoute() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return (
|
||||||
|
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />}>
|
||||||
|
Geçersiz iş id.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Button variant="default" leftSection={<IconArrowLeft size={16} />} w="fit-content" onClick={() => navigate('/jobs')}>
|
||||||
|
Jobs listesine dön
|
||||||
|
</Button>
|
||||||
|
<JobDetailPage jobId={id} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
export async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
function getCoreBaseUrl(): string {
|
||||||
const base = import.meta.env.VITE_PUBLIC_CORE_URL || 'http://localhost:3001';
|
const base = import.meta.env.VITE_PUBLIC_CORE_URL || 'http://localhost:3001';
|
||||||
const res = await fetch(`${base}${path}`, {
|
return base.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(path: string): string {
|
||||||
|
return path.startsWith('/') ? path : `/${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildApiUrl(path: string): string {
|
||||||
|
return `${getCoreBaseUrl()}${normalizePath(path)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSseUrl(path: string): string {
|
||||||
|
return buildApiUrl(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(buildApiUrl(path), {
|
||||||
headers: { 'content-type': 'application/json', ...(options?.headers || {}) },
|
headers: { 'content-type': 'application/json', ...(options?.headers || {}) },
|
||||||
...options
|
...options
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,50 @@
|
|||||||
|
import { Badge, Paper, ScrollArea, Table, Text } from '@mantine/core';
|
||||||
import { Job } from '../types';
|
import { Job } from '../types';
|
||||||
|
import { statusColor } from '../lib/status';
|
||||||
|
|
||||||
export function JobTable({ jobs, onSelect }: { jobs: Job[]; onSelect: (id: string) => void }) {
|
export function JobTable({ jobs, onSelect }: { jobs: Job[]; onSelect: (id: string) => void }) {
|
||||||
return (
|
return (
|
||||||
<table width="100%" cellPadding={8} style={{ borderCollapse: 'collapse', background: '#fff' }}>
|
<Paper radius="lg" withBorder p={0} style={{ background: 'rgba(255,255,255,0.88)', borderColor: 'rgba(16,32,50,0.12)' }}>
|
||||||
<thead>
|
<ScrollArea>
|
||||||
<tr>
|
<Table highlightOnHover horizontalSpacing="md" verticalSpacing="sm" miw={780}>
|
||||||
<th align="left">ID</th>
|
<Table.Thead>
|
||||||
<th align="left">Durum</th>
|
<Table.Tr>
|
||||||
<th align="left">Baslik</th>
|
<Table.Th>ID</Table.Th>
|
||||||
<th align="left">Guncelleme</th>
|
<Table.Th>Durum</Table.Th>
|
||||||
</tr>
|
<Table.Th>Başlık</Table.Th>
|
||||||
</thead>
|
<Table.Th>Güncelleme</Table.Th>
|
||||||
<tbody>
|
</Table.Tr>
|
||||||
{jobs.map((j) => (
|
</Table.Thead>
|
||||||
<tr key={j._id} onClick={() => onSelect(j._id)} style={{ cursor: 'pointer', borderTop: '1px solid #e2e8f0' }}>
|
<Table.Tbody>
|
||||||
<td>{j._id.slice(-8)}</td>
|
{jobs.length === 0 ? (
|
||||||
<td>{j.status}</td>
|
<Table.Tr>
|
||||||
<td>{j.requestSnapshot?.title || '-'}</td>
|
<Table.Td colSpan={4}>
|
||||||
<td>{new Date(j.updatedAt).toLocaleString()}</td>
|
<Text c="dimmed" ta="center" py="lg">
|
||||||
</tr>
|
Kayıt bulunamadı.
|
||||||
))}
|
</Text>
|
||||||
</tbody>
|
</Table.Td>
|
||||||
</table>
|
</Table.Tr>
|
||||||
|
) : (
|
||||||
|
jobs.map((job) => (
|
||||||
|
<Table.Tr key={job._id} style={{ cursor: 'pointer' }} onClick={() => onSelect(job._id)}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text ff="monospace" size="sm" c="dimmed">
|
||||||
|
{job._id.slice(-8)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={statusColor(job.status)} variant="light">
|
||||||
|
{job.status}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{job.requestSnapshot?.title || job.requestSnapshot?.path || '-'}</Table.Td>
|
||||||
|
<Table.Td>{new Date(job.updatedAt).toLocaleString()}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,118 @@
|
|||||||
import React from 'react';
|
import { useState } from 'react';
|
||||||
|
import { AppShell, Box, Burger, Group, NavLink, Stack, Text } from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconChartDonut3,
|
||||||
|
IconListDetails,
|
||||||
|
IconMessage2Exclamation,
|
||||||
|
IconFolderSearch,
|
||||||
|
IconAdjustmentsCog,
|
||||||
|
IconRadar2
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
const tabs = ['dashboard', 'jobs', 'review', 'settings', 'paths'] as const;
|
interface MenuItem {
|
||||||
export type Tab = (typeof tabs)[number];
|
label: string;
|
||||||
|
path: string;
|
||||||
|
icon: typeof IconChartDonut3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: MenuItem[] = [
|
||||||
|
{ label: 'Dashboard', path: '/', icon: IconChartDonut3 },
|
||||||
|
{ label: 'Jobs', path: '/jobs', icon: IconListDetails },
|
||||||
|
{ label: 'Review', path: '/review', icon: IconMessage2Exclamation },
|
||||||
|
{ label: 'Watched Paths', path: '/paths', icon: IconFolderSearch },
|
||||||
|
{ label: 'Settings', path: '/settings', icon: IconAdjustmentsCog }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
const [opened, setOpened] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
export function Layout({ tab, setTab, children }: { tab: Tab; setTab: (t: Tab) => void; children: React.ReactNode }) {
|
|
||||||
return (
|
return (
|
||||||
<div style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', background: 'linear-gradient(120deg,#f6f8fb,#eef3ff)', minHeight: '100vh', color: '#0f172a' }}>
|
<AppShell
|
||||||
<header style={{ padding: 16, borderBottom: '1px solid #dbe2f0', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
header={{ height: 70 }}
|
||||||
<strong>subwatcher</strong>
|
navbar={{ width: 270, breakpoint: 'sm', collapsed: { mobile: !opened } }}
|
||||||
{tabs.map((t) => (
|
padding="lg"
|
||||||
<button key={t} onClick={() => setTab(t)} style={{ background: tab === t ? '#1e293b' : '#fff', color: tab === t ? '#fff' : '#111', border: '1px solid #94a3b8', borderRadius: 8, padding: '6px 10px' }}>
|
styles={{
|
||||||
{t}
|
main: {
|
||||||
</button>
|
background: 'transparent'
|
||||||
))}
|
},
|
||||||
</header>
|
navbar: {
|
||||||
<main style={{ padding: 16 }}>{children}</main>
|
backdropFilter: 'blur(10px)',
|
||||||
|
background: 'linear-gradient(180deg, rgba(255,255,255,0.9), rgba(247,251,255,0.94))',
|
||||||
|
borderRight: '1px solid rgba(16,32,50,0.08)'
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
background: 'linear-gradient(90deg, rgba(255,255,255,0.9), rgba(244,249,255,0.88))',
|
||||||
|
borderBottom: '1px solid rgba(16,32,50,0.08)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppShell.Header>
|
||||||
|
<Group h="100%" px="md" justify="space-between">
|
||||||
|
<Group gap="sm">
|
||||||
|
<Burger opened={opened} onClick={() => setOpened((s) => !s)} hiddenFrom="sm" size="sm" />
|
||||||
|
<Group gap={10}>
|
||||||
|
<IconRadar2 size={24} color="#0ea5a3" />
|
||||||
|
<div>
|
||||||
|
<Text className="sw-page-title" fw={700} size="lg">
|
||||||
|
SubWatcher Control
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
subtitle automation cockpit
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</AppShell.Header>
|
||||||
|
|
||||||
|
<AppShell.Navbar p="md">
|
||||||
|
<Stack gap="xs">
|
||||||
|
{items.map((item) => {
|
||||||
|
const active = item.path === '/' ? location.pathname === '/' : location.pathname.startsWith(item.path);
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
active={active}
|
||||||
|
label={item.label}
|
||||||
|
leftSection={<item.icon size={18} />}
|
||||||
|
onClick={() => {
|
||||||
|
navigate(item.path);
|
||||||
|
setOpened(false);
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
borderRadius: 10,
|
||||||
|
color: '#233548'
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
fontFamily: 'Sora, sans-serif',
|
||||||
|
letterSpacing: '0.02em'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
mt="auto"
|
||||||
|
p="sm"
|
||||||
|
style={{ borderRadius: 12, background: 'rgba(14,165,163,0.08)', border: '1px solid rgba(14,165,163,0.2)' }}
|
||||||
|
>
|
||||||
|
<Text size="sm" fw={600} className="sw-page-title">
|
||||||
|
Live Pipeline
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
Durumlar panelden canlı izlenir, hata durumları review ekranına düşer.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</AppShell.Navbar>
|
||||||
|
|
||||||
|
<AppShell.Main>{children}</AppShell.Main>
|
||||||
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
47
services/ui/src/index.css
Normal file
47
services/ui/src/index.css
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=Source+Serif+4:opsz,wght@8..60,400;8..60,500;8..60,600&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--sw-bg-0: #f7fbff;
|
||||||
|
--sw-bg-1: #f1f6fd;
|
||||||
|
--sw-bg-2: #e9f1fb;
|
||||||
|
--sw-accent: #0ea5a3;
|
||||||
|
--sw-accent-2: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 10% 12%, rgba(14, 165, 163, 0.14), transparent 32%),
|
||||||
|
radial-gradient(circle at 88% 10%, rgba(29, 78, 216, 0.1), transparent 34%),
|
||||||
|
linear-gradient(160deg, var(--sw-bg-0), var(--sw-bg-1) 48%, var(--sw-bg-2));
|
||||||
|
color: #1c2733;
|
||||||
|
font-family: 'Source Serif 4', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image: linear-gradient(rgba(14, 23, 38, 0.03) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(14, 23, 38, 0.03) 1px, transparent 1px);
|
||||||
|
background-size: 36px 36px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-page-title {
|
||||||
|
font-family: 'Sora', sans-serif;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
margin: 0;
|
||||||
|
color: #102032;
|
||||||
|
}
|
||||||
23
services/ui/src/lib/status.ts
Normal file
23
services/ui/src/lib/status.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export const JOB_STATUSES = [
|
||||||
|
'PENDING',
|
||||||
|
'WAITING_FILE_STABLE',
|
||||||
|
'PARSED',
|
||||||
|
'ANALYZED',
|
||||||
|
'REQUESTING_API',
|
||||||
|
'FOUND_TEMP',
|
||||||
|
'NORMALIZING_ENCODING',
|
||||||
|
'WRITING_SUBTITLE',
|
||||||
|
'DONE',
|
||||||
|
'NEEDS_REVIEW',
|
||||||
|
'NOT_FOUND',
|
||||||
|
'AMBIGUOUS',
|
||||||
|
'ERROR'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function statusColor(status: string): string {
|
||||||
|
if (status === 'DONE') return 'teal';
|
||||||
|
if (status === 'NEEDS_REVIEW') return 'yellow';
|
||||||
|
if (status === 'ERROR') return 'red';
|
||||||
|
if (status === 'NOT_FOUND' || status === 'AMBIGUOUS') return 'orange';
|
||||||
|
return 'blue';
|
||||||
|
}
|
||||||
@@ -1,9 +1,46 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { createTheme, MantineProvider } from '@mantine/core';
|
||||||
|
import { ModalsProvider } from '@mantine/modals';
|
||||||
|
import { Notifications } from '@mantine/notifications';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import '@mantine/core/styles.css';
|
||||||
|
import '@mantine/notifications/styles.css';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const theme = createTheme({
|
||||||
|
primaryColor: 'cyan',
|
||||||
|
defaultRadius: 'md',
|
||||||
|
fontFamily: '"Source Serif 4", serif',
|
||||||
|
headings: {
|
||||||
|
fontFamily: '"Sora", sans-serif'
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
shell: [
|
||||||
|
'#ffffff',
|
||||||
|
'#f7fbff',
|
||||||
|
'#eef5ff',
|
||||||
|
'#dde9f7',
|
||||||
|
'#ccdaec',
|
||||||
|
'#b4c4d8',
|
||||||
|
'#8fa0b3',
|
||||||
|
'#6f7f90',
|
||||||
|
'#556473',
|
||||||
|
'#3a4651'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<MantineProvider theme={theme} defaultColorScheme="light">
|
||||||
|
<ModalsProvider>
|
||||||
|
<Notifications position="top-right" />
|
||||||
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</ModalsProvider>
|
||||||
|
</MantineProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { Badge, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||||
|
import { IconAlertTriangle, IconChecklist, IconClockHour4, IconProgressCheck } from '@tabler/icons-react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { Job } from '../types';
|
import { Job } from '../types';
|
||||||
import { usePoll } from '../hooks/usePoll';
|
import { usePoll } from '../hooks/usePoll';
|
||||||
@@ -8,37 +10,88 @@ export function DashboardPage({ onSelectJob }: { onSelectJob: (id: string) => vo
|
|||||||
const [jobs, setJobs] = useState<Job[]>([]);
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const data = await api<{ items: Job[] }>('/api/jobs?limit=20');
|
const data = await api<{ items: Job[] }>('/api/jobs?limit=30');
|
||||||
setJobs(data.items);
|
setJobs(data.items);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
usePoll(load, 5000);
|
usePoll(load, 5000);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
const since = Date.now() - 24 * 3600 * 1000;
|
const since = Date.now() - 24 * 3600 * 1000;
|
||||||
const recent = jobs.filter((x) => new Date(x.createdAt).getTime() >= since);
|
const recent = jobs.filter((x) => new Date(x.createdAt).getTime() >= since);
|
||||||
const done = recent.filter((x) => x.status === 'DONE').length;
|
return {
|
||||||
const review = recent.filter((x) => x.status === 'NEEDS_REVIEW').length;
|
recent,
|
||||||
const errors = recent.filter((x) => x.status === 'ERROR').length;
|
total: recent.length,
|
||||||
|
done: recent.filter((x) => x.status === 'DONE').length,
|
||||||
|
review: recent.filter((x) => x.status === 'NEEDS_REVIEW').length,
|
||||||
|
error: recent.filter((x) => x.status === 'ERROR').length
|
||||||
|
};
|
||||||
|
}, [jobs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Group justify="space-between" align="flex-end">
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(120px, 1fr))', gap: 12, marginBottom: 16 }}>
|
<h1 className="sw-page-title">Operations Dashboard</h1>
|
||||||
<Stat label="24h Job" value={String(recent.length)} />
|
<Text c="dimmed">Son 24 saatin özetini ve son işleri buradan takip et.</Text>
|
||||||
<Stat label="DONE" value={String(done)} />
|
|
||||||
<Stat label="REVIEW" value={String(review)} />
|
|
||||||
<Stat label="ERROR" value={String(errors)} />
|
|
||||||
</div>
|
</div>
|
||||||
<h3>Son Isler</h3>
|
<Badge size="lg" variant="outline" color="cyan">
|
||||||
|
Live polling: 5s
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, xl: 4 }} spacing="md">
|
||||||
|
<StatCard title="24h Total" value={stats.total} color="#3fa6ff" icon={<IconClockHour4 size={18} />} />
|
||||||
|
<StatCard title="Completed" value={stats.done} color="#0ea5a3" icon={<IconProgressCheck size={18} />} />
|
||||||
|
<StatCard title="Needs Review" value={stats.review} color="#e3a008" icon={<IconChecklist size={18} />} />
|
||||||
|
<StatCard title="Errors" value={stats.error} color="#dc4c64" icon={<IconAlertTriangle size={18} />} />
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={600} size="lg" className="sw-page-title">
|
||||||
|
Son İşler
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Liste otomatik yenilenir.
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
<JobTable jobs={jobs} onSelect={onSelectJob} />
|
<JobTable jobs={jobs} onSelect={onSelectJob} />
|
||||||
</div>
|
</Stack>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value }: { label: string; value: string }) {
|
function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
color,
|
||||||
|
icon
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{ border: '1px solid #cbd5e1', borderRadius: 12, padding: 12, background: '#ffffffcc' }}>
|
<Card
|
||||||
<div style={{ fontSize: 12, opacity: 0.7 }}>{label}</div>
|
radius="lg"
|
||||||
<div style={{ fontSize: 24, fontWeight: 700 }}>{value}</div>
|
withBorder
|
||||||
</div>
|
p="lg"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(160deg, rgba(255,255,255,0.92), rgba(246,251,255,0.9))',
|
||||||
|
borderColor: 'rgba(16,32,50,0.12)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" mb={8}>
|
||||||
|
<Text c="dimmed" size="sm" ff="Sora, sans-serif">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<div style={{ color }}>{icon}</div>
|
||||||
|
</Group>
|
||||||
|
<Text fw={700} size="2rem" ff="Sora, sans-serif" c="#18314a">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,54 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
import {
|
||||||
import { Job, JobLog } from '../types';
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
NumberInput,
|
||||||
|
ScrollArea,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { IconCheck, IconSparkles, IconTerminal2 } from '@tabler/icons-react';
|
||||||
|
import { api, buildSseUrl } from '../api/client';
|
||||||
|
import { Job, JobCandidate, JobLog } from '../types';
|
||||||
|
import { statusColor } from '../lib/status';
|
||||||
|
|
||||||
|
interface ManualOverride {
|
||||||
|
title?: string;
|
||||||
|
year?: number;
|
||||||
|
release?: string;
|
||||||
|
season?: number;
|
||||||
|
episode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function JobDetailPage({ jobId }: { jobId: string }) {
|
export function JobDetailPage({ jobId }: { jobId: string }) {
|
||||||
const [job, setJob] = useState<Job | null>(null);
|
const [job, setJob] = useState<Job | null>(null);
|
||||||
const [logs, setLogs] = useState<JobLog[]>([]);
|
const [logs, setLogs] = useState<JobLog[]>([]);
|
||||||
const [override, setOverride] = useState<any>({});
|
const [override, setOverride] = useState<ManualOverride>({});
|
||||||
const [candidates, setCandidates] = useState<any[]>([]);
|
const [candidates, setCandidates] = useState<JobCandidate[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let es: EventSource | null = null;
|
let es: EventSource | null = null;
|
||||||
(async () => {
|
(async () => {
|
||||||
const j = await api<Job>(`/api/jobs/${jobId}`);
|
const loadedJob = await api<Job>(`/api/jobs/${jobId}`);
|
||||||
setJob(j);
|
setJob(loadedJob);
|
||||||
const l = await api<{ items: JobLog[] }>(`/api/jobs/${jobId}/logs?limit=200`);
|
|
||||||
setLogs(l.items);
|
|
||||||
if (j.apiSnapshot?.candidates) setCandidates(j.apiSnapshot.candidates);
|
|
||||||
|
|
||||||
es = new EventSource(`/api/jobs/${jobId}/stream`);
|
const loadedLogs = await api<{ items: JobLog[] }>(`/api/jobs/${jobId}/logs?limit=200`);
|
||||||
es.onmessage = (ev) => {
|
setLogs(loadedLogs.items);
|
||||||
const item = JSON.parse(ev.data);
|
|
||||||
|
if (loadedJob.apiSnapshot?.candidates) {
|
||||||
|
setCandidates(loadedJob.apiSnapshot.candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
es = new EventSource(buildSseUrl(`/api/jobs/${jobId}/stream`));
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
const item = JSON.parse(event.data) as JobLog;
|
||||||
setLogs((prev) => [...prev, item]);
|
setLogs((prev) => [...prev, item]);
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
@@ -30,66 +59,173 @@ export function JobDetailPage({ jobId }: { jobId: string }) {
|
|||||||
}, [jobId]);
|
}, [jobId]);
|
||||||
|
|
||||||
async function manualSearch() {
|
async function manualSearch() {
|
||||||
const res = await api<any>(`/api/review/${jobId}/search`, { method: 'POST', body: JSON.stringify(override) });
|
try {
|
||||||
setCandidates(res.candidates || []);
|
const res = await api<{ candidates?: JobCandidate[] }>(`/api/review/${jobId}/search`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(override)
|
||||||
|
});
|
||||||
|
const list = res.candidates || [];
|
||||||
|
setCandidates(list);
|
||||||
|
notifications.show({
|
||||||
|
color: list.length > 0 ? 'teal' : 'yellow',
|
||||||
|
title: 'Arama tamamlandı',
|
||||||
|
message: list.length > 0 ? `${list.length} aday bulundu.` : 'Aday bulunamadı.'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Arama başarısız.' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function choose(c: any) {
|
async function choose(candidate: JobCandidate) {
|
||||||
await api(`/api/review/${jobId}/choose`, { method: 'POST', body: JSON.stringify({ chosenCandidateId: c.id, lang: c.lang || 'tr' }) });
|
try {
|
||||||
const j = await api<Job>(`/api/jobs/${jobId}`);
|
await api(`/api/review/${jobId}/choose`, {
|
||||||
setJob(j);
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ chosenCandidateId: candidate.id, lang: candidate.lang || 'tr' })
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshed = await api<Job>(`/api/jobs/${jobId}`);
|
||||||
|
setJob(refreshed);
|
||||||
|
notifications.show({ color: 'teal', title: 'Seçim kaydedildi', message: 'Aday başarıyla uygulandı.' });
|
||||||
|
} catch (error) {
|
||||||
|
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Aday seçilemedi.' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!job) return <div>Yukleniyor...</div>;
|
if (!job) return <Text c="dimmed">Yükleniyor...</Text>;
|
||||||
|
|
||||||
|
const media = job.mediaFileId;
|
||||||
|
|
||||||
const media = job.mediaFileId as any;
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gap: 12 }}>
|
<Stack gap="lg">
|
||||||
<h3>Job #{job._id.slice(-8)} - {job.status}</h3>
|
<Group justify="space-between" align="flex-start">
|
||||||
<div style={{ border: '1px solid #cbd5e1', padding: 12, borderRadius: 8, background: '#fff' }}>
|
<div>
|
||||||
<div>Baslik: {job.requestSnapshot?.title || '-'}</div>
|
<h1 className="sw-page-title">Job #{job._id.slice(-8)}</h1>
|
||||||
<div>Tip: {job.requestSnapshot?.type || '-'}</div>
|
<Text c="dimmed">{job.requestSnapshot?.path || 'manual trigger'}</Text>
|
||||||
<div>Yil: {job.requestSnapshot?.year || '-'}</div>
|
|
||||||
<div>Release: {job.requestSnapshot?.release || '-'}</div>
|
|
||||||
<div>Season/Episode: {job.requestSnapshot?.season ?? '-'} / {job.requestSnapshot?.episode ?? '-'}</div>
|
|
||||||
<div>Media: {media?.path || '-'}</div>
|
|
||||||
<div>Video: {media?.mediaInfo?.video?.codec_name || '-'} {media?.mediaInfo?.video?.width || '-'}x{media?.mediaInfo?.video?.height || '-'}</div>
|
|
||||||
<div>Sonuc: {job.result?.subtitles?.map((s: any) => s.writtenPath).join(', ') || '-'}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Badge color={statusColor(job.status)} size="lg" variant="light">
|
||||||
|
{job.status}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
{job.status === 'NEEDS_REVIEW' && (
|
<Card withBorder radius="lg" p="lg" style={{ background: 'rgba(255,255,255,0.9)', borderColor: 'rgba(16,32,50,0.12)' }}>
|
||||||
<div style={{ border: '1px solid #f59e0b', padding: 12, borderRadius: 8, background: '#fffbeb' }}>
|
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
|
||||||
<h4>Manual Override</h4>
|
<Detail label="Başlık" value={job.requestSnapshot?.title || '-'} />
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
<Detail label="Tür" value={job.requestSnapshot?.type || '-'} />
|
||||||
<input placeholder="title" onChange={(e) => setOverride((x: any) => ({ ...x, title: e.target.value }))} />
|
<Detail label="Yıl" value={String(job.requestSnapshot?.year || '-')} />
|
||||||
<input placeholder="year" type="number" onChange={(e) => setOverride((x: any) => ({ ...x, year: Number(e.target.value) }))} />
|
<Detail label="Release" value={job.requestSnapshot?.release || '-'} />
|
||||||
<input placeholder="release" onChange={(e) => setOverride((x: any) => ({ ...x, release: e.target.value }))} />
|
<Detail label="Season / Episode" value={`${job.requestSnapshot?.season ?? '-'} / ${job.requestSnapshot?.episode ?? '-'}`} />
|
||||||
<input placeholder="season" type="number" onChange={(e) => setOverride((x: any) => ({ ...x, season: Number(e.target.value) }))} />
|
<Detail label="Media" value={media?.path || '-'} />
|
||||||
<input placeholder="episode" type="number" onChange={(e) => setOverride((x: any) => ({ ...x, episode: Number(e.target.value) }))} />
|
<Detail
|
||||||
<button onClick={manualSearch}>Search</button>
|
label="Video"
|
||||||
|
value={`${media?.mediaInfo?.video?.codec_name || '-'} ${media?.mediaInfo?.video?.width || '-'}x${media?.mediaInfo?.video?.height || '-'}`}
|
||||||
|
/>
|
||||||
|
<Detail label="Çıktı" value={job.result?.subtitles?.map((item) => item.writtenPath).join(', ') || '-'} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{job.status === 'NEEDS_REVIEW' ? (
|
||||||
|
<Card withBorder radius="lg" p="lg" style={{ background: 'rgba(255,252,243,0.92)', borderColor: 'rgba(227,160,8,0.3)' }}>
|
||||||
|
<Stack>
|
||||||
|
<Group gap="xs">
|
||||||
|
<IconSparkles size={18} color="#e3a008" />
|
||||||
|
<Text fw={600} className="sw-page-title">
|
||||||
|
Manual Override
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="sm">
|
||||||
|
<TextInput placeholder="title" value={override.title || ''} onChange={(e) => setOverride((prev) => ({ ...prev, title: e.currentTarget.value }))} />
|
||||||
|
<NumberInput placeholder="year" value={override.year} onChange={(value) => setOverride((prev) => ({ ...prev, year: Number(value) }))} hideControls />
|
||||||
|
<TextInput placeholder="release" value={override.release || ''} onChange={(e) => setOverride((prev) => ({ ...prev, release: e.currentTarget.value }))} />
|
||||||
|
<NumberInput placeholder="season" value={override.season} onChange={(value) => setOverride((prev) => ({ ...prev, season: Number(value) }))} hideControls />
|
||||||
|
<NumberInput placeholder="episode" value={override.episode} onChange={(value) => setOverride((prev) => ({ ...prev, episode: Number(value) }))} hideControls />
|
||||||
|
<Button onClick={manualSearch}>Search</Button>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{candidates.length === 0 ? (
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Aday bulunamadı.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{candidates.map((candidate) => (
|
||||||
|
<Group
|
||||||
|
key={candidate.id}
|
||||||
|
justify="space-between"
|
||||||
|
p="sm"
|
||||||
|
style={{ border: '1px solid rgba(16,32,50,0.1)', borderRadius: 10, background: 'rgba(255,255,255,0.75)' }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Text fw={600}>{candidate.provider}</Text>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{candidate.id} • score {candidate.score}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<Button size="xs" variant="light" leftSection={<IconCheck size={14} />} onClick={() => choose(candidate)}>
|
||||||
{candidates.map((c) => (
|
Seç
|
||||||
<li key={c.id}>
|
</Button>
|
||||||
{c.provider} | {c.id} | score={c.score}
|
</Group>
|
||||||
<button onClick={() => choose(c)}>Sec</button>
|
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</Stack>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div style={{ border: '1px solid #cbd5e1', borderRadius: 8, background: '#fff', padding: 12 }}>
|
<Card withBorder radius="lg" p="lg" style={{ background: 'rgba(255,255,255,0.9)', borderColor: 'rgba(16,32,50,0.12)' }}>
|
||||||
<h4>Canli Loglar</h4>
|
<Stack gap="sm">
|
||||||
<div style={{ maxHeight: 320, overflow: 'auto', fontSize: 12 }}>
|
<Group gap="xs">
|
||||||
{logs.map((l) => (
|
<IconTerminal2 size={18} />
|
||||||
<div key={l._id + l.ts} style={{ borderBottom: '1px dashed #e2e8f0', padding: '4px 0' }}>
|
<Text fw={600} className="sw-page-title">
|
||||||
[{new Date(l.ts).toLocaleTimeString()}] {l.step} - {l.message}
|
Canlı Log Akışı
|
||||||
{l.meta ? ` | meta=${JSON.stringify(l.meta)}` : ''}
|
</Text>
|
||||||
</div>
|
</Group>
|
||||||
))}
|
|
||||||
</div>
|
<Divider />
|
||||||
|
|
||||||
|
<ScrollArea h={340}>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Henüz log yok.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
logs.map((log) => (
|
||||||
|
<Alert key={log._id + log.ts} color={log.level === 'error' ? 'red' : log.level === 'warn' ? 'yellow' : 'teal'} variant="light" p="xs">
|
||||||
|
<Group gap={8} wrap="nowrap" align="flex-start">
|
||||||
|
<Text ff="monospace" size="xs" c="dimmed" mt={2}>
|
||||||
|
[{new Date(log.ts).toLocaleTimeString()}]
|
||||||
|
</Text>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="sm" ff="Sora, sans-serif">
|
||||||
|
{log.step}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{log.message}</Text>
|
||||||
|
{log.meta ? (
|
||||||
|
<Text size="xs" c="dimmed" ff="monospace">
|
||||||
|
{JSON.stringify(log.meta)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Alert>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Detail({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text fw={600}>{value}</Text>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
import { Button, Group, Select, Stack, Text, TextInput } from '@mantine/core';
|
||||||
|
import { IconFilter, IconSearch } from '@tabler/icons-react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { Job } from '../types';
|
import { Job } from '../types';
|
||||||
import { usePoll } from '../hooks/usePoll';
|
import { usePoll } from '../hooks/usePoll';
|
||||||
import { JobTable } from '../components/JobTable';
|
import { JobTable } from '../components/JobTable';
|
||||||
|
import { JOB_STATUSES } from '../lib/status';
|
||||||
|
|
||||||
export function JobsPage({ onSelectJob }: { onSelectJob: (id: string) => void }) {
|
export function JobsPage({ onSelectJob }: { onSelectJob: (id: string) => void }) {
|
||||||
const [jobs, setJobs] = useState<Job[]>([]);
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
const [status, setStatus] = useState('');
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const q = new URLSearchParams({ limit: '100' });
|
const q = new URLSearchParams({ limit: '100' });
|
||||||
if (status) q.set('status', status);
|
if (status) q.set('status', status);
|
||||||
if (search) q.set('search', search);
|
if (search.trim()) q.set('search', search.trim());
|
||||||
const data = await api<{ items: Job[] }>(`/api/jobs?${q.toString()}`);
|
const data = await api<{ items: Job[] }>(`/api/jobs?${q.toString()}`);
|
||||||
setJobs(data.items);
|
setJobs(data.items);
|
||||||
}, [status, search]);
|
}, [status, search]);
|
||||||
@@ -20,13 +23,39 @@ export function JobsPage({ onSelectJob }: { onSelectJob: (id: string) => void })
|
|||||||
usePoll(load, 4000);
|
usePoll(load, 4000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
<h1 className="sw-page-title">Jobs Explorer</h1>
|
||||||
<input placeholder="status" value={status} onChange={(e) => setStatus(e.target.value)} />
|
<Text c="dimmed">Durum ve metin filtresiyle işleri detaylı inceleyin.</Text>
|
||||||
<input placeholder="title/path" value={search} onChange={(e) => setSearch(e.target.value)} />
|
|
||||||
<button onClick={() => load()}>Filtrele</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Group align="end" gap="md" wrap="wrap">
|
||||||
|
<Select
|
||||||
|
label="Status"
|
||||||
|
placeholder="Tüm durumlar"
|
||||||
|
data={JOB_STATUSES.map((value) => ({ value, label: value }))}
|
||||||
|
value={status}
|
||||||
|
onChange={setStatus}
|
||||||
|
leftSection={<IconFilter size={16} />}
|
||||||
|
w={260}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Ara"
|
||||||
|
placeholder="title/path"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||||
|
leftSection={<IconSearch size={16} />}
|
||||||
|
w={320}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button variant="light" onClick={() => load()}>
|
||||||
|
Filtreyi Uygula
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<JobTable jobs={jobs} onSelect={onSelectJob} />
|
<JobTable jobs={jobs} onSelect={onSelectJob} />
|
||||||
</div>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
import { ActionIcon, Badge, Card, Group, Stack, Text } from '@mantine/core';
|
||||||
|
import { IconArrowRight } from '@tabler/icons-react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { Job } from '../types';
|
import { Job } from '../types';
|
||||||
import { usePoll } from '../hooks/usePoll';
|
import { usePoll } from '../hooks/usePoll';
|
||||||
@@ -8,22 +10,46 @@ export function ReviewPage({ onSelectJob }: { onSelectJob: (id: string) => void
|
|||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const data = await api<Job[]>('/api/review');
|
const data = await api<Job[]>('/api/review');
|
||||||
setJobs(data);
|
setJobs(data || []);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
usePoll(load, 5000);
|
usePoll(load, 5000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Group justify="space-between">
|
||||||
<div>
|
<div>
|
||||||
<h3>Needs Review</h3>
|
<h1 className="sw-page-title">Manual Review Queue</h1>
|
||||||
<ul>
|
<Text c="dimmed">Belirsiz veya bulunamayan işler burada manuel karar bekler.</Text>
|
||||||
{jobs.map((j) => (
|
|
||||||
<li key={j._id}>
|
|
||||||
{j.requestSnapshot?.title || '-'} ({j.status})
|
|
||||||
<button onClick={() => onSelectJob(j._id)}>Ac</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Badge size="lg" color="yellow" variant="light">
|
||||||
|
{jobs.length} pending
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<Card withBorder radius="lg" p="lg" style={{ background: 'rgba(255,255,255,0.9)', borderColor: 'rgba(16,32,50,0.12)' }}>
|
||||||
|
<Text c="dimmed">Review bekleyen iş yok.</Text>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
jobs.map((job) => (
|
||||||
|
<Card key={job._id} withBorder radius="lg" p="lg" style={{ background: 'rgba(255,255,255,0.9)', borderColor: 'rgba(16,32,50,0.12)' }}>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<div>
|
||||||
|
<Text fw={600}>{job.requestSnapshot?.title || job.requestSnapshot?.path || 'Unknown job'}</Text>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{job.status} • {new Date(job.updatedAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<ActionIcon variant="light" size="lg" onClick={() => onSelectJob(job._id)}>
|
||||||
|
<IconArrowRight size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,125 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Card, Checkbox, Grid, Group, NumberInput, Stack, Text, TextInput } from '@mantine/core';
|
||||||
|
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
|
import { Settings } from '../types';
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const [settings, setSettings] = useState<any>(null);
|
const [settings, setSettings] = useState<Settings | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api<any>('/api/settings').then(setSettings);
|
api<Settings>('/api/settings').then(setSettings);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (!settings) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
await api('/api/settings', { method: 'POST', body: JSON.stringify(settings) });
|
await api('/api/settings', { method: 'POST', body: JSON.stringify(settings) });
|
||||||
|
notifications.show({ color: 'teal', title: 'Kaydedildi', message: 'Ayarlar başarıyla güncellendi.' });
|
||||||
|
} catch (error) {
|
||||||
|
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Ayarlar kaydedilemedi.' });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!settings) return <div>Yukleniyor...</div>;
|
if (!settings) return <Text c="dimmed">Yükleniyor...</Text>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gap: 8, maxWidth: 760 }}>
|
<Stack gap="lg">
|
||||||
<label>Languages (comma)
|
<div>
|
||||||
<input value={settings.languages.join(',')} onChange={(e) => setSettings({ ...settings, languages: e.target.value.split(',').map((x) => x.trim()).filter(Boolean) })} />
|
<h1 className="sw-page-title">Settings</h1>
|
||||||
</label>
|
<Text c="dimmed">Arama, stabilite ve yazma davranışlarını buradan yönetin.</Text>
|
||||||
<label><input type="checkbox" checked={settings.multiSubtitleEnabled} onChange={(e) => setSettings({ ...settings, multiSubtitleEnabled: e.target.checked })} /> Multi subtitle</label>
|
|
||||||
<label><input type="checkbox" checked={settings.overwriteExisting} onChange={(e) => setSettings({ ...settings, overwriteExisting: e.target.checked })} /> Overwrite existing</label>
|
|
||||||
<label><input type="checkbox" checked={settings.preferHI} onChange={(e) => setSettings({ ...settings, preferHI: e.target.checked })} /> Prefer HI</label>
|
|
||||||
<label><input type="checkbox" checked={settings.preferForced} onChange={(e) => setSettings({ ...settings, preferForced: e.target.checked })} /> Prefer Forced</label>
|
|
||||||
<label>stableChecks <input type="number" value={settings.stableChecks} onChange={(e) => setSettings({ ...settings, stableChecks: Number(e.target.value) })} /></label>
|
|
||||||
<label>stableIntervalSeconds <input type="number" value={settings.stableIntervalSeconds} onChange={(e) => setSettings({ ...settings, stableIntervalSeconds: Number(e.target.value) })} /></label>
|
|
||||||
<label>autoWriteThreshold <input type="number" step="0.01" value={settings.autoWriteThreshold} onChange={(e) => setSettings({ ...settings, autoWriteThreshold: Number(e.target.value) })} /></label>
|
|
||||||
<button onClick={save}>Kaydet</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card withBorder radius="lg" p="lg" style={{ background: 'rgba(255,255,255,0.9)', borderColor: 'rgba(16,32,50,0.12)' }}>
|
||||||
|
<Stack>
|
||||||
|
<TextInput
|
||||||
|
label="Öncelikli diller"
|
||||||
|
description="Virgülle ayırın (örn: tr, en)"
|
||||||
|
value={settings.languages.join(', ')}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
languages: event.currentTarget.value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<Checkbox
|
||||||
|
label="Multi subtitle enabled"
|
||||||
|
checked={settings.multiSubtitleEnabled}
|
||||||
|
onChange={(event) => setSettings({ ...settings, multiSubtitleEnabled: event.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<Checkbox
|
||||||
|
label="Overwrite existing"
|
||||||
|
checked={settings.overwriteExisting}
|
||||||
|
onChange={(event) => setSettings({ ...settings, overwriteExisting: event.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<Checkbox
|
||||||
|
label="Prefer HI"
|
||||||
|
checked={settings.preferHI}
|
||||||
|
onChange={(event) => setSettings({ ...settings, preferHI: event.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<Checkbox
|
||||||
|
label="Prefer forced"
|
||||||
|
checked={settings.preferForced}
|
||||||
|
onChange={(event) => setSettings({ ...settings, preferForced: event.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||||
|
<NumberInput
|
||||||
|
label="Stable checks"
|
||||||
|
min={1}
|
||||||
|
value={settings.stableChecks}
|
||||||
|
onChange={(value) => setSettings({ ...settings, stableChecks: Number(value) })}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||||
|
<NumberInput
|
||||||
|
label="Stable interval seconds"
|
||||||
|
min={1}
|
||||||
|
value={settings.stableIntervalSeconds}
|
||||||
|
onChange={(value) => setSettings({ ...settings, stableIntervalSeconds: Number(value) })}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||||
|
<NumberInput
|
||||||
|
label="Auto write threshold"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
decimalScale={2}
|
||||||
|
value={settings.autoWriteThreshold}
|
||||||
|
onChange={(value) => setSettings({ ...settings, autoWriteThreshold: Number(value) })}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button loading={saving} leftSection={<IconDeviceFloppy size={16} />} onClick={save}>
|
||||||
|
Kaydet
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,160 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
TextInput
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { modals } from '@mantine/modals';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { IconPlus, IconTrash, IconToggleLeft, IconToggleRight } from '@tabler/icons-react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { usePoll } from '../hooks/usePoll';
|
import { usePoll } from '../hooks/usePoll';
|
||||||
|
import { WatchedPath } from '../types';
|
||||||
|
|
||||||
export function WatchedPathsPage() {
|
export function WatchedPathsPage() {
|
||||||
const [items, setItems] = useState<any[]>([]);
|
const [items, setItems] = useState<WatchedPath[]>([]);
|
||||||
const [path, setPath] = useState('');
|
const [path, setPath] = useState('');
|
||||||
const [kind, setKind] = useState('mixed');
|
const [kind, setKind] = useState<string>('mixed');
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const data = await api<any[]>('/api/watched-paths');
|
const data = await api<WatchedPath[]>('/api/watched-paths');
|
||||||
setItems(data);
|
setItems(data || []);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
usePoll(load, 5000);
|
usePoll(load, 5000);
|
||||||
|
|
||||||
async function add() {
|
async function add() {
|
||||||
await api('/api/watched-paths', { method: 'POST', body: JSON.stringify({ action: 'add', path, kind }) });
|
if (!path.trim()) return;
|
||||||
|
try {
|
||||||
|
await api('/api/watched-paths', { method: 'POST', body: JSON.stringify({ action: 'add', path: path.trim(), kind }) });
|
||||||
setPath('');
|
setPath('');
|
||||||
|
notifications.show({ color: 'teal', title: 'Eklendi', message: 'Watched path başarıyla eklendi.' });
|
||||||
await load();
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Path eklenemedi.' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggle(item: any) {
|
async function toggle(item: WatchedPath) {
|
||||||
await api('/api/watched-paths', { method: 'POST', body: JSON.stringify({ action: 'toggle', path: item.path, enabled: !item.enabled }) });
|
try {
|
||||||
|
await api('/api/watched-paths', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ action: 'toggle', path: item.path, enabled: !item.enabled })
|
||||||
|
});
|
||||||
|
notifications.show({ color: 'cyan', title: 'Güncellendi', message: `${item.path} durumu değiştirildi.` });
|
||||||
await load();
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Durum güncellenemedi.' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(item: any) {
|
function remove(item: WatchedPath) {
|
||||||
|
modals.openConfirmModal({
|
||||||
|
title: 'Path silinsin mi?',
|
||||||
|
centered: true,
|
||||||
|
children: <Text size="sm">{item.path} izleme listesinden çıkarılacak.</Text>,
|
||||||
|
labels: { confirm: 'Sil', cancel: 'Vazgeç' },
|
||||||
|
confirmProps: { color: 'red' },
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
await api('/api/watched-paths', { method: 'POST', body: JSON.stringify({ action: 'remove', path: item.path }) });
|
await api('/api/watched-paths', { method: 'POST', body: JSON.stringify({ action: 'remove', path: item.path }) });
|
||||||
|
notifications.show({ color: 'teal', title: 'Silindi', message: 'Watched path kaldırıldı.' });
|
||||||
await load();
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Path silinemedi.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
<h1 className="sw-page-title">Watched Paths</h1>
|
||||||
<input placeholder="/media/custom" value={path} onChange={(e) => setPath(e.target.value)} />
|
<Text c="dimmed">Core watcher’ın izleyeceği dizinleri buradan yönetin.</Text>
|
||||||
<select value={kind} onChange={(e) => setKind(e.target.value)}>
|
|
||||||
<option value="tv">tv</option>
|
|
||||||
<option value="movie">movie</option>
|
|
||||||
<option value="mixed">mixed</option>
|
|
||||||
</select>
|
|
||||||
<button onClick={add}>Ekle</button>
|
|
||||||
</div>
|
|
||||||
<table width="100%" cellPadding={8} style={{ borderCollapse: 'collapse', background: '#fff' }}>
|
|
||||||
<thead><tr><th align="left">Path</th><th align="left">Kind</th><th align="left">Enabled</th><th /></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{items.map((i) => (
|
|
||||||
<tr key={i.path} style={{ borderTop: '1px solid #e2e8f0' }}>
|
|
||||||
<td>{i.path}</td><td>{i.kind}</td><td>{String(i.enabled)}</td>
|
|
||||||
<td>
|
|
||||||
<button onClick={() => toggle(i)}>Toggle</button>
|
|
||||||
<button onClick={() => remove(i)}>Sil</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Group align="end" wrap="wrap">
|
||||||
|
<TextInput
|
||||||
|
label="Yeni dizin"
|
||||||
|
placeholder="/media/custom"
|
||||||
|
value={path}
|
||||||
|
onChange={(event) => setPath(event.currentTarget.value)}
|
||||||
|
w={420}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="İçerik tipi"
|
||||||
|
value={kind}
|
||||||
|
onChange={(value) => setKind(value || 'mixed')}
|
||||||
|
data={[
|
||||||
|
{ value: 'tv', label: 'TV' },
|
||||||
|
{ value: 'movie', label: 'Movie' },
|
||||||
|
{ value: 'mixed', label: 'Mixed' }
|
||||||
|
]}
|
||||||
|
w={140}
|
||||||
|
/>
|
||||||
|
<Button leftSection={<IconPlus size={16} />} onClick={add}>
|
||||||
|
Ekle
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Paper radius="lg" withBorder p={0} style={{ background: 'rgba(255,255,255,0.88)', borderColor: 'rgba(16,32,50,0.12)' }}>
|
||||||
|
<ScrollArea>
|
||||||
|
<Table horizontalSpacing="md" verticalSpacing="sm" miw={780}>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Path</Table.Th>
|
||||||
|
<Table.Th>Kind</Table.Th>
|
||||||
|
<Table.Th>Enabled</Table.Th>
|
||||||
|
<Table.Th></Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={4}>
|
||||||
|
<Text c="dimmed" ta="center" py="md">
|
||||||
|
Henüz watched path eklenmedi.
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<Table.Tr key={item.path}>
|
||||||
|
<Table.Td>{item.path}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge variant="light">{item.kind}</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={item.enabled ? 'teal' : 'gray'} variant="dot">
|
||||||
|
{item.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group justify="flex-end" gap={6} wrap="nowrap">
|
||||||
|
<ActionIcon variant="light" color={item.enabled ? 'yellow' : 'teal'} onClick={() => toggle(item)}>
|
||||||
|
{item.enabled ? <IconToggleLeft size={16} /> : <IconToggleRight size={16} />}
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon variant="light" color="red" onClick={() => remove(item)}>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,40 @@
|
|||||||
|
export interface JobRequestSnapshot {
|
||||||
|
title?: string;
|
||||||
|
path?: string;
|
||||||
|
type?: string;
|
||||||
|
year?: number;
|
||||||
|
release?: string;
|
||||||
|
season?: number;
|
||||||
|
episode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobCandidate {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
score: number;
|
||||||
|
lang?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Job {
|
export interface Job {
|
||||||
_id: string;
|
_id: string;
|
||||||
status: string;
|
status: string;
|
||||||
requestSnapshot?: any;
|
requestSnapshot?: JobRequestSnapshot;
|
||||||
apiSnapshot?: any;
|
apiSnapshot?: {
|
||||||
result?: any;
|
candidates?: JobCandidate[];
|
||||||
mediaFileId?: any;
|
};
|
||||||
|
result?: {
|
||||||
|
subtitles?: Array<{ writtenPath: string }>;
|
||||||
|
};
|
||||||
|
mediaFileId?: {
|
||||||
|
path?: string;
|
||||||
|
mediaInfo?: {
|
||||||
|
video?: {
|
||||||
|
codec_name?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -16,5 +46,22 @@ export interface JobLog {
|
|||||||
message: string;
|
message: string;
|
||||||
level: 'info' | 'warn' | 'error';
|
level: 'info' | 'warn' | 'error';
|
||||||
ts: string;
|
ts: string;
|
||||||
meta?: any;
|
meta?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
languages: string[];
|
||||||
|
multiSubtitleEnabled: boolean;
|
||||||
|
overwriteExisting: boolean;
|
||||||
|
preferHI: boolean;
|
||||||
|
preferForced: boolean;
|
||||||
|
stableChecks: number;
|
||||||
|
stableIntervalSeconds: number;
|
||||||
|
autoWriteThreshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchedPath {
|
||||||
|
path: string;
|
||||||
|
kind: 'tv' | 'movie' | 'mixed';
|
||||||
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user