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:
+141
-62
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user