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:
Trey t
2026-02-16 11:29:11 -06:00
parent c60de19348
commit 1e5f54f60b
28 changed files with 4736 additions and 203 deletions
+141 -62
View File
@@ -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} &middot; {index + 1} / {images.length}
@{current.folder} &middot; {index + 1} / {items.length}
{isVideo && ' (video)'}
</div>
)}
</div>