Files
OFApp/CLAUDE.md
Trey t 1e5f54f60b 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>
2026-02-16 11:29:11 -06:00

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:

  1. MediaGrid.jsx detects files.drm on video items, extracts DASH manifest URL + CloudFront cookies (and keeps the parent entity context: entityType + entityId, e.g. post/{postId})
  2. DrmVideo.jsx uses Shaka Player to load the DASH manifest via /api/drm-hls proxy
  3. Shaka's request filter rewrites CDN segment URLs through /api/drm-hls (attaches CloudFront cookies server-side)
  4. Widevine license challenges go to /api/drm-license which 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})
  5. The /api/drm-hls proxy 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:

  1. Fetch & parse the DASH MPD manifest (supports both segmented and on-demand profiles)
  2. Extract Widevine PSSH (filters by system ID edef8ba9-..., not PlayReady)
  3. Call pywidevine_helper.py as an async subprocess to get content keys — the helper routes license requests through the local /api/drm-license proxy (which handles OF auth/signing)
  4. Download encrypted tracks (single file for on-demand, init+segments for segmented)
  5. 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.

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() walks MEDIA_PATH, stats every media file, and upserts into media_files. Also prunes rows for deleted files/folders. Takes ~6s for ~38k files.
  • Incremental updates: download.js, scrapers/forum.js, scrapers/coomer.js, and scrapers/medialink.js insert into media_files after each file is written.
  • Manual rescan: POST /api/gallery/rescan triggers 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 like https://fapello.to/model/12345, calls the site's JSON API (GET /api/media/{userId}/{page}/{order} with X-Requested-With: XMLHttpRequest + Referer headers to avoid 403), and downloads newUrl (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:

  1. Get CDM files from a rooted Android device or dumped Chrome CDM:

    • client_id.bin — the client identification blob
    • private_key.pem — the RSA private key (PKCS#1 or PKCS#8)
  2. Install pywidevine (if not already):

    pip3 install pywidevine
    
  3. Create the .wvd file:

    pywidevine create-device -t ANDROID -l 3 -k private_key.pem -c client_id.bin -o device.wvd
    
  4. Place the file on the server at /mnt/user/downloads/OFApp/cdm/device.wvd (mapped to /data/cdm/device.wvd inside 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:

  1. Open Firefox (or Chrome) and log into onlyfans.com

  2. Open DevTools → Network tab

  3. Click any request to onlyfans.com/api2/...

  4. 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
  5. 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.com to 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.js mounts express.raw() for /api/drm-license before the global express.json(), and server/proxy.js prefers req.body (Buffer) with a fallback stream read.
  • For subscribed content, /api/drm-license must include entityType + entityId (e.g. entityType=post&entityId=<postId>). Missing entity context typically yields OF 403 User have no permissions, which Shaka surfaces as 6007 (LICENSE_REQUEST_FAILED).
  • Request signing rules normalize differently across GitHub sources — signing.js handles 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-hls proxy skips skd:// 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 set WVD_PATH). Without it, DRM videos are silently skipped during bulk downloads.
  • The pywidevine subprocess MUST be called with execAsync (not execSync) because it calls back to the local /api/drm-license proxy — execSync would 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_files SQLite index, not the filesystem. If files are added/removed manually outside the app, use the "Rescan Media Library" button in Settings (or POST /api/gallery/rescan) to re-index.
  • The forum scraper requires the cheerio npm package (in server dependencies). It's used for HTML parsing only on the server side.