- 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>
12 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Build & Run
npm run install:all # Install server + client deps
npm run dev # Concurrent: client (5173) + server (3001)
npm run dev:server # Server only with --watch
npm run dev:client # Vite dev server only
npm run build # Build client to client/dist/
npm start # Production server (serves client/dist/)
Deployment
Remote server: root@10.3.3.11, password Intel22, path /mnt/user/appdata/OFApp/
# Deploy
sshpass -p 'Intel22' rsync -avz --exclude='node_modules' --exclude='.git' --exclude='dist' --exclude='data' /Users/treyt/Desktop/code/OFApp/ root@10.3.3.11:/mnt/user/appdata/OFApp/
# Rebuild container
sshpass -p 'Intel22' ssh root@10.3.3.11 'cd /mnt/user/appdata/OFApp && docker-compose down && docker-compose up -d --build'
# Check logs
sshpass -p 'Intel22' ssh root@10.3.3.11 'docker logs ofapp 2>&1 | tail -20'
Port mapping: HTTP 3002->3001, HTTPS 3003->3443. Data volumes at /mnt/user/downloads/OFApp/{db,media}.
Architecture
Client: React 18 + Vite + Tailwind. Pages in client/src/pages/, API wrappers in client/src/api.js. Vite proxies /api to localhost:3001 in dev.
Server: Express (ESM modules). All routes are in separate router files mounted in server/index.js.
Server Modules
| File | Purpose |
|---|---|
proxy.js |
OF API proxy with auth headers + media/DRM proxy endpoints |
signing.js |
Dynamic request signing (fetches rules from GitHub, auto-refreshes hourly) |
download.js |
Background media download orchestration with resume support |
db.js |
SQLite (better-sqlite3, WAL mode) — auth, download history, cursors, settings, media index |
gallery.js |
Gallery API (SQL-backed media index), video thumbnails, duplicate scanning, filesystem scanner |
hls.js |
On-demand HLS transcoding via FFmpeg |
settings.js |
Key-value settings API |
scrape.js |
Job-based scraping orchestrator — forum + Coomer/Kemono + MediaLink scrapers with progress/logs |
scrapers/forum.js |
Cheerio-based forum image scraper (XenForo, generic forums) |
scrapers/coomer.js |
Coomer/Kemono API scraper with concurrent downloads |
scrapers/medialink.js |
Fapello/gallery-site JSON API scraper — paginates media API, downloads full-size images + videos |
widevine.js |
Widevine CDM — WVD parsing, used for browser DRM playback only (not downloads) |
drm-download.js |
DRM video download pipeline — MPD parsing, pywidevine key acquisition, decrypt, mux |
pywidevine_helper.py |
Python helper using pywidevine lib for Widevine license challenges (called as subprocess) |
Auth & Signing Flow
All OF API requests go through proxy.js which adds auth headers (Cookie, user-id, x-bc, x-of-rev, app-token) plus a dynamic sign header generated by signing.js. The signing uses SHA-1 with parameters from community-maintained rules fetched from GitHub repos (rafa-9, datawhores, DATAHOARDERS — tried in order).
DRM Video Playback
OF videos use PallyCon DRM (Widevine for Chrome/Firefox, FairPlay for Safari). The flow:
MediaGrid.jsxdetectsfiles.drmon video items, extracts DASH manifest URL + CloudFront cookies (and keeps the parent entity context:entityType+entityId, e.g.post/{postId})DrmVideo.jsxuses Shaka Player to load the DASH manifest via/api/drm-hlsproxy- Shaka's request filter rewrites CDN segment URLs through
/api/drm-hls(attaches CloudFront cookies server-side) - Widevine license challenges go to
/api/drm-licensewhich forwards to OF's DRM resolver:- Own media:
POST /api2/v2/users/media/{mediaId}/drm/?type=widevine - Posts/messages/etc:
POST /api2/v2/users/media/{mediaId}/drm/{entityType}/{entityId}?type=widevine(e.g.post/{postId})
- Own media:
- The
/api/drm-hlsproxy handles DASH manifests (injects<BaseURL>), HLS playlists (rewrites URLs), and binary segments (pipes through)
EME requires HTTPS — the server auto-generates a self-signed cert at /data/certs/ on first boot. Access DRM content via https://10.3.3.11:3003.
Media Proxy
CDN media from *.onlyfans.com is proxied through /api/media-proxy to avoid CORS issues. It passes Range headers and caches responses. URLs from public. and thumbs. subdomains are NOT proxied (client-side check in MediaGrid.jsx).
Download System
download.js runs background downloads in-memory (not persisted across restarts). It paginates through /api2/v2/users/:id/posts/medias, downloads each file to MEDIA_PATH/:username/, records in SQLite, and supports cursor-based resume for interrupted downloads.
DRM Video Downloads: When a media item has files.drm.manifest.dash, the downloader uses drm-download.js to:
- Fetch & parse the DASH MPD manifest (supports both segmented and on-demand profiles)
- Extract Widevine PSSH (filters by system ID
edef8ba9-..., not PlayReady) - Call
pywidevine_helper.pyas an async subprocess to get content keys — the helper routes license requests through the local/api/drm-licenseproxy (which handles OF auth/signing) - Download encrypted tracks (single file for on-demand, init+segments for segmented)
- Decrypt with ffmpeg (
-decryption_key) → mux audio+video
Requires a .wvd (Widevine Device) file at WVD_PATH and Python 3 + pywidevine installed. Without the .wvd, DRM videos are skipped with a log message. The Dockerfile installs python3 py3-pip and pywidevine.
Gallery Media Index
The gallery uses a SQLite media_files table instead of scanning the filesystem on every request. This makes gallery page loads instant even with tens of thousands of files.
- Startup scan: On server boot,
scanMediaFiles()walksMEDIA_PATH, stats every media file, and upserts intomedia_files. Also prunes rows for deleted files/folders. Takes ~6s for ~38k files. - Incremental updates:
download.js,scrapers/forum.js,scrapers/coomer.js, andscrapers/medialink.jsinsert intomedia_filesafter each file is written. - Manual rescan:
POST /api/gallery/rescantriggers a full re-index (accessible via Settings page "Rescan Media Library" button). Use this after manually adding/removing files from the media folder. - Schema:
media_files(folder, filename, type, size, modified, posted_at)with indexes on folder, type, modified, posted_at. Unique on(folder, filename).
Scraper System
scrape.js provides a job-based scraping system with three scraper types:
- Forum scraper (
scrapers/forum.js): Scrapes image-hosting forum threads (XenForo-style). Uses Cheerio to parse HTML, finds images in post content areas, derives full-size URLs from thumbnails, filters out avatars/emojis/icons. Supports page range, delay between pages, auto-detection of max page count. - Coomer/Kemono scraper (
scrapers/coomer.js): Uses the Coomer/Kemono API (/api/v1/{service}/user/{userId}/posts) to fetch posts and download attached files. Supports configurable concurrency (1-20 workers) and skips already-downloaded files. - MediaLink scraper (
scrapers/medialink.js): Scrapes gallery sites like Fapello. Accepts a URL likehttps://fapello.to/model/12345, calls the site's JSON API (GET /api/media/{userId}/{page}/{order}withX-Requested-With: XMLHttpRequest+Refererheaders to avoid 403), and downloadsnewUrl(full-size) for images or videos (type "2"). Paginates until the API returns empty. Supports configurable concurrency (1-10 workers), delay, and max pages.
Jobs run in-memory with progress tracking, cancellation support, and per-job log history. The client polls for updates every 2s.
Setup Guides
Creating a WVD File (Widevine Device)
DRM downloads require a .wvd file containing a Widevine L3 CDM. To create one:
-
Get CDM files from a rooted Android device or dumped Chrome CDM:
client_id.bin— the client identification blobprivate_key.pem— the RSA private key (PKCS#1 or PKCS#8)
-
Install pywidevine (if not already):
pip3 install pywidevine -
Create the .wvd file:
pywidevine create-device -t ANDROID -l 3 -k private_key.pem -c client_id.bin -o device.wvd -
Place the file on the server at
/mnt/user/downloads/OFApp/cdm/device.wvd(mapped to/data/cdm/device.wvdinside Docker).
Note: Google periodically revokes L3 CDMs. When revoked, you'll need new client_id.bin + private_key.pem from a different device/source and regenerate the .wvd.
Updating Auth Cookies
Auth cookies expire periodically. When you see 401 errors in the logs, re-extract from the browser:
-
Open Firefox (or Chrome) and log into onlyfans.com
-
Open DevTools → Network tab
-
Click any request to
onlyfans.com/api2/... -
From the request headers, grab:
- Cookie — the full cookie string (contains
sess,auth_id,st,csrf, etc.) - user-id — your numeric user ID (e.g.
101476031) - x-bc — browser checksum hash (e.g.
8dbf86e101ff9265acbfbeb648d74e85092b6206) - user-agent — your browser's UA string
- Cookie — the full cookie string (contains
-
Update via the app's settings page, or directly in the DB:
sqlite3 /mnt/user/downloads/OFApp/db/ofapp.db DELETE FROM auth_config; INSERT INTO auth_config (cookie, user_id, x_bc, user_agent) VALUES ( 'sess=...; auth_id=...; st=...; ...', '101476031', '8dbf86e101ff9265acbfbeb648d74e85092b6206', 'Mozilla/5.0 ...' );In Firefox specifically: DevTools → Storage tab → Cookies →
onlyfans.comto see individual cookie values, or Network tab → right-click a request → Copy → Copy Request Headers.
Environment Variables
| Variable | Default | Notes |
|---|---|---|
PORT |
3001 | HTTP |
HTTPS_PORT |
3443 | HTTPS for DRM/EME |
DB_PATH |
/data/db/ofapp.db | SQLite |
MEDIA_PATH |
/data/media | Downloaded files |
DOWNLOAD_DELAY |
1000 | ms between downloads |
HLS_ENABLED |
false | FFmpeg HLS transcoding |
WVD_PATH |
/data/cdm/device.wvd | Widevine device file for DRM downloads |
Key Gotchas
- Widevine license challenges must be treated as raw binary.
server/index.jsmountsexpress.raw()for/api/drm-licensebefore the globalexpress.json(), andserver/proxy.jsprefersreq.body(Buffer) with a fallback stream read. - For subscribed content,
/api/drm-licensemust includeentityType+entityId(e.g.entityType=post&entityId=<postId>). Missing entity context typically yields OF403 User have no permissions, which Shaka surfaces as6007(LICENSE_REQUEST_FAILED). - Request signing rules normalize differently across GitHub sources —
signing.jshandles both old format (static_param) and new format (static-param with dashes). - CloudFront cookies for DRM content are IP-locked to the server's public IP (47.185.183.191). They won't work from a different IP.
- The
/api/drm-hlsproxy skipsskd://URIs in HLS manifests (FairPlay key identifiers, Safari only). - Server log prefixes:
[signing],[drm-license],[drm-hls],[download],[drm-download],[widevine],[media-proxy],[hls],[gallery]. - DRM downloads require a Widevine L3 device file (
.wvd). Place it at/data/cdm/device.wvd(or setWVD_PATH). Without it, DRM videos are silently skipped during bulk downloads. - The pywidevine subprocess MUST be called with
execAsync(notexecSync) because it calls back to the local/api/drm-licenseproxy —execSyncwould deadlock the Node.js event loop. - MPD PSSH extraction must filter for the Widevine system ID (
edef8ba9-79d6-4ace-a3c8-27dcd51d21ed), not PlayReady (9a04f079). The MPD may contain both. - OF uses DASH on-demand profile (
isoff-on-demand:2011) with<BaseURL>+<SegmentBase>— the entire track is a single encrypted file, not segmented. - Widevine DRM requires a two-step license flow: first POST
[0x08, 0x04]for the service certificate, then send the actual challenge. PallyCon's license server is strict about challenge format — our custom protobuf CDM was rejected, so we use pywidevine (proven library) instead. - Auth cookies expire — user needs to re-extract from browser when they get 401 errors.
- Gallery endpoints read from the
media_filesSQLite index, not the filesystem. If files are added/removed manually outside the app, use the "Rescan Media Library" button in Settings (orPOST /api/gallery/rescan) to re-index. - The forum scraper requires the
cheerionpm package (in server dependencies). It's used for HTML parsing only on the server side.