Add DRM downloads, scrapers, gallery index, and UI improvements
- DRM video download pipeline with pywidevine subprocess for Widevine key acquisition - Scraper system: forum threads, Coomer/Kemono API, and MediaLink (Fapello) scrapers - SQLite-backed media index for instant gallery loads with startup scan - Duplicate detection and gallery filtering/sorting - HLS video component, log viewer, and scrape management UI - Dockerfile updated for Python/pywidevine, docker-compose volume for CDM Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,8 @@ import UserPosts from './pages/UserPosts'
|
||||
import Downloads from './pages/Downloads'
|
||||
import Search from './pages/Search'
|
||||
import Gallery from './pages/Gallery'
|
||||
import Duplicates from './pages/Duplicates'
|
||||
import Scrape from './pages/Scrape'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/feed', label: 'Feed', icon: FeedIcon },
|
||||
@@ -15,6 +17,7 @@ const navItems = [
|
||||
{ to: '/search', label: 'Search', icon: SearchIcon },
|
||||
{ to: '/downloads', label: 'Downloads', icon: DownloadIcon },
|
||||
{ to: '/gallery', label: 'Gallery', icon: GalleryNavIcon },
|
||||
{ to: '/scrape', label: 'Scrape', icon: ScrapeIcon },
|
||||
{ to: '/', label: 'Settings', icon: SettingsIcon },
|
||||
]
|
||||
|
||||
@@ -111,6 +114,8 @@ export default function App() {
|
||||
<Route path="/search" element={<Search />} />
|
||||
<Route path="/downloads" element={<Downloads />} />
|
||||
<Route path="/gallery" element={<Gallery />} />
|
||||
<Route path="/duplicates" element={<Duplicates />} />
|
||||
<Route path="/scrape" element={<Scrape />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
@@ -160,6 +165,14 @@ function GalleryNavIcon({ className }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ScrapeIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
|
||||
@@ -114,3 +114,84 @@ export function getGalleryFiles({ folder, folders, type, sort, offset, limit } =
|
||||
const query = buildQuery({ folder, folders: folders ? folders.join(',') : undefined, type, sort, offset, limit });
|
||||
return request(`/api/gallery/files${query}`);
|
||||
}
|
||||
|
||||
export function rescanMedia() {
|
||||
return request('/api/gallery/rescan', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function getRescanStatus() {
|
||||
return request('/api/gallery/rescan/status');
|
||||
}
|
||||
|
||||
export function generateThumbs() {
|
||||
return request('/api/gallery/generate-thumbs', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function getThumbsStatus() {
|
||||
return request('/api/gallery/generate-thumbs/status');
|
||||
}
|
||||
|
||||
export function scanDuplicates() {
|
||||
return request('/api/gallery/scan-duplicates', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function getDuplicateScanStatus() {
|
||||
return request('/api/gallery/scan-duplicates/status');
|
||||
}
|
||||
|
||||
export function getDuplicateGroups(offset = 0, limit = 20) {
|
||||
const query = buildQuery({ offset, limit });
|
||||
return request(`/api/gallery/duplicates${query}`);
|
||||
}
|
||||
|
||||
export function cleanDuplicates() {
|
||||
return request('/api/gallery/duplicates/clean', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function deleteMediaFile(folder, filename) {
|
||||
return request(`/api/gallery/media/${encodeURIComponent(folder)}/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function startForumScrape(config) {
|
||||
return request('/api/scrape/forum', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
export function startCoomerScrape(config) {
|
||||
return request('/api/scrape/coomer', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
export function startMediaLinkScrape(config) {
|
||||
return request('/api/scrape/medialink', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
export function getScrapeJobs() {
|
||||
return request('/api/scrape/jobs');
|
||||
}
|
||||
|
||||
export function getScrapeJob(jobId) {
|
||||
return request(`/api/scrape/jobs/${jobId}`);
|
||||
}
|
||||
|
||||
export function cancelScrapeJob(jobId) {
|
||||
return request(`/api/scrape/jobs/${jobId}/cancel`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function detectForumPages(url) {
|
||||
return request('/api/scrape/forum/detect-pages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, forwardRef, useImperativeHandle } from 'react'
|
||||
import Hls from 'hls.js'
|
||||
|
||||
export default function HlsVideo({ hlsSrc, src, autoPlay, ...props }) {
|
||||
const HlsVideo = forwardRef(function HlsVideo({ hlsSrc, src, autoPlay, ...props }, ref) {
|
||||
const videoRef = useRef(null)
|
||||
const hlsRef = useRef(null)
|
||||
|
||||
// Expose the underlying <video> element via ref
|
||||
useImperativeHandle(ref, () => videoRef.current, [])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
@@ -68,4 +71,6 @@ export default function HlsVideo({ hlsSrc, src, autoPlay, ...props }) {
|
||||
}, [hlsSrc, src, autoPlay])
|
||||
|
||||
return <video ref={videoRef} autoPlay={autoPlay} {...props} />
|
||||
}
|
||||
})
|
||||
|
||||
export default HlsVideo
|
||||
|
||||
44
client/src/components/LogViewer.jsx
Normal file
44
client/src/components/LogViewer.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export default function LogViewer({ logs = [], expanded, onToggle }) {
|
||||
const bottomRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [logs.length, expanded])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2 text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path fillRule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Logs ({logs.length})
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-2 bg-[#0a0a0a] border border-[#222] rounded-lg p-3 max-h-64 overflow-y-auto font-mono text-xs leading-relaxed">
|
||||
{logs.length === 0 ? (
|
||||
<span className="text-gray-600">No logs yet...</span>
|
||||
) : (
|
||||
logs.map((line, i) => (
|
||||
<div key={i} className={`${line.includes('FAILED') || line.includes('Error') ? 'text-red-400' : line.includes('Downloaded') || line.includes('Done') ? 'text-green-400' : 'text-gray-400'}`}>
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { getDownloadHistory, getActiveDownloads, getUser } from '../api'
|
||||
import { getDownloadHistory, getActiveDownloads, getUser, getScrapeJobs } from '../api'
|
||||
import Spinner from '../components/Spinner'
|
||||
|
||||
export default function Downloads() {
|
||||
const [history, setHistory] = useState([])
|
||||
const [active, setActive] = useState([])
|
||||
const [scrapeJobs, setScrapeJobs] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const pollRef = useRef(null)
|
||||
@@ -23,9 +24,10 @@ export default function Downloads() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const [histData, activeData] = await Promise.all([
|
||||
const [histData, activeData, scrapeData] = await Promise.all([
|
||||
getDownloadHistory(),
|
||||
getActiveDownloads(),
|
||||
getScrapeJobs(),
|
||||
])
|
||||
|
||||
if (histData.error) {
|
||||
@@ -37,25 +39,31 @@ export default function Downloads() {
|
||||
const histList = Array.isArray(histData) ? histData : histData.list || []
|
||||
setHistory(histList)
|
||||
setActive(Array.isArray(activeData) ? activeData : [])
|
||||
setScrapeJobs(Array.isArray(scrapeData) ? scrapeData.filter(j => j.running) : [])
|
||||
setLoading(false)
|
||||
resolveUsernames(histList)
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
pollRef.current = setInterval(async () => {
|
||||
const activeData = await getActiveDownloads()
|
||||
if (activeData.error) return
|
||||
|
||||
const list = Array.isArray(activeData) ? activeData : []
|
||||
setActive((prev) => {
|
||||
// If something just finished, refresh history
|
||||
if (prev.length > 0 && list.length < prev.length) {
|
||||
getDownloadHistory().then((h) => {
|
||||
if (!h.error) setHistory(Array.isArray(h) ? h : h.list || [])
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
const [activeData, scrapeData] = await Promise.all([
|
||||
getActiveDownloads(),
|
||||
getScrapeJobs(),
|
||||
])
|
||||
if (!activeData.error) {
|
||||
const list = Array.isArray(activeData) ? activeData : []
|
||||
setActive((prev) => {
|
||||
if (prev.length > 0 && list.length < prev.length) {
|
||||
getDownloadHistory().then((h) => {
|
||||
if (!h.error) setHistory(Array.isArray(h) ? h : h.list || [])
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
}
|
||||
if (!scrapeData.error) {
|
||||
setScrapeJobs(Array.isArray(scrapeData) ? scrapeData.filter(j => j.running) : [])
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
@@ -159,6 +167,50 @@ export default function Downloads() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrape Jobs */}
|
||||
{scrapeJobs.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">
|
||||
Scrape Jobs
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{scrapeJobs.map((job) => {
|
||||
const progress = job.progress.total > 0
|
||||
? Math.round((job.progress.completed / job.progress.total) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div key={job.id} className="bg-[#161616] border border-[#222] rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${
|
||||
job.type === 'forum' ? 'bg-purple-500/10 text-purple-400' : 'bg-orange-500/10 text-orange-400'
|
||||
}`}>
|
||||
{job.type}
|
||||
</span>
|
||||
<p className="text-sm font-medium text-white">{job.folderName}</p>
|
||||
</div>
|
||||
<span className="text-xs text-[#0095f6] font-medium">{progress}%</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
{job.progress.completed} / {job.progress.total} {job.type === 'forum' ? 'pages' : 'files'}
|
||||
{job.progress.errors > 0 && (
|
||||
<span className="text-red-400 ml-2">({job.progress.errors} errors)</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="w-full bg-[#1a1a1a] rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-[#0095f6] h-1.5 rounded-full transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download History */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">
|
||||
|
||||
257
client/src/pages/Duplicates.jsx
Normal file
257
client/src/pages/Duplicates.jsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { getDuplicateGroups, getDuplicateScanStatus, deleteMediaFile, cleanDuplicates } from '../api'
|
||||
|
||||
export default function Duplicates() {
|
||||
const [groups, setGroups] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [deleting, setDeleting] = useState(null)
|
||||
const [cleaning, setCleaning] = useState(false)
|
||||
const [cleanResult, setCleanResult] = useState(null)
|
||||
const [scanStatus, setScanStatus] = useState(null)
|
||||
const pollRef = useRef(null)
|
||||
const LIMIT = 20
|
||||
|
||||
const fetchGroups = async (off = 0) => {
|
||||
setLoading(true)
|
||||
const data = await getDuplicateGroups(off, LIMIT)
|
||||
if (!data.error) {
|
||||
setGroups(data.groups || [])
|
||||
setTotal(data.total || 0)
|
||||
setOffset(off)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Check if scan is still running
|
||||
getDuplicateScanStatus().then((s) => {
|
||||
if (!s.error) setScanStatus(s)
|
||||
if (s.running) {
|
||||
pollRef.current = setInterval(async () => {
|
||||
const st = await getDuplicateScanStatus()
|
||||
if (!st.error) setScanStatus(st)
|
||||
if (!st.running) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
fetchGroups(0)
|
||||
}
|
||||
}, 1000)
|
||||
} else {
|
||||
fetchGroups(0)
|
||||
}
|
||||
})
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (folder, filename, groupIdx) => {
|
||||
const key = `${folder}/${filename}`
|
||||
setDeleting(key)
|
||||
const result = await deleteMediaFile(folder, filename)
|
||||
setDeleting(null)
|
||||
if (result.error) return
|
||||
|
||||
// Update local state — remove file from group, remove group if < 2
|
||||
setGroups((prev) => {
|
||||
const updated = prev.map((group, i) => {
|
||||
if (i !== groupIdx) return group
|
||||
return group.filter((f) => !(f.folder === folder && f.filename === filename))
|
||||
}).filter((g) => g.length > 1)
|
||||
setTotal((t) => t - (prev.length - updated.length))
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
const totalSaved = groups.reduce((sum, group) => {
|
||||
const sizes = group.map((f) => f.size).sort((a, b) => b - a)
|
||||
return sum + sizes.slice(1).reduce((s, sz) => s + sz, 0)
|
||||
}, 0)
|
||||
|
||||
if (scanStatus?.running) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-white mb-6">Duplicate Files</h1>
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-6">
|
||||
<p className="text-sm text-gray-300 mb-3">Scanning for duplicates...</p>
|
||||
<div className="flex items-center justify-between text-xs text-gray-400 mb-1.5">
|
||||
<span>{scanStatus.done} of {scanStatus.total} files checked</span>
|
||||
<span>{scanStatus.groups} groups found</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-[#222] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#0095f6] rounded-full transition-all duration-300"
|
||||
style={{ width: `${(scanStatus.done / Math.max(scanStatus.total, 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Duplicate Files</h1>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{total} duplicate group{total !== 1 ? 's' : ''} found
|
||||
{totalSaved > 0 && (
|
||||
<span className="text-gray-500"> · {(totalSaved / (1024 * 1024)).toFixed(1)} MB reclaimable</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{total > 0 && !cleaning && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const dupeCount = groups.reduce((sum, g) => sum + g.length - 1, 0)
|
||||
if (!confirm(`Delete ${total > LIMIT ? 'all' : dupeCount} duplicate files across ${total} groups?\n\nOne copy of each file will be kept.`)) return
|
||||
setCleaning(true)
|
||||
setCleanResult(null)
|
||||
const result = await cleanDuplicates()
|
||||
setCleaning(false)
|
||||
if (result.error) return
|
||||
setCleanResult(result)
|
||||
setGroups([])
|
||||
setTotal(0)
|
||||
}}
|
||||
className="px-4 py-2 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Delete All Duplicates
|
||||
</button>
|
||||
)}
|
||||
{cleaning && (
|
||||
<span className="text-sm text-gray-400 flex items-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4 text-red-400" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Deleting...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{total > LIMIT && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => fetchGroups(Math.max(0, offset - LIMIT))}
|
||||
disabled={offset === 0 || loading}
|
||||
className="px-3 py-1.5 bg-[#222] hover:bg-[#333] disabled:opacity-30 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-xs text-gray-400">
|
||||
{offset + 1}–{Math.min(offset + LIMIT, total)} of {total}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => fetchGroups(offset + LIMIT)}
|
||||
disabled={offset + LIMIT >= total || loading}
|
||||
className="px-3 py-1.5 bg-[#222] hover:bg-[#333] disabled:opacity-30 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<svg className="animate-spin h-6 w-6 text-[#0095f6]" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cleanResult && (
|
||||
<div className="bg-green-500/10 border border-green-500/30 rounded-lg px-4 py-3 mb-4">
|
||||
<p className="text-sm text-green-400">
|
||||
Deleted {cleanResult.deleted} duplicate files, freed {(cleanResult.freed / (1024 * 1024)).toFixed(1)} MB
|
||||
{cleanResult.errors > 0 && <span className="text-yellow-400"> ({cleanResult.errors} errors)</span>}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && groups.length === 0 && !cleanResult && (
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-12 text-center">
|
||||
<p className="text-gray-400">No duplicates found. Run a scan from Settings first.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && groups.map((group, groupIdx) => (
|
||||
<div key={groupIdx} className="bg-[#161616] border border-[#222] rounded-lg mb-4 overflow-hidden">
|
||||
<div className="px-4 py-2.5 border-b border-[#222] flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">
|
||||
{group.length} copies · {(group[0].size / (1024 * 1024)).toFixed(1)} MB each
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{group[0].type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 p-3" style={{ gridTemplateColumns: `repeat(${Math.min(group.length, 4)}, 1fr)` }}>
|
||||
{group.map((file) => {
|
||||
const key = `${file.folder}/${file.filename}`
|
||||
return (
|
||||
<div key={key} className="bg-[#111] rounded-lg overflow-hidden border border-[#1a1a1a]">
|
||||
<div className="aspect-video bg-black flex items-center justify-center overflow-hidden">
|
||||
{file.type === 'image' ? (
|
||||
<img src={file.url} alt={file.filename} className="w-full h-full object-contain" loading="lazy" />
|
||||
) : file.thumbUrl ? (
|
||||
<img src={file.thumbUrl} alt={file.filename} className="w-full h-full object-contain" loading="lazy" />
|
||||
) : (
|
||||
<div className="text-gray-600 text-xs">No preview</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2.5 space-y-1">
|
||||
<p className="text-xs text-[#0095f6] font-medium">@{file.folder}</p>
|
||||
<p className="text-[11px] text-gray-400 font-mono break-all">{file.path}</p>
|
||||
<div className="flex items-center justify-between text-[11px] text-gray-500">
|
||||
<span>{(file.size / (1024 * 1024)).toFixed(1)} MB</span>
|
||||
<span>{new Date(file.modified).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={deleting === key}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete ${file.filename} from @${file.folder}?`)) {
|
||||
handleDelete(file.folder, file.filename, groupIdx)
|
||||
}
|
||||
}}
|
||||
className="w-full mt-1.5 py-1.5 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 text-xs font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deleting === key ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Bottom pagination */}
|
||||
{!loading && total > LIMIT && (
|
||||
<div className="flex items-center justify-center gap-2 py-4">
|
||||
<button
|
||||
onClick={() => { fetchGroups(Math.max(0, offset - LIMIT)); window.scrollTo(0, 0) }}
|
||||
disabled={offset === 0}
|
||||
className="px-3 py-1.5 bg-[#222] hover:bg-[#333] disabled:opacity-30 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-xs text-gray-400">
|
||||
{offset + 1}–{Math.min(offset + LIMIT, total)} of {total}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { fetchGroups(offset + LIMIT); window.scrollTo(0, 0) }}
|
||||
disabled={offset + LIMIT >= total}
|
||||
className="px-3 py-1.5 bg-[#222] hover:bg-[#333] disabled:opacity-30 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,56 @@ import HlsVideo from '../components/HlsVideo'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
function GalleryThumbnail({ file }) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [errored, setErrored] = useState(false)
|
||||
const [retries, setRetries] = useState(0)
|
||||
|
||||
const imgSrc = file.type === 'video'
|
||||
? `/api/gallery/thumb/${encodeURIComponent(file.folder)}/${encodeURIComponent(file.filename)}`
|
||||
: file.url
|
||||
|
||||
// Images — lazy load with retry
|
||||
const handleError = () => {
|
||||
if (retries < 2) {
|
||||
setTimeout(() => setRetries(r => r + 1), 1000 + retries * 1500)
|
||||
} else {
|
||||
setErrored(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!loaded && !errored && (
|
||||
<div className="absolute inset-0 bg-[#1a1a1a] animate-pulse" />
|
||||
)}
|
||||
{errored ? (
|
||||
<div className="w-full h-full bg-[#1a1a1a] flex items-center justify-center">
|
||||
{file.type === 'video' ? (
|
||||
<svg className="w-10 h-10 text-white/20" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M18 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
key={retries}
|
||||
src={imgSrc}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={handleError}
|
||||
className={`w-full h-full object-cover transition-opacity duration-300 ${loaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function formatShortDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
@@ -307,22 +357,7 @@ export default function Gallery() {
|
||||
className="relative group bg-[#161616] rounded-lg overflow-hidden cursor-pointer aspect-square"
|
||||
onClick={() => setLightbox(file)}
|
||||
>
|
||||
{file.type === 'video' ? (
|
||||
<video
|
||||
src={`${file.url}#t=0.5`}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsInline
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={file.url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<GalleryThumbnail file={file} />
|
||||
|
||||
{/* Date badge */}
|
||||
{file.postedAt && (
|
||||
@@ -371,6 +406,8 @@ export default function Gallery() {
|
||||
{slideshow && (
|
||||
<Slideshow
|
||||
filterParams={getFilterParams()}
|
||||
typeFilter={typeFilter}
|
||||
hlsEnabled={hlsEnabled}
|
||||
onClose={() => setSlideshow(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -423,68 +460,96 @@ function Lightbox({ file, hlsEnabled, onClose }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Slideshow({ filterParams, onClose }) {
|
||||
const [current, setCurrent] = useState(null)
|
||||
const [images, setImages] = useState([])
|
||||
function Slideshow({ filterParams, typeFilter, hlsEnabled, onClose }) {
|
||||
const [items, setItems] = useState([])
|
||||
const [index, setIndex] = useState(0)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [fadeKey, setFadeKey] = useState(0)
|
||||
const videoRef = useRef(null)
|
||||
const timerRef = useRef(null)
|
||||
|
||||
// Load a large shuffled batch of images
|
||||
useEffect(() => {
|
||||
getGalleryFiles({ ...filterParams, type: 'image', sort: 'shuffle', limit: 500 }).then((data) => {
|
||||
if (!data.error && data.files.length > 0) {
|
||||
setImages(data.files)
|
||||
setCurrent(data.files[0])
|
||||
const current = items[index] || null
|
||||
const isVideo = current?.type === 'video'
|
||||
|
||||
// Load shuffled batch respecting type filter
|
||||
const loadBatch = useCallback(async () => {
|
||||
const params = {
|
||||
...filterParams,
|
||||
sort: 'shuffle',
|
||||
limit: 500,
|
||||
}
|
||||
if (typeFilter === 'image') params.type = 'image'
|
||||
else if (typeFilter === 'video') params.type = 'video'
|
||||
// 'all' — no type filter
|
||||
|
||||
const data = await getGalleryFiles(params)
|
||||
if (!data.error && data.files.length > 0) {
|
||||
setItems(data.files)
|
||||
setIndex(0)
|
||||
setFadeKey((k) => k + 1)
|
||||
}
|
||||
}, [filterParams, typeFilter])
|
||||
|
||||
useEffect(() => { loadBatch() }, [])
|
||||
|
||||
// Advance to next item (or reload batch if at end)
|
||||
const advance = useCallback(() => {
|
||||
setIndex((prev) => {
|
||||
const next = prev + 1
|
||||
if (next >= items.length) {
|
||||
loadBatch()
|
||||
return prev
|
||||
}
|
||||
setFadeKey((k) => k + 1)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
}, [items.length, loadBatch])
|
||||
|
||||
// Auto-advance every 5 seconds
|
||||
// For images (and images in 'all' mode): auto-advance after 5s
|
||||
useEffect(() => {
|
||||
if (images.length === 0 || paused) return
|
||||
const timer = setInterval(() => {
|
||||
setIndex((prev) => {
|
||||
const next = prev + 1
|
||||
if (next >= images.length) {
|
||||
getGalleryFiles({ ...filterParams, type: 'image', sort: 'shuffle', limit: 500 }).then((data) => {
|
||||
if (!data.error && data.files.length > 0) {
|
||||
setImages(data.files)
|
||||
setCurrent(data.files[0])
|
||||
setIndex(0)
|
||||
}
|
||||
})
|
||||
return prev
|
||||
}
|
||||
setCurrent(images[next])
|
||||
return next
|
||||
})
|
||||
}, 5000)
|
||||
return () => clearInterval(timer)
|
||||
}, [images, paused])
|
||||
if (items.length === 0 || paused) return
|
||||
if (isVideo) return // videos advance on ended event
|
||||
timerRef.current = setTimeout(advance, 5000)
|
||||
return () => clearTimeout(timerRef.current)
|
||||
}, [index, items.length, paused, isVideo, advance])
|
||||
|
||||
// When a video ends, advance
|
||||
const handleVideoEnded = useCallback(() => {
|
||||
if (!paused) advance()
|
||||
}, [paused, advance])
|
||||
|
||||
// Keyboard controls
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === ' ') { e.preventDefault(); setPaused((p) => !p) }
|
||||
if (e.key === 'ArrowRight' && images.length > 0) {
|
||||
setIndex((prev) => {
|
||||
const next = Math.min(prev + 1, images.length - 1)
|
||||
setCurrent(images[next])
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setPaused((p) => {
|
||||
const next = !p
|
||||
// If unpausing a video, resume playback
|
||||
if (!next && videoRef.current && videoRef.current.paused) {
|
||||
videoRef.current.play()
|
||||
} else if (next && videoRef.current && !videoRef.current.paused) {
|
||||
videoRef.current.pause()
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
if (e.key === 'ArrowLeft' && images.length > 0) {
|
||||
if (e.key === 'ArrowRight' && items.length > 0) {
|
||||
clearTimeout(timerRef.current)
|
||||
advance()
|
||||
}
|
||||
if (e.key === 'ArrowLeft' && items.length > 0) {
|
||||
setIndex((prev) => {
|
||||
const next = Math.max(prev - 1, 0)
|
||||
setCurrent(images[next])
|
||||
setFadeKey((k) => k + 1)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKey)
|
||||
return () => window.removeEventListener('keydown', handleKey)
|
||||
}, [onClose, images])
|
||||
}, [onClose, items, advance])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] bg-black flex items-center justify-center">
|
||||
@@ -504,19 +569,33 @@ function Slideshow({ filterParams, onClose }) {
|
||||
)}
|
||||
|
||||
{current ? (
|
||||
<img
|
||||
key={current.url}
|
||||
src={current.url}
|
||||
alt=""
|
||||
className="max-w-full max-h-full object-contain animate-fadeIn"
|
||||
/>
|
||||
isVideo ? (
|
||||
<HlsVideo
|
||||
key={fadeKey}
|
||||
ref={videoRef}
|
||||
hlsSrc={hlsEnabled ? `/api/hls/${encodeURIComponent(current.folder)}/${encodeURIComponent(current.filename)}/master.m3u8` : null}
|
||||
src={current.url}
|
||||
controls
|
||||
autoPlay
|
||||
onEnded={handleVideoEnded}
|
||||
className="max-w-full max-h-full object-contain animate-fadeIn"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={fadeKey}
|
||||
src={current.url}
|
||||
alt=""
|
||||
className="max-w-full max-h-full object-contain animate-fadeIn"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
|
||||
{current && (
|
||||
<div className="absolute bottom-4 left-0 right-0 text-center text-white/40 text-sm">
|
||||
@{current.folder} · {index + 1} / {images.length}
|
||||
@{current.folder} · {index + 1} / {items.length}
|
||||
{isVideo && ' (video)'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { saveAuth, getAuth, getMe, getSettings, updateSettings } from '../api'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { saveAuth, getAuth, getMe, getSettings, updateSettings, generateThumbs, getThumbsStatus, scanDuplicates, getDuplicateScanStatus, rescanMedia, getRescanStatus } from '../api'
|
||||
import Spinner from '../components/Spinner'
|
||||
|
||||
const fields = [
|
||||
@@ -54,6 +55,13 @@ export default function Login({ onAuth }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [initialLoading, setInitialLoading] = useState(true)
|
||||
const [hlsEnabled, setHlsEnabled] = useState(false)
|
||||
const [thumbGen, setThumbGen] = useState(null) // { running, total, done, errors }
|
||||
const thumbPollRef = useRef(null)
|
||||
const [dupScan, setDupScan] = useState(null) // { running, total, done, groups }
|
||||
const dupPollRef = useRef(null)
|
||||
const [rescan, setRescan] = useState(null) // { running, fileCount, lastRun }
|
||||
const rescanPollRef = useRef(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
getAuth().then((data) => {
|
||||
@@ -76,6 +84,85 @@ export default function Login({ onAuth }) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const startThumbGen = async () => {
|
||||
const result = await generateThumbs()
|
||||
if (result.error) return
|
||||
setThumbGen({ running: true, total: result.total || 0, done: 0, errors: 0 })
|
||||
|
||||
if (result.status === 'done') {
|
||||
setThumbGen({ running: false, total: 0, done: 0, errors: 0, message: result.message })
|
||||
return
|
||||
}
|
||||
|
||||
// Poll for progress
|
||||
thumbPollRef.current = setInterval(async () => {
|
||||
const s = await getThumbsStatus()
|
||||
if (s.error) return
|
||||
setThumbGen(s)
|
||||
if (!s.running) {
|
||||
clearInterval(thumbPollRef.current)
|
||||
thumbPollRef.current = null
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const startDupScan = async () => {
|
||||
const result = await scanDuplicates()
|
||||
if (result.error) return
|
||||
if (result.status === 'already_running') {
|
||||
navigate('/duplicates')
|
||||
return
|
||||
}
|
||||
setDupScan({ running: true, total: result.total || 0, done: 0, groups: 0 })
|
||||
|
||||
dupPollRef.current = setInterval(async () => {
|
||||
const s = await getDuplicateScanStatus()
|
||||
if (s.error) return
|
||||
setDupScan(s)
|
||||
if (!s.running) {
|
||||
clearInterval(dupPollRef.current)
|
||||
dupPollRef.current = null
|
||||
setDupScan(s)
|
||||
if (s.groups > 0) navigate('/duplicates')
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const startRescan = async () => {
|
||||
const result = await rescanMedia()
|
||||
if (result.error) return
|
||||
if (result.status === 'already_running') {
|
||||
setRescan({ running: true, fileCount: result.fileCount || 0 })
|
||||
return
|
||||
}
|
||||
setRescan({ running: true, fileCount: 0 })
|
||||
|
||||
rescanPollRef.current = setInterval(async () => {
|
||||
const s = await getRescanStatus()
|
||||
if (s.error) return
|
||||
setRescan(s)
|
||||
if (!s.running) {
|
||||
clearInterval(rescanPollRef.current)
|
||||
rescanPollRef.current = null
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// Load initial rescan status
|
||||
useEffect(() => {
|
||||
getRescanStatus().then((s) => {
|
||||
if (!s.error && s.lastRun) setRescan(s)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (thumbPollRef.current) clearInterval(thumbPollRef.current)
|
||||
if (dupPollRef.current) clearInterval(dupPollRef.current)
|
||||
if (rescanPollRef.current) clearInterval(rescanPollRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
setStatus(null)
|
||||
@@ -247,31 +334,151 @@ export default function Login({ onAuth }) {
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-300">HLS Video Streaming</p>
|
||||
<p className="text-sm font-medium text-gray-300">Rescan Media Library</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Videos start playing instantly via segmented streaming instead of downloading the full file first
|
||||
Re-index the media folder to pick up manually added or removed files.
|
||||
{rescan?.fileCount > 0 && !rescan.running && (
|
||||
<span className="text-gray-400"> Currently {rescan.fileCount.toLocaleString()} files indexed.</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={hlsEnabled}
|
||||
onClick={async () => {
|
||||
const next = !hlsEnabled
|
||||
setHlsEnabled(next)
|
||||
await updateSettings({ hls_enabled: String(next) })
|
||||
}}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${
|
||||
hlsEnabled ? 'bg-[#0095f6]' : 'bg-[#333]'
|
||||
}`}
|
||||
disabled={rescan?.running}
|
||||
onClick={startRescan}
|
||||
className="px-4 py-2 bg-[#0095f6] hover:bg-[#0081d6] disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors flex-shrink-0"
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
hlsEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
{rescan?.running ? 'Scanning...' : 'Rescan'}
|
||||
</button>
|
||||
</div>
|
||||
{rescan?.running && (
|
||||
<div className="mt-3">
|
||||
<div className="w-full h-1.5 bg-[#222] rounded-full overflow-hidden">
|
||||
<div className="h-full bg-[#0095f6] rounded-full animate-pulse w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{rescan && !rescan.running && rescan.lastRun && (
|
||||
<p className="text-xs text-green-400 mt-2">
|
||||
Last scan: {rescan.fileCount.toLocaleString()} files indexed
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="border-t border-[#222] mt-5 pt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-300">HLS Video Streaming</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Videos start playing instantly via segmented streaming instead of downloading the full file first
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={hlsEnabled}
|
||||
onClick={async () => {
|
||||
const next = !hlsEnabled
|
||||
setHlsEnabled(next)
|
||||
await updateSettings({ hls_enabled: String(next) })
|
||||
}}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${
|
||||
hlsEnabled ? 'bg-[#0095f6]' : 'bg-[#333]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
hlsEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#222] mt-5 pt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-300">Video Thumbnails</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Generate preview thumbnails for all downloaded videos. New videos get thumbnails automatically when browsed.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={thumbGen?.running}
|
||||
onClick={startThumbGen}
|
||||
className="px-4 py-2 bg-[#0095f6] hover:bg-[#0081d6] disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors flex-shrink-0"
|
||||
>
|
||||
{thumbGen?.running ? 'Generating...' : 'Generate'}
|
||||
</button>
|
||||
</div>
|
||||
{thumbGen?.running && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between text-xs text-gray-400 mb-1.5">
|
||||
<span>{thumbGen.done} of {thumbGen.total} videos</span>
|
||||
<span>{Math.round((thumbGen.done / Math.max(thumbGen.total, 1)) * 100)}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-[#222] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#0095f6] rounded-full transition-all duration-300"
|
||||
style={{ width: `${(thumbGen.done / Math.max(thumbGen.total, 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{thumbGen.errors > 0 && (
|
||||
<p className="text-xs text-yellow-500 mt-1">{thumbGen.errors} failed</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{thumbGen && !thumbGen.running && thumbGen.message && (
|
||||
<p className="text-xs text-green-400 mt-2">{thumbGen.message}</p>
|
||||
)}
|
||||
{thumbGen && !thumbGen.running && !thumbGen.message && thumbGen.done > 0 && (
|
||||
<p className="text-xs text-green-400 mt-2">Done! Generated {thumbGen.done} thumbnails{thumbGen.errors > 0 ? `, ${thumbGen.errors} failed` : ''}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-[#222] mt-5 pt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-300">Duplicate Files</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Scan downloaded media for duplicate files and review them side by side.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={dupScan?.running}
|
||||
onClick={startDupScan}
|
||||
className="px-4 py-2 bg-[#0095f6] hover:bg-[#0081d6] disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors flex-shrink-0"
|
||||
>
|
||||
{dupScan?.running ? 'Scanning...' : 'Find Duplicates'}
|
||||
</button>
|
||||
</div>
|
||||
{dupScan?.running && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between text-xs text-gray-400 mb-1.5">
|
||||
<span>{dupScan.done} of {dupScan.total} files checked</span>
|
||||
<span>{dupScan.groups} groups found</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-[#222] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#0095f6] rounded-full transition-all duration-300"
|
||||
style={{ width: `${(dupScan.done / Math.max(dupScan.total, 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dupScan && !dupScan.running && dupScan.groups === 0 && dupScan.total > 0 && (
|
||||
<p className="text-xs text-green-400 mt-2">No duplicates found!</p>
|
||||
)}
|
||||
{dupScan && !dupScan.running && dupScan.groups > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/duplicates')}
|
||||
className="text-xs text-[#0095f6] hover:underline mt-2"
|
||||
>
|
||||
View {dupScan.groups} duplicate group{dupScan.groups !== 1 ? 's' : ''}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
612
client/src/pages/Scrape.jsx
Normal file
612
client/src/pages/Scrape.jsx
Normal file
@@ -0,0 +1,612 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import {
|
||||
startForumScrape,
|
||||
startCoomerScrape,
|
||||
startMediaLinkScrape,
|
||||
getScrapeJobs,
|
||||
getScrapeJob,
|
||||
cancelScrapeJob,
|
||||
detectForumPages,
|
||||
} from '../api'
|
||||
import LogViewer from '../components/LogViewer'
|
||||
|
||||
export default function Scrape() {
|
||||
const [tab, setTab] = useState('forum')
|
||||
const [jobs, setJobs] = useState([])
|
||||
const [expandedLogs, setExpandedLogs] = useState({})
|
||||
const [jobLogs, setJobLogs] = useState({})
|
||||
const pollRef = useRef(null)
|
||||
|
||||
// Forum form
|
||||
const [forumUrl, setForumUrl] = useState('')
|
||||
const [forumFolder, setForumFolder] = useState('')
|
||||
const [forumAutoDetect, setForumAutoDetect] = useState(true)
|
||||
const [forumStart, setForumStart] = useState(1)
|
||||
const [forumEnd, setForumEnd] = useState(10)
|
||||
const [forumDelay, setForumDelay] = useState(1.0)
|
||||
const [forumDetecting, setForumDetecting] = useState(false)
|
||||
const [forumSubmitting, setForumSubmitting] = useState(false)
|
||||
|
||||
// Coomer form
|
||||
const [coomerUrl, setCoomerUrl] = useState('')
|
||||
const [coomerFolder, setCoomerFolder] = useState('')
|
||||
const [coomerPages, setCoomerPages] = useState(10)
|
||||
const [coomerWorkers, setCoomerWorkers] = useState(10)
|
||||
const [coomerSubmitting, setCoomerSubmitting] = useState(false)
|
||||
|
||||
// MediaLink form
|
||||
const [mlUrl, setMlUrl] = useState('')
|
||||
const [mlFolder, setMlFolder] = useState('')
|
||||
const [mlPages, setMlPages] = useState(50)
|
||||
const [mlWorkers, setMlWorkers] = useState(3)
|
||||
const [mlDelay, setMlDelay] = useState(500)
|
||||
const [mlSubmitting, setMlSubmitting] = useState(false)
|
||||
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs()
|
||||
pollRef.current = setInterval(fetchJobs, 2000)
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
||||
}, [])
|
||||
|
||||
const fetchJobs = async () => {
|
||||
const data = await getScrapeJobs()
|
||||
if (!data.error) setJobs(Array.isArray(data) ? data : [])
|
||||
}
|
||||
|
||||
const toggleLogs = async (jobId) => {
|
||||
const isExpanded = expandedLogs[jobId]
|
||||
setExpandedLogs(prev => ({ ...prev, [jobId]: !isExpanded }))
|
||||
if (!isExpanded) {
|
||||
const data = await getScrapeJob(jobId)
|
||||
if (!data.error && data.logs) {
|
||||
setJobLogs(prev => ({ ...prev, [jobId]: data.logs }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh logs for expanded jobs
|
||||
useEffect(() => {
|
||||
const expanded = Object.entries(expandedLogs).filter(([, v]) => v).map(([k]) => k)
|
||||
if (expanded.length === 0) return
|
||||
|
||||
const refresh = async () => {
|
||||
for (const jobId of expanded) {
|
||||
const data = await getScrapeJob(jobId)
|
||||
if (!data.error && data.logs) {
|
||||
setJobLogs(prev => ({ ...prev, [jobId]: data.logs }))
|
||||
}
|
||||
}
|
||||
}
|
||||
const interval = setInterval(refresh, 2000)
|
||||
return () => clearInterval(interval)
|
||||
}, [expandedLogs])
|
||||
|
||||
// Auto-derive folder name from URL
|
||||
const deriveForumFolder = (url) => {
|
||||
if (!url) return ''
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
// Try to get thread title from path
|
||||
const parts = parsed.pathname.split('/').filter(Boolean)
|
||||
const thread = parts.find(p => p.includes('.') && !p.startsWith('page-'))
|
||||
if (thread) return thread.replace(/\.\d+$/, '').replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
return parsed.hostname.replace(/\./g, '_')
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
const deriveCoomerFolder = (url) => {
|
||||
if (!url) return ''
|
||||
try {
|
||||
const m = new URL(url).pathname.match(/\/([^/]+)\/user\/([^/?#]+)/)
|
||||
if (m) return `${m[1]}_${m[2]}`
|
||||
return ''
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
const handleForumUrlChange = async (url) => {
|
||||
setForumUrl(url)
|
||||
if (!forumFolder || forumFolder === deriveForumFolder(forumUrl)) {
|
||||
setForumFolder(deriveForumFolder(url))
|
||||
}
|
||||
|
||||
if (forumAutoDetect && url.length > 10) {
|
||||
setForumDetecting(true)
|
||||
const data = await detectForumPages(url)
|
||||
setForumDetecting(false)
|
||||
if (!data.error && data.maxPage) {
|
||||
setForumEnd(data.maxPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCoomerUrlChange = (url) => {
|
||||
setCoomerUrl(url)
|
||||
if (!coomerFolder || coomerFolder === deriveCoomerFolder(coomerUrl)) {
|
||||
setCoomerFolder(deriveCoomerFolder(url))
|
||||
}
|
||||
}
|
||||
|
||||
const submitForum = async () => {
|
||||
if (!forumUrl || !forumFolder) return
|
||||
setError(null)
|
||||
setForumSubmitting(true)
|
||||
const data = await startForumScrape({
|
||||
url: forumUrl,
|
||||
folderName: forumFolder,
|
||||
startPage: forumStart,
|
||||
endPage: forumEnd,
|
||||
delay: forumDelay,
|
||||
})
|
||||
setForumSubmitting(false)
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
} else {
|
||||
fetchJobs()
|
||||
}
|
||||
}
|
||||
|
||||
const submitCoomer = async () => {
|
||||
if (!coomerUrl || !coomerFolder) return
|
||||
setError(null)
|
||||
setCoomerSubmitting(true)
|
||||
const data = await startCoomerScrape({
|
||||
url: coomerUrl,
|
||||
folderName: coomerFolder,
|
||||
pages: coomerPages,
|
||||
workers: coomerWorkers,
|
||||
})
|
||||
setCoomerSubmitting(false)
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
} else {
|
||||
fetchJobs()
|
||||
}
|
||||
}
|
||||
|
||||
const deriveMlFolder = (url) => {
|
||||
if (!url) return ''
|
||||
try {
|
||||
const m = new URL(url).pathname.match(/\/(?:model|media)\/(\d+)/)
|
||||
if (m) return `medialink_${m[1]}`
|
||||
return ''
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
const handleMlUrlChange = (url) => {
|
||||
setMlUrl(url)
|
||||
if (!mlFolder || mlFolder === deriveMlFolder(mlUrl)) {
|
||||
setMlFolder(deriveMlFolder(url))
|
||||
}
|
||||
}
|
||||
|
||||
const submitMediaLink = async () => {
|
||||
if (!mlUrl || !mlFolder) return
|
||||
setError(null)
|
||||
setMlSubmitting(true)
|
||||
const data = await startMediaLinkScrape({
|
||||
url: mlUrl,
|
||||
folderName: mlFolder,
|
||||
pages: mlPages,
|
||||
workers: mlWorkers,
|
||||
delay: mlDelay,
|
||||
})
|
||||
setMlSubmitting(false)
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
} else {
|
||||
fetchJobs()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = async (jobId) => {
|
||||
await cancelScrapeJob(jobId)
|
||||
fetchJobs()
|
||||
}
|
||||
|
||||
const activeJobs = jobs.filter(j => j.running)
|
||||
const completedJobs = jobs.filter(j => !j.running)
|
||||
|
||||
const formatDuration = (start, end) => {
|
||||
if (!start || !end) return '--'
|
||||
const ms = new Date(end) - new Date(start)
|
||||
const secs = Math.floor(ms / 1000)
|
||||
if (secs < 60) return `${secs}s`
|
||||
const mins = Math.floor(secs / 60)
|
||||
const remSecs = secs % 60
|
||||
return `${mins}m ${remSecs}s`
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white mb-1">Scrape</h1>
|
||||
<p className="text-gray-500 text-sm">Download images from forums, Coomer/Kemono, and gallery sites</p>
|
||||
</div>
|
||||
|
||||
{/* Tab Selector */}
|
||||
<div className="flex gap-1 mb-6 bg-[#161616] p-1 rounded-lg w-fit">
|
||||
<button
|
||||
onClick={() => setTab('forum')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
tab === 'forum'
|
||||
? 'bg-[#0095f6] text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Forums
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('coomer')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
tab === 'coomer'
|
||||
? 'bg-[#0095f6] text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Coomer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('medialink')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
tab === 'medialink'
|
||||
? 'bg-[#0095f6] text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
MediaLink
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forum Form */}
|
||||
{tab === 'forum' && (
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-5 mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-4">Forum Scraper</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1.5">Thread URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={forumUrl}
|
||||
onChange={(e) => handleForumUrlChange(e.target.value)}
|
||||
placeholder="https://forum.example.com/threads/thread-name.12345/page-1"
|
||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1.5">Folder Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={forumFolder}
|
||||
onChange={(e) => setForumFolder(e.target.value)}
|
||||
placeholder="thread-name"
|
||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1.5">Start Page</label>
|
||||
<input
|
||||
type="number"
|
||||
value={forumStart}
|
||||
onChange={(e) => setForumStart(parseInt(e.target.value) || 1)}
|
||||
min={1}
|
||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1.5">
|
||||
End Page
|
||||
{forumDetecting && <span className="text-[#0095f6] ml-2">detecting...</span>}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={forumEnd}
|
||||
onChange={(e) => setForumEnd(parseInt(e.target.value) || 10)}
|
||||
min={1}
|
||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1.5">Delay (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={forumDelay}
|
||||
onChange={(e) => setForumDelay(parseFloat(e.target.value) || 1.0)}
|
||||
min={0.5}
|
||||
max={5}
|
||||
step={0.5}
|
||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoDetect"
|
||||
checked={forumAutoDetect}
|
||||
onChange={(e) => setForumAutoDetect(e.target.checked)}
|
||||
className="rounded bg-[#0a0a0a] border-[#333] text-[#0095f6] focus:ring-[#0095f6]"
|
||||
/>
|
||||
<label htmlFor="autoDetect" className="text-xs text-gray-400">
|
||||
Auto-detect page count from URL
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={submitForum}
|
||||
disabled={!forumUrl || !forumFolder || forumSubmitting}
|
||||
className="w-full bg-[#0095f6] hover:bg-[#0084db] disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium py-2.5 rounded-lg transition-colors"
|
||||
>
|
||||
{forumSubmitting ? 'Starting...' : 'Start Forum Scrape'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coomer Form */}
|
||||
{tab === 'coomer' && (
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-5 mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-4">Coomer / Kemono Scraper</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1.5">User URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={coomerUrl}
|
||||
onChange={(e) => handleCoomerUrlChange(e.target.value)}
|
||||
placeholder="https://coomer.su/onlyfans/user/username"
|
||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1.5">Folder Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={coomerFolder}
|
||||
onChange={(e) => setCoomerFolder(e.target.value)}
|
||||
placeholder="service_username"
|
||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1.5">Pages (50 posts each)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={coomerPages}
|
||||
onChange={(e) => setCoomerPages(parseInt(e.target.value) || 10)}
|
||||
min={1}
|
||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1.5">Workers (1-20)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={coomerWorkers}
|
||||
onChange={(e) => setCoomerWorkers(Math.min(20, Math.max(1, parseInt(e.target.value) || 10)))}
|
||||
min={1}
|
||||
max={20}
|
||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={submitCoomer}
|
||||
disabled={!coomerUrl || !coomerFolder || coomerSubmitting}
|
||||
className="w-full bg-[#0095f6] hover:bg-[#0084db] disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium py-2.5 rounded-lg transition-colors"
|
||||
>
|
||||
{coomerSubmitting ? 'Starting...' : 'Start Coomer Scrape'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MediaLink Form */}
|
||||
{tab === 'medialink' && (
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-5 mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-4">MediaLink Scraper</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">Scrapes gallery sites that use detail pages with full-size images. Follows media links and downloads from detail pages.</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1.5">Gallery URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mlUrl}
|
||||
onChange={(e) => handleMlUrlChange(e.target.value)}
|
||||
placeholder="https://fapello.to/model/12345"
|
||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1.5">Folder Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mlFolder}
|
||||
onChange={(e) => setMlFolder(e.target.value)}
|
||||
placeholder="medialink_12345"
|
||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1.5">Max Pages</label>
|
||||
<input
|
||||
type="number"
|
||||
value={mlPages}
|
||||
onChange={(e) => setMlPages(parseInt(e.target.value) || 50)}
|
||||
min={1}
|
||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1.5">Workers (1-10)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={mlWorkers}
|
||||
onChange={(e) => setMlWorkers(Math.min(10, Math.max(1, parseInt(e.target.value) || 3)))}
|
||||
min={1}
|
||||
max={10}
|
||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1.5">Delay (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={mlDelay}
|
||||
onChange={(e) => setMlDelay(parseInt(e.target.value) || 500)}
|
||||
min={0}
|
||||
max={5000}
|
||||
step={100}
|
||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={submitMediaLink}
|
||||
disabled={!mlUrl || !mlFolder || mlSubmitting}
|
||||
className="w-full bg-[#0095f6] hover:bg-[#0084db] disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium py-2.5 rounded-lg transition-colors"
|
||||
>
|
||||
{mlSubmitting ? 'Starting...' : 'Start MediaLink Scrape'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Jobs */}
|
||||
{activeJobs.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">
|
||||
Active Jobs
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{activeJobs.map(job => {
|
||||
const progress = job.progress.total > 0
|
||||
? Math.round((job.progress.completed / job.progress.total) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div key={job.id} className="bg-[#161616] border border-[#222] rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${
|
||||
job.type === 'forum' ? 'bg-purple-500/10 text-purple-400' : job.type === 'medialink' ? 'bg-green-500/10 text-green-400' : 'bg-orange-500/10 text-orange-400'
|
||||
}`}>
|
||||
{job.type}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-white">{job.folderName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#0095f6] font-medium">{progress}%</span>
|
||||
<button
|
||||
onClick={() => handleCancel(job.id)}
|
||||
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 mb-2">
|
||||
<span>{job.progress.completed} / {job.progress.total} {job.type === 'forum' ? 'pages' : 'files'}</span>
|
||||
{job.progress.errors > 0 && (
|
||||
<span className="text-red-400">{job.progress.errors} error{job.progress.errors !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-[#1a1a1a] rounded-full h-1.5 mb-3">
|
||||
<div
|
||||
className="bg-[#0095f6] h-1.5 rounded-full transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LogViewer
|
||||
logs={jobLogs[job.id] || []}
|
||||
expanded={expandedLogs[job.id] || false}
|
||||
onToggle={() => toggleLogs(job.id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed Jobs */}
|
||||
{completedJobs.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">
|
||||
Completed Jobs
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{completedJobs.map(job => (
|
||||
<div key={job.id} className="bg-[#161616] border border-[#222] rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${
|
||||
job.type === 'forum' ? 'bg-purple-500/10 text-purple-400' : job.type === 'medialink' ? 'bg-green-500/10 text-green-400' : 'bg-orange-500/10 text-orange-400'
|
||||
}`}>
|
||||
{job.type}
|
||||
</span>
|
||||
<span className="text-sm text-white">{job.folderName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>{job.progress.completed} files</span>
|
||||
{job.progress.errors > 0 && (
|
||||
<span className="text-red-400">{job.progress.errors} errors</span>
|
||||
)}
|
||||
<span>{formatDuration(job.startedAt, job.completedAt)}</span>
|
||||
{job.cancelled && (
|
||||
<span className="text-yellow-400">cancelled</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<LogViewer
|
||||
logs={jobLogs[job.id] || []}
|
||||
expanded={expandedLogs[job.id] || false}
|
||||
onToggle={() => toggleLogs(job.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{jobs.length === 0 && (
|
||||
<div className="text-center py-12 bg-[#161616] border border-[#222] rounded-lg">
|
||||
<svg className="w-12 h-12 text-gray-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
</svg>
|
||||
<p className="text-gray-500 text-sm">No scrape jobs yet</p>
|
||||
<p className="text-gray-600 text-xs mt-1">Configure and start a scrape above</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user