From 236f36aae6d31210f2126444a6b96656fc376c21 Mon Sep 17 00:00:00 2001 From: Trey T Date: Thu, 16 Apr 2026 07:48:10 -0500 Subject: [PATCH] Add app auth, dashboard, scheduler, video management, and new scrapers - JWT-based app authentication with user roles, folder/route access control - Dashboard with storage stats, health checks, and recent activity - Auto-download/scrape scheduler (12h interval) with per-user and per-job configs - Video upload, tagging, HLS transcoding, and detail pages - New scrapers: LeakGallery, Mega (megajs), yt-dlp - FlareSolverr integration for Cloudflare-protected sites - Gallery: advanced filtering (date, size, search), sort modes, equal-mix shuffle - Forum sites management with stored cookies/auth - GridWall/GridCell components for responsive media grid - Media API with folder-access permissions Co-Authored-By: Claude Opus 4.6 (1M context) --- .mcp.json | 10 + Dockerfile | 7 +- client/index.html | 2 +- client/src/App.jsx | 296 +++++++-- client/src/AuthContext.jsx | 71 +++ client/src/api.js | 280 ++++++++- client/src/components/GridCell.jsx | 252 ++++++++ client/src/components/GridWall.jsx | 238 +++++++ client/src/components/HlsVideo.jsx | 6 +- client/src/components/PostCard.jsx | 28 +- client/src/components/TagInput.jsx | 132 ++++ client/src/components/UserCard.jsx | 25 +- client/src/components/VideoCard.jsx | 93 +++ client/src/main.jsx | 5 +- client/src/pages/AppLogin.jsx | 78 +++ client/src/pages/AppSetup.jsx | 102 +++ client/src/pages/Dashboard.jsx | 309 +++++++++ client/src/pages/Downloads.jsx | 42 +- client/src/pages/Feed.jsx | 21 +- client/src/pages/Gallery.jsx | 469 ++++++++++++-- client/src/pages/Login.jsx | 56 +- client/src/pages/Scrape.jsx | 934 ++++++++++++++++++++++++++-- client/src/pages/UserManagement.jsx | 425 +++++++++++++ client/src/pages/Users.jsx | 25 +- client/src/pages/VideoDetail.jsx | 287 +++++++++ client/src/pages/VideoUpload.jsx | 310 +++++++++ client/src/pages/Videos.jsx | 473 ++++++++++++++ docker-compose.yml | 19 + package-lock.json | 137 ++++ package.json | 3 + server/auth.js | 355 +++++++++++ server/dashboard.js | 51 ++ server/db.js | 556 ++++++++++++++++- server/download.js | 144 ++++- server/flaresolverr.js | 128 ++++ server/gallery.js | 251 +++++++- server/health.js | 74 +++ server/hls.js | 400 ++++++++++-- server/index.js | 34 + server/login_helper.py | 216 +++++++ server/media-api.js | 66 ++ server/package-lock.json | 275 ++++++++ server/package.json | 5 + server/proxy.js | 17 + server/scheduler.js | 95 +++ server/scrape.js | 451 +++++++++++++- server/scrapers/coomer.js | 50 +- server/scrapers/forum.js | 233 +++++-- server/scrapers/leakgallery.js | 191 ++++++ server/scrapers/medialink.js | 281 +++++++-- server/scrapers/mega.js | 219 +++++++ server/scrapers/ytdlp.js | 300 +++++++++ server/video-hls.js | 434 +++++++++++++ server/videos.js | 445 +++++++++++++ 54 files changed, 9986 insertions(+), 420 deletions(-) create mode 100644 .mcp.json create mode 100644 client/src/AuthContext.jsx create mode 100644 client/src/components/GridCell.jsx create mode 100644 client/src/components/GridWall.jsx create mode 100644 client/src/components/TagInput.jsx create mode 100644 client/src/components/VideoCard.jsx create mode 100644 client/src/pages/AppLogin.jsx create mode 100644 client/src/pages/AppSetup.jsx create mode 100644 client/src/pages/Dashboard.jsx create mode 100644 client/src/pages/UserManagement.jsx create mode 100644 client/src/pages/VideoDetail.jsx create mode 100644 client/src/pages/VideoUpload.jsx create mode 100644 client/src/pages/Videos.jsx create mode 100644 server/auth.js create mode 100644 server/dashboard.js create mode 100644 server/flaresolverr.js create mode 100644 server/health.js create mode 100644 server/login_helper.py create mode 100644 server/media-api.js create mode 100644 server/scheduler.js create mode 100644 server/scrapers/leakgallery.js create mode 100644 server/scrapers/mega.js create mode 100644 server/scrapers/ytdlp.js create mode 100644 server/video-hls.js create mode 100644 server/videos.js diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..a80c72d --- /dev/null +++ b/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "github-webhook": { + "command": "bun", + "args": [ + "/Users/m4mini/Desktop/code/github-webhook-channel/webhook.ts" + ] + } + } +} diff --git a/Dockerfile b/Dockerfile index 79b765a..ba0a959 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,8 +8,11 @@ RUN cd client && npm run build # Stage 2 — Production FROM node:20-alpine -RUN apk add --no-cache ffmpeg openssl python3 py3-pip \ - && pip3 install --break-system-packages pywidevine +RUN apk add --no-cache ffmpeg openssl python3 py3-pip intel-media-driver \ + chromium chromium-chromedriver nss freetype harfbuzz ca-certificates ttf-freefont \ + xvfb-run xorg-server xf86-video-dummy \ + && pip3 install --break-system-packages pywidevine yt-dlp gallery-dl \ + selenium undetected-chromedriver WORKDIR /app COPY server/package*.json ./server/ RUN cd server && npm install --production diff --git a/client/index.html b/client/index.html index 3341c0c..483f8af 100644 --- a/client/index.html +++ b/client/index.html @@ -2,7 +2,7 @@ - + OFApp diff --git a/client/src/App.jsx b/client/src/App.jsx index 1df0a16..7542c25 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,7 +1,11 @@ -import { useState, useEffect } from 'react' -import { Routes, Route, NavLink, useLocation } from 'react-router-dom' -import { getMe } from './api' +import { useState, useEffect, useRef } from 'react' +import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom' +import { getMe, getNewMediaCount, checkAuth, appAuthLogout } from './api' +import { useAuth } from './AuthContext' +import AppLogin from './pages/AppLogin' +import AppSetup from './pages/AppSetup' import Login from './pages/Login' +import Dashboard from './pages/Dashboard' import Feed from './pages/Feed' import Users from './pages/Users' import UserPosts from './pages/UserPosts' @@ -10,35 +14,99 @@ import Search from './pages/Search' import Gallery from './pages/Gallery' import Duplicates from './pages/Duplicates' import Scrape from './pages/Scrape' +import Videos from './pages/Videos' +import VideoDetail from './pages/VideoDetail' +import VideoUpload from './pages/VideoUpload' +import UserManagement from './pages/UserManagement' -const navItems = [ - { to: '/feed', label: 'Feed', icon: FeedIcon }, - { to: '/users', label: 'Users', icon: UsersIcon }, - { 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 }, +// Route key mapping for nav items (null = always visible, undefined = no check needed) +const allNavItems = [ + { to: '/', label: 'Home', icon: HomeIcon, exact: true, routeKey: 'dashboard' }, + { to: '/feed', label: 'Feed', icon: FeedIcon, routeKey: 'feed' }, + { to: '/users', label: 'Users', icon: UsersIcon, routeKey: 'users' }, + { to: '/search', label: 'Search', icon: SearchIcon, routeKey: 'users' }, + { to: '/downloads', label: 'Downloads', icon: DownloadIcon, routeKey: 'downloads' }, + { to: '/gallery', label: 'Gallery', icon: GalleryNavIcon, routeKey: 'gallery' }, + { to: '/videos', label: 'Videos', icon: VideoNavIcon, routeKey: 'videos' }, + { to: '/scrape', label: 'Scrape', icon: ScrapeIcon, routeKey: 'scrape' }, + { to: '/settings', label: 'Settings', icon: SettingsIcon, routeKey: 'settings' }, ] -// Bottom bar shows a subset of nav items (most used) to avoid crowding -const mobileNavItems = [ - { to: '/feed', label: 'Feed', icon: FeedIcon }, - { to: '/users', label: 'Users', icon: UsersIcon }, - { to: '/gallery', label: 'Gallery', icon: GalleryNavIcon }, - { to: '/search', label: 'Search', icon: SearchIcon }, +const allMobileNavItems = [ + { to: '/', label: 'Home', icon: HomeIcon, exact: true, routeKey: 'dashboard' }, + { to: '/users', label: 'Users', icon: UsersIcon, routeKey: 'users' }, + { to: '/gallery', label: 'Gallery', icon: GalleryNavIcon, routeKey: 'gallery' }, + { to: '/search', label: 'Search', icon: SearchIcon, routeKey: 'users' }, { to: '/more', label: 'More', icon: MoreIcon }, ] -const moreNavItems = [ - { to: '/downloads', label: 'Downloads', icon: DownloadIcon }, - { to: '/scrape', label: 'Scrape', icon: ScrapeIcon }, - { to: '/', label: 'Settings', icon: SettingsIcon }, +const allMoreNavItems = [ + { to: '/feed', label: 'Feed', icon: FeedIcon, routeKey: 'feed' }, + { to: '/downloads', label: 'Downloads', icon: DownloadIcon, routeKey: 'downloads' }, + { to: '/videos', label: 'Videos', icon: VideoNavIcon, routeKey: 'videos' }, + { to: '/scrape', label: 'Scrape', icon: ScrapeIcon, routeKey: 'scrape' }, + { to: '/settings', label: 'Settings', icon: SettingsIcon, routeKey: 'settings' }, ] +function ProtectedRoute({ routeKey, children }) { + const { hasRoute, appUser } = useAuth() + // 'admin' is a special key — only admin role can access + if (routeKey === 'admin') { + if (appUser?.role !== 'admin') return + return children + } + if (!hasRoute(routeKey)) { + return + } + return children +} + export default function App() { + const { appUser, setupRequired, loading: authLoading, hasRoute } = useAuth() + + // Show auth screens if needed + if (authLoading) { + return ( +
+
+
+ ) + } + if (setupRequired) return + if (!appUser) return + + // Filter nav items based on route permissions + const navItems = allNavItems.filter((item) => + !item.routeKey || hasRoute(item.routeKey) + ) + const mobileNavItems = allMobileNavItems.filter((item) => + !item.routeKey || hasRoute(item.routeKey) + ) + const moreNavItems = allMoreNavItems.filter((item) => + !item.routeKey || hasRoute(item.routeKey) + ) + + // Add admin nav item for admins + if (appUser.role === 'admin') { + navItems.push({ to: '/admin/users', label: 'Admin', icon: AdminIcon }) + moreNavItems.push({ to: '/admin/users', label: 'Admin', icon: AdminIcon }) + } + + return +} + +function AppShell({ navItems, mobileNavItems, moreNavItems, hasRoute, appUser }) { + const { logout } = useAuth() const [currentUser, setCurrentUser] = useState(null) const [moreOpen, setMoreOpen] = useState(false) + const [authWarning, setAuthWarning] = useState(null) + const authPollRef = useRef(null) const location = useLocation() useEffect(() => { @@ -49,11 +117,48 @@ export default function App() { }) }, []) + // Auth validity polling (every 5 min) + useEffect(() => { + const poll = () => { + checkAuth().then((data) => { + if (data && !data.error && !data.valid) { + setAuthWarning(data.error || 'Auth expired') + } else if (data?.valid) { + setAuthWarning(null) + } + }) + } + poll() + authPollRef.current = setInterval(poll, 5 * 60 * 1000) + return () => clearInterval(authPollRef.current) + }, []) + // Close "more" menu on route change useEffect(() => { setMoreOpen(false) }, [location.pathname]) + // New media badge + const [newMediaCount, setNewMediaCount] = useState(0) + + useEffect(() => { + const fetchCount = () => { + getNewMediaCount().then((data) => { + if (!data.error) setNewMediaCount(data.count || 0) + }) + } + fetchCount() + const id = setInterval(fetchCount, 60000) + return () => clearInterval(id) + }, []) + + // Instantly clear badge when on gallery page + useEffect(() => { + if (location.pathname.startsWith('/gallery')) { + setNewMediaCount(0) + } + }, [location.pathname]) + const refreshUser = () => { getMe().then((data) => { if (!data.error) { @@ -62,12 +167,17 @@ export default function App() { }) } + const handleLogout = async () => { + await appAuthLogout() + logout() + } + const isMoreActive = moreNavItems.some((item) => - item.to === '/' ? location.pathname === '/' : location.pathname.startsWith(item.to) + item.to !== '/more' && location.pathname.startsWith(item.to) ) return ( -
+
{/* Desktop Sidebar */} {/* Main Content */} -
+
+ {/* Auth Warning Banner */} + {authWarning && ( +
+
+ + + + + Auth expired —{' '} + + Update credentials in Settings + + +
+ +
+ )} +
- } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } />
@@ -166,21 +318,25 @@ export default function App() { ) } - const isActive = - item.to === '/' - ? location.pathname === '/' - : location.pathname.startsWith(item.to) + const isActive = item.exact + ? location.pathname === item.to + : location.pathname.startsWith(item.to) return ( {item.label} + {item.to === '/gallery' && newMediaCount > 0 && ( + + {newMediaCount > 99 ? '99+' : newMediaCount} + + )} ) })} @@ -193,10 +349,7 @@ export default function App() {
{moreNavItems.map((item) => { const Icon = item.icon - const isActive = - item.to === '/' - ? location.pathname === '/' - : location.pathname.startsWith(item.to) + const isActive = location.pathname.startsWith(item.to) return ( ) })} +
)} @@ -221,6 +381,22 @@ export default function App() { /* Icon Components */ +function AdminIcon({ className }) { + return ( + + + + ) +} + +function LogoutIcon({ className }) { + return ( + + + + ) +} + function FeedIcon({ className }) { return ( @@ -285,3 +461,19 @@ function MoreIcon({ className }) { ) } + +function HomeIcon({ className }) { + return ( + + + + ) +} + +function VideoNavIcon({ className }) { + return ( + + + + ) +} diff --git a/client/src/AuthContext.jsx b/client/src/AuthContext.jsx new file mode 100644 index 0000000..69758ad --- /dev/null +++ b/client/src/AuthContext.jsx @@ -0,0 +1,71 @@ +import { createContext, useContext, useState, useEffect, useCallback } from 'react' +import { appAuthStatus, appAuthMe } from './api' + +const AuthContext = createContext(null) + +export function AuthProvider({ children }) { + const [appUser, setAppUser] = useState(null) + const [setupRequired, setSetupRequired] = useState(false) + const [loading, setLoading] = useState(true) + + useEffect(() => { + appAuthStatus().then((data) => { + if (data.error) { + setLoading(false) + return + } + if (data.setupRequired) { + setSetupRequired(true) + setLoading(false) + return + } + // Not setup mode — fetch current user + appAuthMe().then((meData) => { + if (meData.error) { + setLoading(false) + return + } + if (meData.setupRequired) { + setSetupRequired(true) + } else if (meData.user) { + setAppUser(meData.user) + } + setLoading(false) + }) + }) + }, []) + + const login = useCallback((userData) => { + setAppUser(userData) + setSetupRequired(false) + }, []) + + const logout = useCallback(() => { + setAppUser(null) + }, []) + + const hasRoute = useCallback((routeKey) => { + if (!appUser) return false + if (appUser.role === 'admin') return true + return appUser.routes?.includes(routeKey) || false + }, [appUser]) + + const hasFolder = useCallback((folderName) => { + if (!appUser) return false + if (appUser.role === 'admin') return true + if (appUser.folders === null) return true // null = all access + return appUser.folders?.includes(folderName) || false + }, [appUser]) + + return ( + + {children} + + ) +} + +export function useAuth() { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used within AuthProvider') + return ctx +} diff --git a/client/src/api.js b/client/src/api.js index 37dc53f..2103b98 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -1,6 +1,13 @@ async function request(url, options = {}) { try { const response = await fetch(url, options); + + // Handle 401 — redirect to login (skip for auth endpoints) + if (response.status === 401 && !url.startsWith('/api/app-auth/')) { + window.location.href = '/login'; + return { error: 'Authentication required' }; + } + const data = await response.json(); if (!response.ok) { @@ -78,6 +85,14 @@ export function startDownload(userId, limit, resume, username) { }); } +export function downloadPost(userId, username, postId, media, postedAt) { + return request('/api/download/post', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId, username, postId, media, postedAt }), + }); +} + export function getDownloadStatus(userId) { return request(`/api/download/${userId}/status`); } @@ -110,8 +125,8 @@ export function updateSettings(settings) { }); } -export function getGalleryFiles({ folder, folders, type, sort, offset, limit } = {}) { - const query = buildQuery({ folder, folders: folders ? folders.join(',') : undefined, type, sort, offset, limit }); +export function getGalleryFiles({ folder, folders, type, sort, offset, limit, dateFrom, dateTo, minSize, maxSize, search } = {}) { + const query = buildQuery({ folder, folders: folders ? folders.join(',') : undefined, type, sort, offset, limit, dateFrom, dateTo, minSize, maxSize, search }); return request(`/api/gallery/files${query}`); } @@ -131,8 +146,8 @@ export function getThumbsStatus() { return request('/api/gallery/generate-thumbs/status'); } -export function scanDuplicates() { - return request('/api/gallery/scan-duplicates', { method: 'POST' }); +export function scanDuplicates(mode = 'everywhere') { + return request(`/api/gallery/scan-duplicates?mode=${mode}`, { method: 'POST' }); } export function getDuplicateScanStatus() { @@ -152,6 +167,18 @@ export function deleteMediaFile(folder, filename) { return request(`/api/gallery/media/${encodeURIComponent(folder)}/${encodeURIComponent(filename)}`, { method: 'DELETE' }); } +export function getNewMediaCount() { + return request('/api/gallery/new-count'); +} + +export function markGallerySeen() { + return request('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ gallery_last_seen: new Date().toISOString() }), + }); +} + export function startForumScrape(config) { return request('/api/scrape/forum', { method: 'POST', @@ -176,6 +203,30 @@ export function startMediaLinkScrape(config) { }); } +export function startMegaScrape(config) { + return request('/api/scrape/mega', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); +} + +export function startLeakGalleryScrape(config) { + return request('/api/scrape/leakgallery', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); +} + +export function startYtdlpScrape(config) { + return request('/api/scrape/ytdlp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); +} + export function getScrapeJobs() { return request('/api/scrape/jobs'); } @@ -188,10 +239,227 @@ export function cancelScrapeJob(jobId) { return request(`/api/scrape/jobs/${jobId}/cancel`, { method: 'POST' }); } -export function detectForumPages(url) { +export function detectForumPages(url, cookies) { return request('/api/scrape/forum/detect-pages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url }), + body: JSON.stringify({ url, cookies }), }); } + +// --- FlareSolverr --- + +export function getFlareSolverrStatus() { + return request('/api/flaresolverr/status'); +} + +export function refreshForumCookies(siteId) { + return request(`/api/flaresolverr/refresh/${siteId}`, { method: 'POST' }); +} + +// --- Forum Sites --- + +export function getForumSites() { + return request('/api/scrape/forum-sites'); +} + +export function createForumSite(data) { + return request('/api/scrape/forum-sites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); +} + +export function updateForumSite(id, data) { + return request(`/api/scrape/forum-sites/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); +} + +export function deleteForumSite(id) { + return request(`/api/scrape/forum-sites/${id}`, { method: 'DELETE' }); +} + +// --- Auto-download --- + +export function getAutoDownloadUsers() { + return request('/api/download/auto'); +} + +export function addAutoDownloadUser(userId, username) { + return request(`/api/download/auto/${userId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username }), + }); +} + +export function removeAutoDownloadUser(userId) { + return request(`/api/download/auto/${userId}`, { method: 'DELETE' }); +} + +// --- Auto-scrape --- + +export function getAutoScrapeJobs() { + return request('/api/scrape/auto'); +} + +export function addAutoScrapeJob(config) { + return request('/api/scrape/auto', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); +} + +export function removeAutoScrapeJob(id) { + return request(`/api/scrape/auto/${id}`, { method: 'DELETE' }); +} + +// --- Dashboard / Health --- + +export function checkAuth() { + return request('/api/auth/check'); +} + +export function getDashboard() { + return request('/api/dashboard'); +} + +export function getHealth() { + return request('/api/health'); +} + +export function getActiveDownloadDetails() { + return request('/api/download/active/details'); +} + +// --- Videos --- + +export function getVideos({ search, tags, minDuration, maxDuration, minWidth, sort, offset, limit } = {}) { + const query = buildQuery({ + search, tags: tags ? tags.join(',') : undefined, + minDuration, maxDuration, minWidth, sort, offset, limit, + }); + return request(`/api/videos${query}`); +} + +export function getVideo(id) { + return request(`/api/videos/${id}`); +} + +export function updateVideoMeta(id, data) { + return request(`/api/videos/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); +} + +export function deleteVideo(id) { + return request(`/api/videos/${id}`, { method: 'DELETE' }); +} + +export function uploadVideo(file, onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/videos/upload'); + + if (onProgress) { + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) onProgress(e.loaded / e.total); + }; + } + + xhr.onload = () => { + try { + const data = JSON.parse(xhr.responseText); + resolve(data); + } catch { + reject(new Error('Invalid response')); + } + }; + + xhr.onerror = () => reject(new Error('Upload failed')); + + const formData = new FormData(); + formData.append('video', file); + xhr.send(formData); + }); +} + +export function scanVideos() { + return request('/api/videos/scan', { method: 'POST' }); +} + +export function getVideoScanStatus() { + return request('/api/videos/scan/status'); +} + +export function getVideoTags(search) { + const query = buildQuery({ search }); + return request(`/api/videos/tags${query}`); +} + +// --- App Auth --- + +export function appAuthStatus() { + return request('/api/app-auth/status'); +} + +export function appAuthLogin(username, password) { + return request('/api/app-auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); +} + +export function appAuthLogout() { + return request('/api/app-auth/logout', { method: 'POST' }); +} + +export function appAuthMe() { + return request('/api/app-auth/me'); +} + +export function appAuthSetup(username, password) { + return request('/api/app-auth/setup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); +} + +// --- Admin User Management --- + +export function getAppUsers() { + return request('/api/admin/users'); +} + +export function createAppUser(data) { + return request('/api/admin/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); +} + +export function updateAppUser(id, data) { + return request(`/api/admin/users/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); +} + +export function deleteAppUser(id) { + return request(`/api/admin/users/${id}`, { method: 'DELETE' }); +} + +export function getAvailableFolders() { + return request('/api/admin/available-folders'); +} diff --git a/client/src/components/GridCell.jsx b/client/src/components/GridCell.jsx new file mode 100644 index 0000000..f7933ff --- /dev/null +++ b/client/src/components/GridCell.jsx @@ -0,0 +1,252 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import HlsVideo from './HlsVideo' + +const SLIDESHOW_INTERVAL = 5000 + +// URL helpers (pure functions, no hooks) +function getItemUrl(item) { + if (!item) return '' + if (item.folder) { + return `/api/gallery/media/${encodeURIComponent(item.folder)}/${encodeURIComponent(item.filename)}` + } + if (item.id) { + return `/api/videos/${item.id}/stream` + } + return item.url || '' +} + +function getItemThumbUrl(item) { + if (!item) return '' + if (item.folder) { + return `/api/gallery/thumb/${encodeURIComponent(item.folder)}/${encodeURIComponent(item.filename)}` + } + if (item.id) { + return `/api/videos/${item.id}/thumbnail` + } + return '' +} + +function getItemHlsUrl(item, hlsEnabled) { + if (!item) return null + if (item.id && !item.folder) { + return `/api/video-hls/${item.id}/master.m3u8` + } + if (hlsEnabled && item.folder) { + return `/api/hls/${encodeURIComponent(item.folder)}/${encodeURIComponent(item.filename)}/master.m3u8` + } + return null +} + +function getItemLabel(item) { + if (!item) return '' + if (item.folder) return `@${item.folder}` + if (item.title) return item.title + return '' +} + +export default function GridCell({ queue, onNeedMore, paused, hlsEnabled }) { + const [currentIndex, setCurrentIndex] = useState(0) + // Two-layer crossfade: both layers always rendered, activeLayer controls which is on top + const [activeLayer, setActiveLayer] = useState(0) + const [layerSrcs, setLayerSrcs] = useState(['', '']) + const [muted, setMuted] = useState(true) + const videoRef = useRef(null) + const timerRef = useRef(null) + const fadingRef = useRef(false) + + const current = queue[currentIndex] || null + const isVideo = current?.type === 'video' + + // Initialize first layer when first item arrives + useEffect(() => { + if (current && !layerSrcs[0] && !layerSrcs[1]) { + setLayerSrcs([getItemUrl(current), '']) + setActiveLayer(0) + } + }, [current]) // eslint-disable-line react-hooks/exhaustive-deps + + // Request more items when queue is running low + useEffect(() => { + if (queue.length - currentIndex < 5 && onNeedMore) { + onNeedMore() + } + }, [currentIndex, queue.length, onNeedMore]) + + const advance = useCallback(() => { + if (fadingRef.current) return + const nextIdx = currentIndex + 1 + if (nextIdx >= queue.length) { + if (onNeedMore) onNeedMore() + return + } + + const upcoming = queue[nextIdx] + const upcomingIsVideo = upcoming?.type === 'video' + + if (!isVideo && !upcomingIsVideo) { + // Preload the next image, then crossfade + const nextLayer = activeLayer === 0 ? 1 : 0 + const url = getItemUrl(upcoming) + const img = new Image() + img.src = url + const doFade = () => { + fadingRef.current = true + setLayerSrcs(prev => { + const next = [...prev] + next[nextLayer] = url + return next + }) + setActiveLayer(nextLayer) + setTimeout(() => { + setCurrentIndex(nextIdx) + fadingRef.current = false + }, 600) + } + img.onload = doFade + img.onerror = doFade // still transition even on error + } else { + setCurrentIndex(nextIdx) + setLayerSrcs([getItemUrl(upcoming), '']) + setActiveLayer(0) + } + }, [currentIndex, queue, isVideo, onNeedMore, activeLayer]) + + const skip = useCallback(() => { + clearTimeout(timerRef.current) + advance() + }, [advance]) + + // Image slideshow timer + useEffect(() => { + if (!current || paused || isVideo) return + timerRef.current = setTimeout(advance, SLIDESHOW_INTERVAL) + return () => clearTimeout(timerRef.current) + }, [currentIndex, current, paused, isVideo, advance]) + + const handleVideoEnded = useCallback(() => { + if (!paused) advance() + }, [paused, advance]) + + // Pause/resume video when master pause toggles + useEffect(() => { + if (!videoRef.current || !isVideo) return + if (paused) { + videoRef.current.pause() + } else { + videoRef.current.play().catch(() => {}) + } + }, [paused, isVideo]) + + if (!current) { + return ( +
+
+
+ ) + } + + return ( +
+ {isVideo ? ( + + ) : ( +
+ {/* Layer 0 */} + + {/* Layer 1 */} + +
+ )} + + {/* Controls */} +
+ {isVideo && ( + <> + {/* Mute/Unmute */} + + {/* Seek 25/50/75% */} + {[25, 50, 75].map(pct => ( + + ))} + + )} + +
+ + {/* Label */} +
+ {getItemLabel(current)} +
+ + {/* Video indicator */} + {isVideo && ( +
+ + + +
+ )} +
+ ) +} diff --git a/client/src/components/GridWall.jsx b/client/src/components/GridWall.jsx new file mode 100644 index 0000000..af3791e --- /dev/null +++ b/client/src/components/GridWall.jsx @@ -0,0 +1,238 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import GridCell from './GridCell' + +function useFullscreen(ref) { + const [isFullscreen, setIsFullscreen] = useState(false) + + useEffect(() => { + const onChange = () => setIsFullscreen(!!document.fullscreenElement) + document.addEventListener('fullscreenchange', onChange) + document.addEventListener('webkitfullscreenchange', onChange) + return () => { + document.removeEventListener('fullscreenchange', onChange) + document.removeEventListener('webkitfullscreenchange', onChange) + } + }, []) + + const toggle = useCallback(() => { + if (!ref.current) return + if (document.fullscreenElement) { + document.exitFullscreen?.() || document.webkitExitFullscreen?.() + } else { + ref.current.requestFullscreen?.() || ref.current.webkitRequestFullscreen?.() + } + }, [ref]) + + return { isFullscreen, toggle } +} + +const GRID_LAYOUTS = [ + { label: '1\u00d71', cols: 1, rows: 1 }, + { label: '2\u00d71', cols: 2, rows: 1 }, + { label: '2\u00d72', cols: 2, rows: 2 }, + { label: '3\u00d72', cols: 3, rows: 2 }, + { label: '3\u00d73', cols: 3, rows: 3 }, +] + +const BATCH_SIZE = 200 + +export default function GridWall({ layout, fetchItems, hlsEnabled, onClose }) { + const cellCount = layout.cols * layout.rows + const [queues, setQueues] = useState(() => Array.from({ length: cellCount }, () => [])) + const [paused, setPaused] = useState(false) + const usedIdsRef = useRef(new Set()) + const fetchingRef = useRef(false) + const containerRef = useRef(null) + const { isFullscreen, toggle: toggleFullscreen } = useFullscreen(containerRef) + + // Deal items round-robin into cell queues, avoiding duplicates + const dealItems = useCallback((items) => { + const fresh = items.filter(item => { + const key = item.folder ? `${item.folder}/${item.filename}` : `vid-${item.id}` + if (usedIdsRef.current.has(key)) return false + usedIdsRef.current.add(key) + return true + }) + + if (fresh.length === 0) return + + setQueues(prev => { + const next = prev.map(q => [...q]) + fresh.forEach((item, i) => { + next[i % cellCount].push(item) + }) + return next + }) + }, [cellCount]) + + // Fetch a batch and deal into queues + const fetchAndDeal = useCallback(async () => { + if (fetchingRef.current) return + fetchingRef.current = true + try { + const items = await fetchItems(BATCH_SIZE) + dealItems(items) + } catch (err) { + console.error('[GridWall] fetch error:', err) + } finally { + fetchingRef.current = false + } + }, [fetchItems, dealItems]) + + // Initial load + useEffect(() => { + fetchAndDeal() + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // Keyboard controls + useEffect(() => { + const handleKey = (e) => { + if (e.key === 'Escape' && !document.fullscreenElement) onClose() + if (e.key === ' ') { + e.preventDefault() + setPaused(p => !p) + } + if (e.key === 'f' || e.key === 'F') toggleFullscreen() + } + window.addEventListener('keydown', handleKey) + return () => window.removeEventListener('keydown', handleKey) + }, [onClose, toggleFullscreen]) + + return ( +
+ {/* Top bar */} +
+
+ {layout.label} Grid +
+
+ {/* Play/Pause */} + + {/* Fullscreen */} + + {/* Close */} + +
+
+ + {/* Grid */} +
+ {queues.map((queue, i) => ( + + ))} +
+ + {/* Paused indicator */} + {paused && ( +
+ Paused · Space to resume +
+ )} +
+ ) +} + +// Picker popover component +export function GridWallPicker({ onSelect, onClose }) { + const ref = useRef(null) + + useEffect(() => { + const handleClick = (e) => { + if (ref.current && !ref.current.contains(e.target)) onClose() + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [onClose]) + + return ( +
+ {GRID_LAYOUTS.map(layout => ( + + ))} +
+ ) +} + +function GridIcon({ cols, rows }) { + const size = 16 + const gap = 1 + const cellW = (size - gap * (cols - 1)) / cols + const cellH = (size - gap * (rows - 1)) / rows + + return ( + + {Array.from({ length: rows }, (_, r) => + Array.from({ length: cols }, (_, c) => ( + + )) + )} + + ) +} diff --git a/client/src/components/HlsVideo.jsx b/client/src/components/HlsVideo.jsx index 9524a1a..be52a24 100644 --- a/client/src/components/HlsVideo.jsx +++ b/client/src/components/HlsVideo.jsx @@ -37,8 +37,10 @@ const HlsVideo = forwardRef(function HlsVideo({ hlsSrc, src, autoPlay, ...props // Always use hls.js when supported (including Safari) for consistent behavior if (Hls.isSupported()) { const hls = new Hls({ - maxBufferLength: 10, - maxMaxBufferLength: 30, + maxBufferLength: 30, + maxMaxBufferLength: 60, + capLevelToPlayerSize: true, + startLevel: -1, emeEnabled: true, }) hlsRef.current = hls diff --git a/client/src/components/PostCard.jsx b/client/src/components/PostCard.jsx index b6db08c..4ef34e4 100644 --- a/client/src/components/PostCard.jsx +++ b/client/src/components/PostCard.jsx @@ -33,13 +33,16 @@ function timeAgo(dateStr) { return 'just now' } -export default function PostCard({ post }) { +export default function PostCard({ post, onDownloadPost, downloadingPosts }) { const author = post.author || post.fromUser || {} const media = post.media || [] const text = post.text || post.rawText || '' const postedAt = post.postedAt || post.createdAt || post.publishedAt const [showText, setShowText] = useState(false) + const isDownloading = downloadingPosts?.has?.(post.id) + const hasMedia = media.length > 0 + return (
{/* Author Row */} @@ -62,6 +65,29 @@ export default function PostCard({ post }) { {postedAt && {timeAgo(postedAt)}}

+ {onDownloadPost && hasMedia && ( + + )}
{/* Media */} diff --git a/client/src/components/TagInput.jsx b/client/src/components/TagInput.jsx new file mode 100644 index 0000000..1971062 --- /dev/null +++ b/client/src/components/TagInput.jsx @@ -0,0 +1,132 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import { getVideoTags } from '../api' + +export default function TagInput({ tags = [], onChange }) { + const [input, setInput] = useState('') + const [suggestions, setSuggestions] = useState([]) + const [showSuggestions, setShowSuggestions] = useState(false) + const inputRef = useRef(null) + const containerRef = useRef(null) + const debounceRef = useRef(null) + + // Close suggestions on click outside + useEffect(() => { + const handleClick = (e) => { + if (containerRef.current && !containerRef.current.contains(e.target)) { + setShowSuggestions(false) + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, []) + + const fetchSuggestions = useCallback((query) => { + if (debounceRef.current) clearTimeout(debounceRef.current) + if (!query.trim()) { + setSuggestions([]) + setShowSuggestions(false) + return + } + debounceRef.current = setTimeout(async () => { + const data = await getVideoTags(query.trim()) + if (Array.isArray(data)) { + // Filter out already-selected tags + const filtered = data.filter(t => !tags.some( + existing => existing.toLowerCase() === t.name.toLowerCase() + )) + setSuggestions(filtered) + setShowSuggestions(filtered.length > 0) + } + }, 300) + }, [tags]) + + const addTag = (name) => { + const trimmed = name.trim() + if (!trimmed) return + if (tags.some(t => t.toLowerCase() === trimmed.toLowerCase())) return + onChange([...tags, trimmed]) + setInput('') + setSuggestions([]) + setShowSuggestions(false) + inputRef.current?.focus() + } + + const removeTag = (index) => { + onChange(tags.filter((_, i) => i !== index)) + } + + const handleKeyDown = (e) => { + if ((e.key === 'Enter' || e.key === ',') && input.trim()) { + e.preventDefault() + addTag(input) + } + if (e.key === 'Backspace' && !input && tags.length > 0) { + removeTag(tags.length - 1) + } + if (e.key === 'Escape') { + setShowSuggestions(false) + } + } + + const handleChange = (e) => { + const val = e.target.value + // If user types comma, add tag + if (val.includes(',')) { + const parts = val.split(',') + for (const part of parts) { + if (part.trim()) addTag(part) + } + return + } + setInput(val) + fetchSuggestions(val) + } + + return ( +
+
+ {tags.map((tag, i) => ( + + {tag} + + + ))} + { if (suggestions.length > 0) setShowSuggestions(true) }} + placeholder={tags.length === 0 ? 'Add tags...' : ''} + className="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-gray-600 outline-none" + /> +
+ + {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map((tag) => ( + + ))} +
+ )} +
+ ) +} diff --git a/client/src/components/UserCard.jsx b/client/src/components/UserCard.jsx index 06f2b6d..4e9f8ca 100644 --- a/client/src/components/UserCard.jsx +++ b/client/src/components/UserCard.jsx @@ -7,7 +7,7 @@ function decodeHTML(str) { return el.value } -export default function UserCard({ user, onDownload, downloading }) { +export default function UserCard({ user, onDownload, downloading, autoDownload, onToggleAutoDownload }) { const handleDownloadClick = (e) => { e.preventDefault() e.stopPropagation() @@ -16,6 +16,14 @@ export default function UserCard({ user, onDownload, downloading }) { } } + const handleAutoToggle = (e) => { + e.preventDefault() + e.stopPropagation() + if (onToggleAutoDownload) { + onToggleAutoDownload(user.id, user.username) + } + } + return (
@@ -47,6 +55,21 @@ export default function UserCard({ user, onDownload, downloading }) { )}
+ {/* Auto-download toggle */} + + {/* Download Button */} + +
+
+ ) +} diff --git a/client/src/pages/AppSetup.jsx b/client/src/pages/AppSetup.jsx new file mode 100644 index 0000000..d9f81e3 --- /dev/null +++ b/client/src/pages/AppSetup.jsx @@ -0,0 +1,102 @@ +import { useState } from 'react' +import { appAuthSetup } from '../api' +import { useAuth } from '../AuthContext' + +export default function AppSetup() { + const { login } = useAuth() + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + + if (password !== confirmPassword) { + setError('Passwords do not match') + return + } + if (password.length < 4) { + setError('Password must be at least 4 characters') + return + } + + setLoading(true) + const data = await appAuthSetup(username, password) + setLoading(false) + + if (data.error) { + setError(data.error) + } else if (data.user) { + login(data.user) + } + } + + return ( +
+
+
+

+ OFApp +

+

Welcome! Create your admin account to get started.

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setUsername(e.target.value)} + className="w-full bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2.5 text-white placeholder-gray-600 focus:outline-none focus:border-[#0095f6] transition-colors" + placeholder="Choose a username" + autoFocus + autoComplete="username" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2.5 text-white placeholder-gray-600 focus:outline-none focus:border-[#0095f6] transition-colors" + placeholder="Choose a password" + autoComplete="new-password" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="w-full bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2.5 text-white placeholder-gray-600 focus:outline-none focus:border-[#0095f6] transition-colors" + placeholder="Confirm password" + autoComplete="new-password" + /> +
+ + +
+
+
+ ) +} diff --git a/client/src/pages/Dashboard.jsx b/client/src/pages/Dashboard.jsx new file mode 100644 index 0000000..6545221 --- /dev/null +++ b/client/src/pages/Dashboard.jsx @@ -0,0 +1,309 @@ +import { useState, useEffect, useRef } from 'react' +import { getDashboard, getHealth } from '../api' +import Spinner from '../components/Spinner' + +function formatBytes(bytes) { + if (!bytes || bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] +} + +function formatUptime(seconds) { + if (!seconds) return '--' + const d = Math.floor(seconds / 86400) + const h = Math.floor((seconds % 86400) / 3600) + const m = Math.floor((seconds % 3600) / 60) + if (d > 0) return `${d}d ${h}h` + if (h > 0) return `${h}h ${m}m` + return `${m}m` +} + +export default function Dashboard() { + const [data, setData] = useState(null) + const [health, setHealth] = useState(null) + const [loading, setLoading] = useState(true) + const pollRef = useRef(null) + + const loadData = async () => { + const [dashData, healthData] = await Promise.all([ + getDashboard(), + getHealth(), + ]) + if (!dashData.error) setData(dashData) + if (!healthData.error) setHealth(healthData) + setLoading(false) + } + + useEffect(() => { + loadData() + pollRef.current = setInterval(loadData, 30000) + return () => clearInterval(pollRef.current) + }, []) + + if (loading) return + + if (!data) { + return ( +
+

Failed to load dashboard

+
+ ) + } + + const maxFolderSize = data.topFolders?.[0]?.total_size || 1 + + return ( +
+
+

Dashboard

+

System overview and storage insights

+
+ + {/* Stats Cards */} +
+ } + color="blue" + /> + } + color="purple" + /> + } + color="green" + /> + } + color="amber" + /> +
+ + {/* Active Jobs */} + {(data.activeJobs.downloads > 0 || data.activeJobs.scrapes > 0) && ( +
+

Active Jobs

+
+ {data.activeJobs.downloadList?.map((dl) => { + const pct = dl.total > 0 ? Math.round((dl.completed / dl.total) * 100) : 0 + return ( +
+
+
+
+ Download — {dl.username ? `@${dl.username}` : dl.userId} +
+ {pct}% +
+
+
+
+

+ {dl.completed} / {dl.total} files + {dl.errors > 0 && ({dl.errors} errors)} +

+
+ ) + })} + {data.activeJobs.scrapeList?.map((job, i) => { + const pct = job.progress.total > 0 ? Math.round((job.progress.completed / job.progress.total) * 100) : 0 + return ( +
+
+
+
+ + {job.type} + {job.folderName} + +
+ {pct}% +
+
+
+
+

+ {job.progress.completed} / {job.progress.total} {job.type === 'forum' ? 'pages' : 'files'} + {job.progress.errors > 0 && ({job.progress.errors} errors)} +

+
+ ) + })} +
+
+ )} + +
+ {/* Storage Breakdown */} +
+

Top Creators by Storage

+ {data.topFolders?.length > 0 ? ( +
+ {data.topFolders.slice(0, 7).map((f) => ( +
+
+ {f.folder} + + {formatBytes(f.total_size)} · {f.file_count} files + +
+
+
+
+
+ ))} +
+ ) : ( +

No media indexed yet

+ )} +
+ + {/* System Health */} + {health && ( +
+

System Health

+
+ + + + + + + + {health.diskSpace && ( + 1024 * 1024 * 1024} + /> + )} +
+
+ )} +
+ + {/* Scheduler */} + {(data.scheduler.autoDownloadCount > 0 || data.scheduler.autoScrapeCount > 0) && ( +
+

Scheduler

+
+ {data.scheduler.autoDownloadCount > 0 && ( +
+ {data.scheduler.autoDownloadCount} auto-download user{data.scheduler.autoDownloadCount !== 1 ? 's' : ''} +
+ )} + {data.scheduler.autoScrapeCount > 0 && ( +
+ {data.scheduler.autoScrapeCount} auto-scrape job{data.scheduler.autoScrapeCount !== 1 ? 's' : ''} +
+ )} +
+
+ )} + + {/* Recent Downloads */} + {data.recentDownloads?.length > 0 && ( +
+

Recent Downloads

+
+ {data.recentDownloads.map((dl, i) => ( +
+
+ + {dl.media_type === 'video' ? 'VID' : 'IMG'} + + @{dl.user_id} + {dl.filename} +
+ + {dl.downloaded_at ? new Date(dl.downloaded_at + 'Z').toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) : ''} + +
+ ))} +
+
+ )} +
+ ) +} + +function StatCard({ label, value, icon, color }) { + const colors = { + blue: 'bg-blue-500/10 text-blue-400', + purple: 'bg-purple-500/10 text-purple-400', + green: 'bg-green-500/10 text-green-400', + amber: 'bg-amber-500/10 text-amber-400', + } + + return ( +
+
+ {label} +
{icon}
+
+

{value}

+
+ ) +} + +function HealthItem({ label, value, ok }) { + return ( +
+
+ {label}: + {value} +
+ ) +} + +function FilesIcon() { + return ( + + + + ) +} + +function StorageIcon() { + return ( + + + + ) +} + +function FoldersIcon() { + return ( + + + + ) +} + +function TodayIcon() { + return ( + + + + ) +} diff --git a/client/src/pages/Downloads.jsx b/client/src/pages/Downloads.jsx index 2e21fda..380e255 100644 --- a/client/src/pages/Downloads.jsx +++ b/client/src/pages/Downloads.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react' -import { getDownloadHistory, getActiveDownloads, getUser, getScrapeJobs } from '../api' +import { getDownloadHistory, getActiveDownloadDetails, getUser, getScrapeJobs } from '../api' import Spinner from '../components/Spinner' export default function Downloads() { @@ -10,6 +10,8 @@ export default function Downloads() { const [error, setError] = useState(null) const pollRef = useRef(null) const [usernames, setUsernames] = useState({}) + const prevCompleted = useRef({}) + const [speeds, setSpeeds] = useState({}) useEffect(() => { loadAll() @@ -26,7 +28,7 @@ export default function Downloads() { const [histData, activeData, scrapeData] = await Promise.all([ getDownloadHistory(), - getActiveDownloads(), + getActiveDownloadDetails(), getScrapeJobs(), ]) @@ -47,11 +49,24 @@ export default function Downloads() { const startPolling = () => { pollRef.current = setInterval(async () => { const [activeData, scrapeData] = await Promise.all([ - getActiveDownloads(), + getActiveDownloadDetails(), getScrapeJobs(), ]) if (!activeData.error) { const list = Array.isArray(activeData) ? activeData : [] + // Calculate download speed (files/sec) based on completed delta + const newSpeeds = {} + for (const dl of list) { + const uid = dl.user_id + const prev = prevCompleted.current[uid] || 0 + const delta = (dl.completed || 0) - prev + prevCompleted.current[uid] = dl.completed || 0 + if (delta > 0) { + newSpeeds[uid] = (delta / 2).toFixed(1) // 2s poll interval + } + } + setSpeeds((prev) => ({ ...prev, ...newSpeeds })) + setActive((prev) => { if (prev.length > 0 && list.length < prev.length) { getDownloadHistory().then((h) => { @@ -141,6 +156,11 @@ export default function Downloads() {

{dl.completed || 0} / {dl.total || '?'} files + {speeds[uid] && ( + + ({speeds[uid]} files/s) + + )} {dl.errors > 0 && ( ({dl.errors} error{dl.errors !== 1 ? 's' : ''}) @@ -154,12 +174,26 @@ export default function Downloads() {

{/* Progress Bar */} -
+
+ + {/* Recent File Log */} + {dl.recentFiles && dl.recentFiles.length > 0 && ( +
+ {dl.recentFiles.map((f, fi) => ( +
+ + {f.filename?.slice(0, 50)} +
+ ))} +
+ )}
) })} diff --git a/client/src/pages/Feed.jsx b/client/src/pages/Feed.jsx index ff7a88c..1e4b9f0 100644 --- a/client/src/pages/Feed.jsx +++ b/client/src/pages/Feed.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { getFeed } from '../api' +import { getFeed, downloadPost } from '../api' import PostCard from '../components/PostCard' import Spinner from '../components/Spinner' import LoadMoreButton from '../components/LoadMoreButton' @@ -11,6 +11,7 @@ export default function Feed() { const [loadingMore, setLoadingMore] = useState(false) const [hasMore, setHasMore] = useState(true) const [error, setError] = useState(null) + const [downloadingPosts, setDownloadingPosts] = useState(new Set()) useEffect(() => { loadFeed() @@ -63,6 +64,22 @@ export default function Feed() { setLoadingMore(false) } + const handleDownloadPost = async (post) => { + const author = post.author || post.fromUser || {} + const media = post.media || [] + if (!author.id || media.length === 0) return + + setDownloadingPosts((prev) => new Set([...prev, post.id])) + const postedAt = post.postedAt || post.createdAt || post.publishedAt || null + const result = await downloadPost(author.id, author.username, post.id, media, postedAt) + if (result.error) console.error('Download failed:', result.error) + setDownloadingPosts((prev) => { + const next = new Set(prev) + next.delete(post.id) + return next + }) + } + if (loading) return if (error) { @@ -100,7 +117,7 @@ export default function Feed() {
{posts.map((post) => (
- +
))}
diff --git a/client/src/pages/Gallery.jsx b/client/src/pages/Gallery.jsx index 55f7c90..bc61b1e 100644 --- a/client/src/pages/Gallery.jsx +++ b/client/src/pages/Gallery.jsx @@ -1,8 +1,9 @@ import { useState, useEffect, useCallback, useRef } from 'react' -import { getGalleryFolders, getGalleryFiles, getSettings } from '../api' +import { getGalleryFolders, getGalleryFiles, getSettings, deleteMediaFile, markGallerySeen } from '../api' import Spinner from '../components/Spinner' import LoadMoreButton from '../components/LoadMoreButton' import HlsVideo from '../components/HlsVideo' +import GridWall, { GridWallPicker } from '../components/GridWall' const PAGE_SIZE = 50 @@ -11,14 +12,12 @@ function GalleryThumbnail({ file }) { 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 + const imgSrc = `/api/gallery/thumb/${encodeURIComponent(file.folder)}/${encodeURIComponent(file.filename)}${retries > 0 ? `?r=${retries}` : ''}` // Images — lazy load with retry const handleError = () => { - if (retries < 2) { - setTimeout(() => setRetries(r => r + 1), 1000 + retries * 1500) + if (retries < 4) { + setTimeout(() => setRetries(r => r + 1), 2000 + retries * 2000) } else { setErrored(true) } @@ -68,6 +67,116 @@ const TYPE_OPTIONS = [ { value: 'video', label: 'Videos' }, ] +const SORT_OPTIONS = [ + { value: 'latest', label: 'Latest' }, + { value: 'oldest', label: 'Oldest' }, + { value: 'largest', label: 'Largest' }, + { value: 'smallest', label: 'Smallest' }, + { value: 'name', label: 'Name' }, + { value: 'shuffle', label: 'Shuffle' }, +] + +function VideoPreviewThumbnail({ file, children }) { + const [hovering, setHovering] = useState(false) + const videoRef = useRef(null) + const hoverTimerRef = useRef(null) + const stopTimerRef = useRef(null) + const isTouchDevice = typeof window !== 'undefined' && 'ontouchstart' in window + + if (file.type !== 'video' || isTouchDevice) return children + + const handleMouseEnter = () => { + hoverTimerRef.current = setTimeout(() => setHovering(true), 400) + } + + const handleMouseLeave = () => { + clearTimeout(hoverTimerRef.current) + clearTimeout(stopTimerRef.current) + setHovering(false) + if (videoRef.current) { + videoRef.current.pause() + videoRef.current.removeAttribute('src') + videoRef.current.load() + } + } + + const handleLoadedMetadata = () => { + const video = videoRef.current + if (!video) return + if (video.duration > 15) { + video.currentTime = video.duration / 2 + stopTimerRef.current = setTimeout(() => { + if (videoRef.current) { + videoRef.current.pause() + } + }, 7000) + } + } + + return ( +
+ {children} + {hovering && ( +
+ ) +} + +function SortDropdown({ value, onChange }) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + const current = SORT_OPTIONS.find((o) => o.value === value) + + useEffect(() => { + if (!open) return + const handleClick = (e) => { + if (ref.current && !ref.current.contains(e.target)) setOpen(false) + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [open]) + + return ( +
+ + {open && ( +
+ {SORT_OPTIONS.map((opt) => ( + + ))} +
+ )} +
+ ) +} + export default function Gallery() { const [folders, setFolders] = useState([]) const [files, setFiles] = useState([]) @@ -79,12 +188,19 @@ export default function Gallery() { const [activeFolder, setActiveFolder] = useState(null) // kept for API compat const [checkedFolders, setCheckedFolders] = useState(new Set()) const [typeFilter, setTypeFilter] = useState('all') - const [shuffle, setShuffle] = useState(false) - const [lightbox, setLightbox] = useState(null) + const [sortOption, setSortOption] = useState('latest') + const [lightboxIndex, setLightboxIndex] = useState(null) const [slideshow, setSlideshow] = useState(false) const [hlsEnabled, setHlsEnabled] = useState(false) const [userFilterOpen, setUserFilterOpen] = useState(false) const [userSearch, setUserSearch] = useState('') + const [filtersExpanded, setFiltersExpanded] = useState(false) + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') + const [searchText, setSearchText] = useState('') + const [gridWallLayout, setGridWallLayout] = useState(null) + const [gridPickerOpen, setGridPickerOpen] = useState(false) + const gridPickerRef = useRef(null) const filterRef = useRef(null) useEffect(() => { @@ -94,6 +210,7 @@ export default function Gallery() { getGalleryFolders().then((data) => { if (!data.error) setFolders(Array.isArray(data) ? data : []) }) + markGallerySeen() }, []) // Close popover on click outside @@ -148,9 +265,12 @@ export default function Gallery() { const data = await getGalleryFiles({ ...getFilterParams(), type: typeFilter !== 'all' ? typeFilter : undefined, - sort: shuffle ? 'shuffle' : 'latest', + sort: sortOption, offset, limit: PAGE_SIZE, + dateFrom: dateFrom || undefined, + dateTo: dateTo || undefined, + search: searchText || undefined, }) if (data.error) { @@ -162,16 +282,37 @@ export default function Gallery() { setLoading(false) setLoadingMore(false) - }, [getFilterParams, typeFilter, shuffle, files.length]) + }, [getFilterParams, typeFilter, sortOption, dateFrom, dateTo, searchText, files.length]) useEffect(() => { loadFiles(true) - }, [activeFolder, checkedFolders, typeFilter, shuffle]) + }, [activeFolder, checkedFolders, typeFilter, sortOption, dateFrom, dateTo, searchText]) const handleReshuffle = () => { loadFiles(true) } + // Grid wall: fetch shuffled items from current filter + const fetchGridItems = useCallback(async (limit) => { + const data = await getGalleryFiles({ + ...getFilterParams(), + type: typeFilter !== 'all' ? typeFilter : undefined, + sort: 'shuffle', + limit, + dateFrom: dateFrom || undefined, + dateTo: dateTo || undefined, + search: searchText || undefined, + }) + return data.error ? [] : data.files + }, [getFilterParams, typeFilter, dateFrom, dateTo, searchText]) + + const hasAdvancedFilters = dateFrom || dateTo || searchText + const clearAdvancedFilters = () => { + setDateFrom('') + setDateTo('') + setSearchText('') + } + const hasMore = files.length < total return ( @@ -300,21 +441,11 @@ export default function Gallery() { ))}
- {/* Shuffle Toggle */} - + {/* Sort Dropdown */} + {/* Reshuffle Button */} - {shuffle && ( + {sortOption === 'shuffle' && ( + {/* Slideshow Button */} + + {/* Grid Wall Button */} +
+ + {gridPickerOpen && ( + { setGridWallLayout(layout); setGridPickerOpen(false) }} + onClose={() => setGridPickerOpen(false)} + /> + )} +
+ {/* Advanced Filters Panel */} + {filtersExpanded && ( +
+
+ + setDateFrom(e.target.value)} + className="px-2.5 py-1.5 text-sm rounded-md border border-[#333] bg-[#111] text-gray-300 focus:outline-none focus:border-[#0095f6]" + /> +
+
+ + setDateTo(e.target.value)} + className="px-2.5 py-1.5 text-sm rounded-md border border-[#333] bg-[#111] text-gray-300 focus:outline-none focus:border-[#0095f6]" + /> +
+
+ + setSearchText(e.target.value)} + placeholder="Filename or folder..." + className="w-full px-2.5 py-1.5 text-sm rounded-md border border-[#333] bg-[#111] text-gray-300 placeholder-gray-600 focus:outline-none focus:border-[#0095f6]" + /> +
+ {hasAdvancedFilters && ( + + )} +
+ )} + {loading ? ( ) : error ? ( @@ -355,9 +561,11 @@ export default function Gallery() {
setLightbox(file)} + onClick={() => setLightboxIndex(i)} > - + + + {/* Date badge */} {file.postedAt && ( @@ -368,8 +576,24 @@ export default function Gallery() {
)} + {/* Delete button */} + + {/* Overlay */} -
+

@{file.folder}

@@ -377,7 +601,7 @@ export default function Gallery() { {/* Video badge */} {file.type === 'video' && ( -
+
@@ -398,8 +622,29 @@ export default function Gallery() { )} {/* Lightbox */} - {lightbox && ( - setLightbox(null)} /> + {lightboxIndex !== null && files[lightboxIndex] && ( + setLightboxIndex(null)} + hasMore={hasMore} + onLoadMore={() => loadFiles(false)} + onDelete={async (i) => { + const f = files[i] + const res = await deleteMediaFile(f.folder, f.filename) + if (!res.error) { + const newFiles = files.filter((_, idx) => idx !== i) + setFiles(newFiles) + if (newFiles.length === 0) { + setLightboxIndex(null) + } else if (i >= newFiles.length) { + setLightboxIndex(newFiles.length - 1) + } + } + }} + /> )} {/* Slideshow */} @@ -407,54 +652,133 @@ export default function Gallery() { setSlideshow(false)} /> )} + + {/* Grid Wall */} + {gridWallLayout && ( + setGridWallLayout(null)} + /> + )}
) } -function Lightbox({ file, hlsEnabled, onClose }) { +function Lightbox({ files, index, setIndex, hlsEnabled, onClose, onDelete, hasMore, onLoadMore }) { + const file = files[index] + const hasPrev = index > 0 + const hasNext = index < files.length - 1 || hasMore + const loadingMoreRef = useRef(false) + + // Auto-load more files when navigating near the end + useEffect(() => { + if (hasMore && index >= files.length - 5 && !loadingMoreRef.current) { + loadingMoreRef.current = true + onLoadMore() + setTimeout(() => { loadingMoreRef.current = false }, 1000) + } + }, [index, files.length, hasMore, onLoadMore]) + useEffect(() => { const handleKey = (e) => { if (e.key === 'Escape') onClose() + if (e.key === 'ArrowRight' && hasNext) setIndex(index + 1) + if (e.key === 'ArrowLeft' && hasPrev) setIndex(index - 1) } window.addEventListener('keydown', handleKey) return () => window.removeEventListener('keydown', handleKey) - }, [onClose]) + }, [onClose, index, hasPrev, hasNext, setIndex]) return (
- +
+ e.stopPropagation()} + className="p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors" + title="Open in new window" + > + + + + + + +
+ + {/* Prev arrow */} + {hasPrev && ( + + )} + + {/* Next arrow */} + {hasNext && ( + + )}
e.stopPropagation()}> {file.type === 'video' ? ( ) : ( - + + + )} -

@{file.folder}

+

+ @{file.folder} · {index + 1} / {files.length} +

) @@ -471,12 +795,12 @@ function Slideshow({ filterParams, typeFilter, hlsEnabled, onClose }) { const current = items[index] || null const isVideo = current?.type === 'video' - // Load shuffled batch respecting type filter + // Load ALL matching files shuffled const loadBatch = useCallback(async () => { const params = { ...filterParams, sort: 'shuffle', - limit: 500, + limit: 100000, } if (typeFilter === 'image') params.type = 'image' else if (typeFilter === 'video') params.type = 'video' @@ -553,14 +877,29 @@ function Slideshow({ filterParams, typeFilter, hlsEnabled, onClose }) { return (
- +
+ {current && ( + + + + + + )} + +
{paused && (
@@ -602,6 +941,14 @@ function Slideshow({ filterParams, typeFilter, hlsEnabled, onClose }) { ) } +function GridWallIcon({ className }) { + return ( + + + + ) +} + function SlideshowIcon({ className }) { return ( @@ -634,6 +981,14 @@ function UsersFilterIcon({ className }) { ) } +function FilterIcon({ className }) { + return ( + + + + ) +} + function GalleryIcon({ className }) { return ( diff --git a/client/src/pages/Login.jsx b/client/src/pages/Login.jsx index d0051c9..e001597 100644 --- a/client/src/pages/Login.jsx +++ b/client/src/pages/Login.jsx @@ -106,8 +106,8 @@ export default function Login({ onAuth }) { }, 1000) } - const startDupScan = async () => { - const result = await scanDuplicates() + const startDupScan = async (mode = 'everywhere') => { + const result = await scanDuplicates(mode) if (result.error) return if (result.status === 'already_running') { navigate('/duplicates') @@ -397,9 +397,9 @@ export default function Login({ onAuth }) {
-

Video Thumbnails

+

Thumbnails

- Generate preview thumbnails for all downloaded videos. New videos get thumbnails automatically when browsed. + Generate preview thumbnails for all downloaded media. New files get thumbnails automatically when browsed.

@@ -443,14 +449,24 @@ export default function Login({ onAuth }) { Scan downloaded media for duplicate files and review them side by side.

- +
+ + +
{dupScan?.running && (
diff --git a/client/src/pages/Scrape.jsx b/client/src/pages/Scrape.jsx index 992c73d..b124602 100644 --- a/client/src/pages/Scrape.jsx +++ b/client/src/pages/Scrape.jsx @@ -3,10 +3,22 @@ import { startForumScrape, startCoomerScrape, startMediaLinkScrape, + startLeakGalleryScrape, + startMegaScrape, + startYtdlpScrape, getScrapeJobs, getScrapeJob, cancelScrapeJob, detectForumPages, + getAutoScrapeJobs, + addAutoScrapeJob, + removeAutoScrapeJob, + getForumSites, + createForumSite, + updateForumSite, + deleteForumSite, + getFlareSolverrStatus, + refreshForumCookies, } from '../api' import LogViewer from '../components/LogViewer' @@ -24,8 +36,16 @@ export default function Scrape() { const [forumStart, setForumStart] = useState(1) const [forumEnd, setForumEnd] = useState(10) const [forumDelay, setForumDelay] = useState(1.0) + const [forumCookies, setForumCookies] = useState('') + const [forumSites, setForumSites] = useState([]) + const [selectedSiteId, setSelectedSiteId] = useState('') const [forumDetecting, setForumDetecting] = useState(false) const [forumSubmitting, setForumSubmitting] = useState(false) + const [forumUsername, setForumUsername] = useState('') + const [forumPassword, setForumPassword] = useState('') + const [forumLastPageOnly, setForumLastPageOnly] = useState(false) + const [flareAvailable, setFlareAvailable] = useState(false) + const [flareRefreshing, setFlareRefreshing] = useState(false) // Coomer form const [coomerUrl, setCoomerUrl] = useState('') @@ -42,19 +62,142 @@ export default function Scrape() { const [mlDelay, setMlDelay] = useState(500) const [mlSubmitting, setMlSubmitting] = useState(false) + // LeakGallery form + const [lgUrl, setLgUrl] = useState('') + const [lgFolder, setLgFolder] = useState('') + const [lgPages, setLgPages] = useState(100) + const [lgWorkers, setLgWorkers] = useState(3) + const [lgDelay, setLgDelay] = useState(300) + const [lgSubmitting, setLgSubmitting] = useState(false) + const [lgAutoScrape, setLgAutoScrape] = useState(false) + + // Mega form + const [megaUrl, setMegaUrl] = useState('') + const [megaFolder, setMegaFolder] = useState('') + const [megaWorkers, setMegaWorkers] = useState(3) + const [megaSubmitting, setMegaSubmitting] = useState(false) + + // yt-dlp form + const [ytUrl, setYtUrl] = useState('') + const [ytQuality, setYtQuality] = useState('best') + const [ytCustomFormat, setYtCustomFormat] = useState('') + const [ytEmbedMetadata, setYtEmbedMetadata] = useState(true) + const [ytEmbedThumbnail, setYtEmbedThumbnail] = useState(true) + const [ytEmbedSubs, setYtEmbedSubs] = useState(true) + const [ytWriteSubs, setYtWriteSubs] = useState(false) + const [ytSubLangs, setYtSubLangs] = useState('en') + const [ytRestrictFilenames, setYtRestrictFilenames] = useState(true) + const [ytOutputTemplate, setYtOutputTemplate] = useState('%(title)s.%(ext)s') + const [ytPlaylist, setYtPlaylist] = useState(false) + const [ytMaxDownloads, setYtMaxDownloads] = useState('') + const [ytConcurrentFragments, setYtConcurrentFragments] = useState(4) + const [ytRateLimit, setYtRateLimit] = useState('') + const [ytSponsorBlock, setYtSponsorBlock] = useState('off') + const [ytCookiesFile, setYtCookiesFile] = useState('') + const [ytShowAdvanced, setYtShowAdvanced] = useState(false) + const [ytSubmitting, setYtSubmitting] = useState(false) + + // Auto-scrape + const [forumAutoScrape, setForumAutoScrape] = useState(false) + const [coomerAutoScrape, setCoomerAutoScrape] = useState(false) + const [mlAutoScrape, setMlAutoScrape] = useState(false) + const [megaAutoScrape, setMegaAutoScrape] = useState(false) + const [autoScrapeJobs, setAutoScrapeJobs] = useState([]) + const [error, setError] = useState(null) useEffect(() => { fetchJobs() + fetchAutoScrapeJobs() + fetchForumSites() + checkFlareStatus() pollRef.current = setInterval(fetchJobs, 2000) return () => { if (pollRef.current) clearInterval(pollRef.current) } }, []) + const checkFlareStatus = async () => { + const data = await getFlareSolverrStatus() + setFlareAvailable(data.available === true) + } + const fetchJobs = async () => { const data = await getScrapeJobs() if (!data.error) setJobs(Array.isArray(data) ? data : []) } + const fetchAutoScrapeJobs = async () => { + const data = await getAutoScrapeJobs() + if (!data.error) setAutoScrapeJobs(Array.isArray(data) ? data : []) + } + + const handleDeleteAutoScrape = async (id) => { + await removeAutoScrapeJob(id) + fetchAutoScrapeJobs() + } + + const fetchForumSites = async () => { + const data = await getForumSites() + if (!data.error) setForumSites(Array.isArray(data) ? data : []) + } + + const handleSelectSite = (siteId) => { + setSelectedSiteId(siteId) + if (!siteId) { + setForumCookies('') + setForumUsername('') + setForumPassword('') + return + } + const site = forumSites.find(s => s.id === parseInt(siteId)) + if (site) { + setForumCookies(site.cookies || '') + setForumUsername(site.username || '') + setForumPassword(site.password || '') + } + } + + const handleSaveForumSite = async () => { + const name = prompt('Forum name (e.g. SimpCity):') + if (!name) return + const data = await createForumSite({ name, baseUrl: '', cookies: forumCookies, username: forumUsername, password: forumPassword }) + if (!data.error) { + await fetchForumSites() + setSelectedSiteId(String(data.id)) + } + } + + const handleUpdateForumSiteCookies = async () => { + if (!selectedSiteId) return + await updateForumSite(selectedSiteId, { cookies: forumCookies, username: forumUsername, password: forumPassword }) + fetchForumSites() + } + + const handleRefreshCookies = async () => { + if (!selectedSiteId) return + setFlareRefreshing(true) + setError(null) + const data = await refreshForumCookies(selectedSiteId) + setFlareRefreshing(false) + if (data.error) { + setError(`Cookie refresh failed: ${data.error}`) + } else { + setForumCookies(data.cookies || '') + fetchForumSites() + } + } + + const handleDeleteForumSite = async () => { + if (!selectedSiteId) return + const site = forumSites.find(s => s.id === parseInt(selectedSiteId)) + if (!confirm(`Delete saved forum "${site?.name}"?`)) return + await deleteForumSite(selectedSiteId) + setSelectedSiteId('') + setForumCookies('') + setForumUsername('') + setForumPassword('') + fetchForumSites() + } + const toggleLogs = async (jobId) => { const isExpanded = expandedLogs[jobId] setExpandedLogs(prev => ({ ...prev, [jobId]: !isExpanded })) @@ -113,7 +256,7 @@ export default function Scrape() { if (forumAutoDetect && url.length > 10) { setForumDetecting(true) - const data = await detectForumPages(url) + const data = await detectForumPages(url, forumCookies) setForumDetecting(false) if (!data.error && data.maxPage) { setForumEnd(data.maxPage) @@ -132,13 +275,22 @@ export default function Scrape() { if (!forumUrl || !forumFolder) return setError(null) setForumSubmitting(true) - const data = await startForumScrape({ + const config = { url: forumUrl, folderName: forumFolder, startPage: forumStart, endPage: forumEnd, delay: forumDelay, - }) + cookies: forumCookies, + siteId: selectedSiteId ? parseInt(selectedSiteId) : null, + lastPageOnly: forumLastPageOnly, + } + const data = await startForumScrape(config) + if (forumAutoScrape) { + await addAutoScrapeJob({ type: 'forum', url: forumUrl, folderName: forumFolder, config }) + fetchAutoScrapeJobs() + setForumAutoScrape(false) + } setForumSubmitting(false) if (data.error) { setError(data.error) @@ -151,12 +303,18 @@ export default function Scrape() { if (!coomerUrl || !coomerFolder) return setError(null) setCoomerSubmitting(true) - const data = await startCoomerScrape({ + const config = { url: coomerUrl, folderName: coomerFolder, pages: coomerPages, workers: coomerWorkers, - }) + } + const data = await startCoomerScrape(config) + if (coomerAutoScrape) { + await addAutoScrapeJob({ type: 'coomer', url: coomerUrl, folderName: coomerFolder, config }) + fetchAutoScrapeJobs() + setCoomerAutoScrape(false) + } setCoomerSubmitting(false) if (data.error) { setError(data.error) @@ -168,8 +326,13 @@ export default function Scrape() { const deriveMlFolder = (url) => { if (!url) return '' try { - const m = new URL(url).pathname.match(/\/(?:model|media)\/(\d+)/) + const parsed = new URL(url) + // fapello.to: /model/{id} or /media/{id} + const m = parsed.pathname.match(/\/(?:model|media)\/(\d+)/) if (m) return `medialink_${m[1]}` + // fapello.com: /{slug}/ + const slugMatch = parsed.pathname.match(/^\/([a-zA-Z0-9_-]+)\/?$/) + if (slugMatch) return slugMatch[1] return '' } catch { return '' } } @@ -185,13 +348,19 @@ export default function Scrape() { if (!mlUrl || !mlFolder) return setError(null) setMlSubmitting(true) - const data = await startMediaLinkScrape({ + const config = { url: mlUrl, folderName: mlFolder, pages: mlPages, workers: mlWorkers, delay: mlDelay, - }) + } + const data = await startMediaLinkScrape(config) + if (mlAutoScrape) { + await addAutoScrapeJob({ type: 'medialink', url: mlUrl, folderName: mlFolder, config }) + fetchAutoScrapeJobs() + setMlAutoScrape(false) + } setMlSubmitting(false) if (data.error) { setError(data.error) @@ -200,6 +369,118 @@ export default function Scrape() { } } + const deriveLgFolder = (url) => { + if (!url) return '' + try { + const m = new URL(url).pathname.match(/^\/([a-zA-Z0-9_.-]+)\/?$/) + if (m) return m[1].replace(/^_/, '').replace(/_$/, '') + return '' + } catch { return '' } + } + + const handleLgUrlChange = (url) => { + setLgUrl(url) + if (!lgFolder || lgFolder === deriveLgFolder(lgUrl)) { + setLgFolder(deriveLgFolder(url)) + } + } + + const submitLeakGallery = async () => { + if (!lgUrl || !lgFolder) return + setError(null) + setLgSubmitting(true) + const config = { + url: lgUrl, + folderName: lgFolder, + pages: lgPages, + workers: lgWorkers, + delay: lgDelay, + } + const data = await startLeakGalleryScrape(config) + if (lgAutoScrape) { + await addAutoScrapeJob({ type: 'leakgallery', url: lgUrl, folderName: lgFolder, config }) + fetchAutoScrapeJobs() + setLgAutoScrape(false) + } + setLgSubmitting(false) + if (data.error) { + setError(data.error) + } else { + fetchJobs() + } + } + + const deriveMegaFolder = (url) => { + if (!url) return '' + try { + const m = url.match(/\/folder\/([^#]+)/) + if (m) return `mega_${m[1].slice(0, 8)}` + return '' + } catch { return '' } + } + + const handleMegaUrlChange = (url) => { + setMegaUrl(url) + if (!megaFolder || megaFolder === deriveMegaFolder(megaUrl)) { + setMegaFolder(deriveMegaFolder(url)) + } + } + + const submitMega = async () => { + if (!megaUrl || !megaFolder) return + setError(null) + setMegaSubmitting(true) + const config = { + url: megaUrl, + folderName: megaFolder, + workers: megaWorkers, + } + const data = await startMegaScrape(config) + if (megaAutoScrape) { + await addAutoScrapeJob({ type: 'mega', url: megaUrl, folderName: megaFolder, config }) + fetchAutoScrapeJobs() + setMegaAutoScrape(false) + } + setMegaSubmitting(false) + if (data.error) { + setError(data.error) + } else { + fetchJobs() + } + } + + const submitYtdlp = async () => { + if (!ytUrl) return + setError(null) + setYtSubmitting(true) + const config = { + url: ytUrl, + quality: ytQuality, + customFormat: ytCustomFormat, + embedMetadata: ytEmbedMetadata, + embedThumbnail: ytEmbedThumbnail, + embedSubs: ytEmbedSubs, + writeSubs: ytWriteSubs, + subLangs: ytSubLangs, + restrictFilenames: ytRestrictFilenames, + outputTemplate: ytOutputTemplate, + playlist: ytPlaylist, + maxDownloads: ytMaxDownloads ? parseInt(ytMaxDownloads) : 0, + concurrentFragments: ytConcurrentFragments, + rateLimit: ytRateLimit, + sponsorBlock: ytSponsorBlock, + cookiesFile: ytCookiesFile, + } + const data = await startYtdlpScrape(config) + setYtSubmitting(false) + if (data.error) { + setError(data.error) + } else { + fetchJobs() + setYtUrl('') + } + } + const handleCancel = async (jobId) => { await cancelScrapeJob(jobId) fetchJobs() @@ -222,41 +503,31 @@ export default function Scrape() {

Scrape

-

Download images from forums, Coomer/Kemono, and gallery sites

+

Download media from forums, galleries, and video sites

{/* Tab Selector */} -
- - - +
+ {[ + { id: 'forum', label: 'Forums' }, + { id: 'coomer', label: 'Coomer' }, + { id: 'medialink', label: 'MediaLink' }, + { id: 'leakgallery', label: 'LeakGallery' }, + { id: 'mega', label: 'Mega' }, + { id: 'ytdlp', label: 'Video' }, + ].map(t => ( + + ))}
{error && ( @@ -301,7 +572,8 @@ export default function Scrape() { 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" + disabled={forumLastPageOnly} + 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 ${forumLastPageOnly ? 'opacity-40 cursor-not-allowed' : ''}`} />
@@ -314,7 +586,8 @@ export default function Scrape() { 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" + disabled={forumLastPageOnly} + 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 ${forumLastPageOnly ? 'opacity-40 cursor-not-allowed' : ''}`} />
@@ -331,16 +604,139 @@ export default function Scrape() {
+
+ +
+ + {selectedSiteId && ( + <> + + + + )} +
+
+ + {selectedSiteId && ( +
+
+ + setForumUsername(e.target.value)} + placeholder="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" + /> +
+
+ + setForumPassword(e.target.value)} + placeholder="password" + 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" + /> +
+
+ )} + + {selectedSiteId && flareAvailable && forumUsername && forumPassword && ( + + )} + + {selectedSiteId && !flareAvailable && forumUsername && forumPassword && ( +
+ FlareSolverr not available — auto cookie refresh disabled. Start the flaresolverr container or paste cookies manually. +
+ )} + +
+
+ + {forumCookies && !selectedSiteId && ( + + )} +
+