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

View File

@@ -43,6 +43,23 @@ db.exec(`
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS media_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
folder TEXT NOT NULL,
filename TEXT NOT NULL,
type TEXT NOT NULL,
size INTEGER NOT NULL,
modified REAL NOT NULL,
posted_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(folder, filename)
);
CREATE INDEX IF NOT EXISTS idx_media_folder ON media_files(folder);
CREATE INDEX IF NOT EXISTS idx_media_type ON media_files(type);
CREATE INDEX IF NOT EXISTS idx_media_modified ON media_files(modified);
CREATE INDEX IF NOT EXISTS idx_media_posted_at ON media_files(posted_at);
`);
// Migration: add posted_at column if missing
@@ -127,3 +144,103 @@ export function getDownloadStats() {
'SELECT user_id, COUNT(*) as file_count, MAX(downloaded_at) as last_download FROM download_history GROUP BY user_id'
).all();
}
// --- media_files helpers ---
const upsertMediaStmt = db.prepare(`
INSERT INTO media_files (folder, filename, type, size, modified, posted_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(folder, filename) DO UPDATE SET
size = excluded.size,
modified = excluded.modified,
posted_at = COALESCE(excluded.posted_at, media_files.posted_at)
`);
export function upsertMediaFile(folder, filename, type, size, modified, postedAt) {
upsertMediaStmt.run(folder, filename, type, size, modified, postedAt || null);
}
export const upsertMediaFileBatch = db.transaction((files) => {
for (const f of files) {
upsertMediaStmt.run(f.folder, f.filename, f.type, f.size, f.modified, f.postedAt || null);
}
});
export function removeMediaFile(folder, filename) {
db.prepare('DELETE FROM media_files WHERE folder = ? AND filename = ?').run(folder, filename);
}
export function getMediaFolders() {
return db.prepare(`
SELECT folder AS name,
COUNT(*) AS total,
SUM(CASE WHEN type = 'image' THEN 1 ELSE 0 END) AS images,
SUM(CASE WHEN type = 'video' THEN 1 ELSE 0 END) AS videos
FROM media_files
GROUP BY folder
ORDER BY folder
`).all();
}
export function getMediaFiles({ folder, folders, type, sort, offset, limit }) {
const conditions = [];
const params = [];
if (folder) {
conditions.push('folder = ?');
params.push(folder);
} else if (folders && folders.length > 0) {
conditions.push(`folder IN (${folders.map(() => '?').join(',')})`);
params.push(...folders);
}
if (type && type !== 'all') {
conditions.push('type = ?');
params.push(type);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const countRow = db.prepare(`SELECT COUNT(*) AS total FROM media_files ${where}`).get(...params);
const total = countRow.total;
let orderBy;
if (sort === 'shuffle') {
orderBy = 'ORDER BY RANDOM()';
} else {
// 'latest' — prefer posted_at, fall back to modified
orderBy = 'ORDER BY COALESCE(posted_at, datetime(modified / 1000, \'unixepoch\')) DESC';
}
const rows = db.prepare(`
SELECT folder, filename, type, size, modified, posted_at
FROM media_files
${where}
${orderBy}
LIMIT ? OFFSET ?
`).all(...params, limit || 50, offset || 0);
return { total, rows };
}
export function getAllIndexedFolders() {
return db.prepare('SELECT DISTINCT folder FROM media_files').all().map(r => r.folder);
}
export function removeStaleFiles(folder, existingFilenames) {
const rows = db.prepare('SELECT filename FROM media_files WHERE folder = ?').all(folder);
const existing = new Set(existingFilenames);
const toDelete = rows.filter(r => !existing.has(r.filename));
if (toDelete.length > 0) {
const del = db.prepare('DELETE FROM media_files WHERE folder = ? AND filename = ?');
const batch = db.transaction((files) => {
for (const f of files) del.run(folder, f.filename);
});
batch(toDelete);
}
return toDelete.length;
}
export function getMediaFileCount() {
return db.prepare('SELECT COUNT(*) AS count FROM media_files').get().count;
}

View File

@@ -1,10 +1,11 @@
import { Router } from 'express';
import fetch from 'node-fetch';
import { mkdirSync, createWriteStream } from 'fs';
import { mkdirSync, createWriteStream, statSync } from 'fs';
import { pipeline } from 'stream/promises';
import { extname } from 'path';
import { getAuthConfig, isMediaDownloaded, recordDownload, getDownloadStats, saveCursor, getCursor, clearCursor } from './db.js';
import { getAuthConfig, isMediaDownloaded, recordDownload, getDownloadStats, saveCursor, getCursor, clearCursor, upsertMediaFile } from './db.js';
import { createSignedHeaders, getRules } from './signing.js';
import { downloadDrmMedia, hasCDM } from './drm-download.js';
const router = Router();
const OF_BASE = 'https://onlyfans.com';
@@ -111,21 +112,32 @@ async function runDownload(userId, authConfig, postLimit, resume, username) {
}
const data = await fetchOF(ofPath, authConfig);
const mediaList = Array.isArray(data) ? data : (data.list || []);
postsFetched += mediaList.length;
const rawList = Array.isArray(data) ? data : (data.list || []);
postsFetched += rawList.length;
for (const media of mediaList) {
const postDate = media.postedAt || media.createdAt || media.publishedAt || null;
const postId = media.postId || media.post_id || media.id;
allMedia.push({ postId, media, postDate });
// The /posts/medias endpoint returns post objects with nested media[].
// Flatten into individual media items.
for (const item of rawList) {
const postDate = item.postedAt || item.createdAt || item.publishedAt || null;
const postId = item.id;
if (Array.isArray(item.media) && item.media.length > 0) {
for (const m of item.media) {
allMedia.push({ postId, media: m, postDate });
}
} else {
// Fallback: treat the item itself as a media object
const pid = item.postId || item.post_id || item.id;
allMedia.push({ postId: pid, media: item, postDate });
}
}
hasMore = Array.isArray(data) ? data.length === batchSize : !!data.hasMore;
if (!Array.isArray(data)) {
beforePublishTime = data.tailMarker || null;
} else if (mediaList.length > 0) {
} else if (rawList.length > 0) {
// For flat array responses, use the last item's date as cursor
const last = mediaList[mediaList.length - 1];
const last = rawList[rawList.length - 1];
beforePublishTime = last.postedAt || last.createdAt || null;
}
@@ -160,8 +172,50 @@ async function runDownload(userId, authConfig, postLimit, resume, username) {
continue;
}
// Check for DRM-protected video
const drm = media.files?.drm;
if (drm?.manifest?.dash && drm?.signature?.dash) {
if (!hasCDM()) {
console.log(`[download] Skipping DRM media ${mediaId} (no WVD file configured)`);
progress.completed++;
continue;
}
try {
const sig = drm.signature.dash;
const cfCookies = {
cp: sig['CloudFront-Policy'],
cs: sig['CloudFront-Signature'],
ck: sig['CloudFront-Key-Pair-Id'],
};
const drmFilename = `${postId}_${mediaId}_video.mp4`;
const userDir = `${MEDIA_PATH}/${username || userId}`;
await downloadDrmMedia({
mpdUrl: drm.manifest.dash,
cfCookies,
mediaId,
entityType: 'post',
entityId: String(postId),
outputDir: userDir,
outputFilename: drmFilename,
});
recordDownload(userId, String(postId), mediaId, 'video', drmFilename, postDate);
try {
const st = statSync(`${userDir}/${drmFilename}`);
upsertMediaFile(username || String(userId), drmFilename, 'video', st.size, st.mtimeMs, postDate);
} catch { /* stat may fail if file was cleaned up */ }
progress.completed++;
} catch (err) {
console.error(`[download] DRM download failed for media ${mediaId}:`, err.message);
progress.errors++;
progress.completed++;
}
await sleep(DOWNLOAD_DELAY);
continue;
}
const url = getMediaUrl(media);
if (!url) {
console.log(`[download] Skipping media ${mediaId} (no URL)`);
progress.completed++;
continue;
}
@@ -175,6 +229,11 @@ async function runDownload(userId, authConfig, postLimit, resume, username) {
await downloadFile(url, dest);
recordDownload(userId, String(postId), mediaId, mediaType, filename, postDate);
try {
const st = statSync(dest);
const indexType = /^(photo|image)$/i.test(mediaType) ? 'image' : /^(video|gif)$/i.test(mediaType) ? 'video' : null;
if (indexType) upsertMediaFile(username || String(userId), filename, indexType, st.size, st.mtimeMs, postDate);
} catch { /* ignore */ }
progress.completed++;
} catch (err) {
console.error(`[download] Error downloading media ${media.id}:`, err.message);

373
server/drm-download.js Normal file
View File

@@ -0,0 +1,373 @@
import { mkdirSync, createWriteStream, existsSync, rmSync } from 'node:fs';
import { execSync, exec as execCb } from 'node:child_process';
import { promisify } from 'node:util';
const execAsync = promisify(execCb);
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import fetch from 'node-fetch';
import { getAuthConfig } from './db.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OF_BASE = 'https://onlyfans.com';
const WVD_PATH = process.env.WVD_PATH || '/data/cdm/device.wvd';
const HELPER_PATH = `${__dirname}/pywidevine_helper.py`;
export function hasCDM() {
return existsSync(WVD_PATH);
}
// ==================== MPD Parser ====================
function parseMpd(mpdText, baseUrl) {
const result = { pssh: null, video: null, audio: null };
// Extract Widevine PSSH (system ID edef8ba9-79d6-4ace-a3c8-27dcd51d21ed)
// Must find the ContentProtection block for Widevine, not PlayReady
const cpRegex = /<ContentProtection[^>]*schemeIdUri="urn:uuid:edef8ba9[^"]*"[^>]*>([\s\S]*?)<\/ContentProtection>/gi;
let cpMatch;
while ((cpMatch = cpRegex.exec(mpdText)) !== null) {
const psshInner = cpMatch[1].match(/cenc:pssh[^>]*>([^<]+)</i);
if (psshInner) {
result.pssh = psshInner[1].trim();
break;
}
}
// Fallback: if Widevine-specific block not found, try any cenc:pssh
if (!result.pssh) {
const psshMatch = mpdText.match(/cenc:pssh[^>]*>([^<]+)</i);
if (psshMatch) result.pssh = psshMatch[1].trim();
}
// Split into AdaptationSets
const asRegex = /<AdaptationSet([^>]*)>([\s\S]*?)<\/AdaptationSet>/gi;
let match;
while ((match = asRegex.exec(mpdText)) !== null) {
const asAttrs = match[1];
const asBody = match[2];
const mimeMatch = asAttrs.match(/mimeType="([^"]+)"/);
const mime = mimeMatch ? mimeMatch[1] : '';
const isVideo = mime.includes('video');
const isAudio = mime.includes('audio');
if (!isVideo && !isAudio) continue;
// Find all Representations, pick highest bandwidth
const reps = [];
const repRegex = /<Representation([^>]*)(?:\/>|>([\s\S]*?)<\/Representation>)/gi;
let repMatch;
while ((repMatch = repRegex.exec(asBody)) !== null) {
const bwMatch = repMatch[1].match(/bandwidth="(\d+)"/);
const idMatch = repMatch[1].match(/id="([^"]+)"/);
const bwAttr = repMatch[1].match(/bandwidth="(\d+)"/);
reps.push({
id: idMatch ? idMatch[1] : '1',
bandwidth: bwMatch ? parseInt(bwMatch[1]) : 0,
body: repMatch[2] || '',
});
}
reps.sort((a, b) => b.bandwidth - a.bandwidth);
const best = reps[0];
if (!best) continue;
// Try SegmentTemplate from Representation first, then AdaptationSet
let segInfo = parseSegmentTemplate(best.body, best.id, best.bandwidth, baseUrl);
if (!segInfo) segInfo = parseSegmentTemplate(asBody, best.id, best.bandwidth, baseUrl);
// Try SegmentList as fallback
if (!segInfo) segInfo = parseSegmentList(best.body || asBody, baseUrl);
// Try SegmentBase (on-demand profile) as final fallback
if (!segInfo) segInfo = parseSegmentBase(best.body || asBody, baseUrl);
if (segInfo) {
if (isVideo) result.video = segInfo;
else result.audio = segInfo;
}
}
return result;
}
function parseSegmentTemplate(text, repId, bandwidth, baseUrl) {
const tmplMatch = text.match(/<SegmentTemplate([^>]*)(?:\/>|>([\s\S]*?)<\/SegmentTemplate>)/i);
if (!tmplMatch) return null;
const attrs = tmplMatch[1];
const body = tmplMatch[2] || '';
const initMatch = attrs.match(/initialization="([^"]+)"/);
const mediaMatch = attrs.match(/media="([^"]+)"/);
const startNumMatch = attrs.match(/startNumber="(\d+)"/);
if (!initMatch || !mediaMatch) return null;
const initTmpl = initMatch[1];
const mediaTmpl = mediaMatch[1];
const startNumber = startNumMatch ? parseInt(startNumMatch[1]) : 1;
const usesTime = mediaTmpl.includes('$Time$');
const initUrl = resolveUrl(
replaceTemplateVars(initTmpl, repId, bandwidth),
baseUrl,
);
const segmentUrls = [];
const timelineMatch = body.match(/<SegmentTimeline>([\s\S]*?)<\/SegmentTimeline>/i);
if (timelineMatch) {
let currentTime = 0;
let segNum = startNumber;
const sElements = [...timelineMatch[1].matchAll(/<S\s+([^/]*?)\/?\s*>/gi)];
for (const s of sElements) {
const tMatch = s[1].match(/t="(\d+)"/);
const dMatch = s[1].match(/d="(\d+)"/);
const rMatch = s[1].match(/r="(-?\d+)"/);
if (tMatch) currentTime = parseInt(tMatch[1]);
const duration = dMatch ? parseInt(dMatch[1]) : 0;
let repeat = rMatch ? parseInt(rMatch[1]) : 0;
if (repeat < 0) repeat = 9999; // r=-1 means repeat until end; bounded by 404 in download
for (let i = 0; i <= repeat; i++) {
let url;
if (usesTime) {
url = replaceTemplateVars(mediaTmpl, repId, bandwidth)
.replace(/\$Time\$/g, String(currentTime));
} else {
url = replaceTemplateVars(mediaTmpl, repId, bandwidth)
.replace(/\$Number\$/g, String(segNum))
.replace(/\$Number%(\d+)d\$/g, (_, w) => String(segNum).padStart(parseInt(w), '0'));
}
segmentUrls.push(resolveUrl(url, baseUrl));
currentTime += duration;
segNum++;
}
}
} else {
// No timeline — use a large count, download will stop on 404
const startNum = startNumber;
for (let i = 0; i < 10000; i++) {
const url = replaceTemplateVars(mediaTmpl, repId, bandwidth)
.replace(/\$Number\$/g, String(startNum + i))
.replace(/\$Number%(\d+)d\$/g, (_, w) => String(startNum + i).padStart(parseInt(w), '0'));
segmentUrls.push(resolveUrl(url, baseUrl));
}
}
return { initUrl, segmentUrls };
}
function parseSegmentList(text, baseUrl) {
const initMatch = text.match(/<Initialization\s+sourceURL="([^"]+)"/i);
if (!initMatch) return null;
const initUrl = resolveUrl(initMatch[1], baseUrl);
const segmentUrls = [];
const segRegex = /<SegmentURL\s+media="([^"]+)"/gi;
let m;
while ((m = segRegex.exec(text)) !== null) {
segmentUrls.push(resolveUrl(m[1], baseUrl));
}
return { initUrl, segmentUrls };
}
function parseSegmentBase(text, baseUrl) {
const baseUrlMatch = text.match(/<BaseURL>([^<]+)<\/BaseURL>/i);
if (!baseUrlMatch) return null;
const fileUrl = resolveUrl(baseUrlMatch[1].trim(), baseUrl);
// On-demand: single file, no segments. Mark as on-demand so the download
// pipeline can just fetch the whole file instead of init+segments.
return { onDemand: true, fileUrl };
}
function replaceTemplateVars(template, repId, bandwidth) {
return template
.replace(/\$RepresentationID\$/g, repId)
.replace(/\$Bandwidth\$/g, String(bandwidth));
}
function resolveUrl(url, baseUrl) {
if (url.startsWith('http')) return url;
return baseUrl + url;
}
// ==================== Download Pipeline ====================
async function fetchWithCookies(url, cfCookies) {
const cookieParts = [];
if (cfCookies.cp) cookieParts.push(`CloudFront-Policy=${cfCookies.cp}`);
if (cfCookies.cs) cookieParts.push(`CloudFront-Signature=${cfCookies.cs}`);
if (cfCookies.ck) cookieParts.push(`CloudFront-Key-Pair-Id=${cfCookies.ck}`);
const headers = {};
if (cookieParts.length > 0) headers['Cookie'] = cookieParts.join('; ');
const res = await fetch(url, { headers });
return res;
}
async function downloadWholeFile(url, cfCookies, outputPath) {
const res = await fetchWithCookies(url, cfCookies);
if (!res.ok) throw new Error(`Download failed: ${res.status} ${url}`);
const ws = createWriteStream(outputPath);
for await (const chunk of res.body) ws.write(chunk);
ws.end();
await new Promise((resolve, reject) => {
ws.on('finish', resolve);
ws.on('error', reject);
});
console.log(`[drm-download] Downloaded whole file → ${outputPath}`);
}
async function downloadSegments(track, cfCookies, outputPath) {
const ws = createWriteStream(outputPath);
// Init segment
const initRes = await fetchWithCookies(track.initUrl, cfCookies);
if (!initRes.ok) throw new Error(`Init segment failed: ${initRes.status}`);
for await (const chunk of initRes.body) ws.write(chunk);
// Media segments
let downloaded = 0;
for (const segUrl of track.segmentUrls) {
const segRes = await fetchWithCookies(segUrl, cfCookies);
if (segRes.status === 404 || segRes.status === 403) break; // end of segments
if (!segRes.ok) throw new Error(`Segment failed: ${segRes.status} ${segUrl}`);
for await (const chunk of segRes.body) ws.write(chunk);
downloaded++;
}
ws.end();
await new Promise((resolve, reject) => {
ws.on('finish', resolve);
ws.on('error', reject);
});
console.log(`[drm-download] Downloaded ${downloaded} segments → ${outputPath}`);
}
export async function downloadDrmMedia({
mpdUrl,
cfCookies,
mediaId,
entityType,
entityId,
outputDir,
outputFilename,
}) {
if (!existsSync(WVD_PATH)) throw new Error('No CDM available — place a .wvd file at ' + WVD_PATH);
const authConfig = getAuthConfig();
if (!authConfig) throw new Error('No auth config');
console.log(`[drm-download] Starting DRM download for media ${mediaId}`);
// 1. Fetch & parse MPD
const mpdRes = await fetchWithCookies(mpdUrl, cfCookies);
if (!mpdRes.ok) throw new Error(`MPD fetch failed: ${mpdRes.status}`);
const mpdText = await mpdRes.text();
const mpdBaseUrl = mpdUrl.substring(0, mpdUrl.lastIndexOf('/') + 1);
const mpd = parseMpd(mpdText, mpdBaseUrl);
if (!mpd.pssh) {
throw new Error('No Widevine PSSH found in MPD');
}
if (!mpd.video) {
throw new Error('No video track found in MPD');
}
const videoDesc = mpd.video.onDemand ? 'on-demand' : `${mpd.video.segmentUrls.length} segs`;
const audioDesc = mpd.audio ? (mpd.audio.onDemand ? 'on-demand' : `${mpd.audio.segmentUrls.length} segs`) : 'none';
console.log(`[drm-download] MPD parsed: video=${videoDesc}, audio=${audioDesc}`);
// 2. Get content key via pywidevine (routed through local proxy)
const PORT = process.env.PORT || 3001;
const proxyParams = new URLSearchParams({ mediaId });
if (entityType) proxyParams.set('entityType', entityType);
if (entityId) proxyParams.set('entityId', entityId);
const proxyUrl = `http://localhost:${PORT}/api/drm-license?${proxyParams}`;
console.log(`[drm-download] Getting content key via pywidevine (proxy → OF)`);
let keyResult;
try {
const { stdout, stderr } = await execAsync(
`python3 "${HELPER_PATH}" "${WVD_PATH}" "${mpd.pssh}" "${proxyUrl}"`,
{ timeout: 60000, maxBuffer: 1024 * 1024 },
);
keyResult = JSON.parse(stdout.trim());
} catch (err) {
const stderr = err.stderr?.toString() || '';
const stdout = err.stdout?.toString() || '';
throw new Error(`pywidevine failed: ${stderr || stdout || err.message}`);
}
if (keyResult.error) throw new Error(`License failed: ${keyResult.error}`);
if (!keyResult.keys?.length) throw new Error('No content keys returned');
const contentKey = keyResult.keys.find(k => k.type === 'CONTENT') || keyResult.keys[0];
console.log(`[drm-download] Got ${keyResult.keys.length} key(s), KID=${contentKey.kid}`);
// 3. Download encrypted segments
mkdirSync(outputDir, { recursive: true });
const tmpDir = `${outputDir}/.drm-tmp-${mediaId}`;
mkdirSync(tmpDir, { recursive: true });
try {
console.log('[drm-download] Downloading video...');
if (mpd.video.onDemand) {
await downloadWholeFile(mpd.video.fileUrl, cfCookies, `${tmpDir}/video_enc.mp4`);
} else {
await downloadSegments(mpd.video, cfCookies, `${tmpDir}/video_enc.mp4`);
}
let hasAudio = false;
if (mpd.audio) {
console.log('[drm-download] Downloading audio...');
if (mpd.audio.onDemand) {
await downloadWholeFile(mpd.audio.fileUrl, cfCookies, `${tmpDir}/audio_enc.mp4`);
} else if (mpd.audio.segmentUrls?.length > 0) {
await downloadSegments(mpd.audio, cfCookies, `${tmpDir}/audio_enc.mp4`);
}
hasAudio = true;
}
// 4. Decrypt with ffmpeg
const keyHex = contentKey.key;
console.log('[drm-download] Decrypting...');
execSync(
`ffmpeg -y -loglevel error -decryption_key ${keyHex} -i "${tmpDir}/video_enc.mp4" -c copy "${tmpDir}/video.mp4"`,
{ stdio: 'pipe', timeout: 300000 },
);
if (hasAudio) {
execSync(
`ffmpeg -y -loglevel error -decryption_key ${keyHex} -i "${tmpDir}/audio_enc.mp4" -c copy "${tmpDir}/audio.mp4"`,
{ stdio: 'pipe', timeout: 300000 },
);
}
// 5. Mux into final file
const outputPath = `${outputDir}/${outputFilename}`;
if (hasAudio) {
console.log('[drm-download] Muxing audio + video...');
execSync(
`ffmpeg -y -loglevel error -i "${tmpDir}/video.mp4" -i "${tmpDir}/audio.mp4" -c copy -movflags +faststart "${outputPath}"`,
{ stdio: 'pipe', timeout: 300000 },
);
} else {
execSync(
`ffmpeg -y -loglevel error -i "${tmpDir}/video.mp4" -c copy -movflags +faststart "${outputPath}"`,
{ stdio: 'pipe', timeout: 300000 },
);
}
console.log(`[drm-download] Complete: ${outputPath}`);
return outputPath;
} finally {
// Cleanup temp files
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
}
}

View File

@@ -1,10 +1,22 @@
import { Router } from 'express';
import { readdirSync, statSync } from 'fs';
import { readdirSync, statSync, existsSync, mkdirSync, unlinkSync, createReadStream } from 'fs';
import { join, extname } from 'path';
import { getPostDateByFilename, getSetting } from './db.js';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { createHash } from 'crypto';
import {
getPostDateByFilename, getSetting,
upsertMediaFileBatch, removeMediaFile, removeStaleFiles,
getMediaFolders, getMediaFiles, getMediaFileCount, getAllIndexedFolders,
} from './db.js';
const execFileAsync = promisify(execFile);
const router = Router();
const MEDIA_PATH = process.env.MEDIA_PATH || './data/media';
const THUMB_DIR = '.thumbs';
// In-flight thumb generation promises (dedup concurrent requests for same file)
const thumbInFlight = new Map();
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp']);
const VIDEO_EXTS = new Set(['.mp4', '.mov', '.avi', '.webm', '.mkv', '.m4v']);
@@ -16,113 +28,166 @@ function getMediaType(filename) {
return null;
}
// GET /api/gallery/folders — list all folders with file counts
router.get('/api/gallery/folders', (req, res, next) => {
try {
const entries = readdirSync(MEDIA_PATH, { withFileTypes: true });
const folders = [];
// --- Background filesystem scanner ---
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
const folderPath = join(MEDIA_PATH, entry.name);
const files = readdirSync(folderPath).filter((f) => {
return !f.startsWith('.') && getMediaType(f) !== null;
});
if (files.length > 0) {
const images = files.filter((f) => getMediaType(f) === 'image').length;
const videos = files.filter((f) => getMediaType(f) === 'video').length;
folders.push({ name: entry.name, total: files.length, images, videos });
}
export function scanMediaFiles() {
const startTime = Date.now();
console.log('[gallery] Starting media index scan...');
if (!existsSync(MEDIA_PATH)) {
console.log('[gallery] Media path does not exist, skipping scan');
return;
}
let entries;
try {
entries = readdirSync(MEDIA_PATH, { withFileTypes: true });
} catch (err) {
console.error('[gallery] Failed to read media path:', err.message);
return;
}
const scannedFolders = new Set();
let totalFiles = 0;
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
const folderName = entry.name;
scannedFolders.add(folderName);
const dirPath = join(MEDIA_PATH, folderName);
let files;
try {
files = readdirSync(dirPath);
} catch { continue; }
const batch = [];
const validFilenames = [];
for (const file of files) {
if (file.startsWith('.')) continue;
const mediaType = getMediaType(file);
if (!mediaType) continue;
validFilenames.push(file);
const filePath = join(dirPath, file);
try {
const stat = statSync(filePath);
const postedAt = getPostDateByFilename(file);
batch.push({
folder: folderName,
filename: file,
type: mediaType,
size: stat.size,
modified: stat.mtimeMs,
postedAt: postedAt || null,
});
} catch { continue; }
}
folders.sort((a, b) => a.name.localeCompare(b.name));
if (batch.length > 0) {
upsertMediaFileBatch(batch);
totalFiles += batch.length;
}
// Remove DB rows for files that no longer exist in this folder
removeStaleFiles(folderName, validFilenames);
}
// Remove DB rows for folders that no longer exist on disk
const indexedFolders = getAllIndexedFolders();
for (const f of indexedFolders) {
if (!scannedFolders.has(f)) {
removeStaleFiles(f, []);
}
}
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
const dbCount = getMediaFileCount();
console.log(`[gallery] Index scan complete: ${totalFiles} files in ${scannedFolders.size} folders (${elapsed}s). DB total: ${dbCount}`);
}
// GET /api/gallery/folders — list all folders with file counts (from DB index)
router.get('/api/gallery/folders', (req, res, next) => {
try {
const folders = getMediaFolders();
res.json(folders);
} catch (err) {
next(err);
}
});
// GET /api/gallery/files?folder=&type=&sort=&offset=&limit=
// GET /api/gallery/files?folder=&type=&sort=&offset=&limit= (from DB index)
router.get('/api/gallery/files', (req, res, next) => {
try {
const { folder, type, sort, offset, limit } = req.query;
const typeFilter = type || 'all'; // all, image, video
const sortMode = sort || 'latest'; // latest, shuffle
const foldersParam = req.query.folders;
const foldersArr = foldersParam
? foldersParam.split(',').map((f) => f.trim()).filter(Boolean)
: undefined;
const offsetNum = parseInt(offset || '0', 10);
const limitNum = parseInt(limit || '50', 10);
const hlsEnabled = (getSetting('hls_enabled') || process.env.HLS_ENABLED) === 'true';
let allFiles = [];
const { total, rows } = getMediaFiles({
folder: folder || undefined,
folders: foldersArr,
type: type || 'all',
sort: sort || 'latest',
offset: offsetNum,
limit: limitNum,
});
const foldersParam = req.query.folders; // comma-separated list
const foldersToScan = folder
? [folder]
: foldersParam
? foldersParam.split(',').map((f) => f.trim()).filter(Boolean)
: readdirSync(MEDIA_PATH, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && !e.name.startsWith('_'))
.map((e) => e.name);
for (const dir of foldersToScan) {
const dirPath = join(MEDIA_PATH, dir);
let files;
try {
files = readdirSync(dirPath);
} catch {
continue;
const files = rows.map((r) => {
const fileObj = {
folder: r.folder,
filename: r.filename,
type: r.type,
size: r.size,
modified: r.modified,
postedAt: r.posted_at || null,
url: `/api/gallery/media/${encodeURIComponent(r.folder)}/${encodeURIComponent(r.filename)}`,
};
if (hlsEnabled && r.type === 'video') {
fileObj.hlsUrl = `/api/hls/${encodeURIComponent(r.folder)}/${encodeURIComponent(r.filename)}/master.m3u8`;
}
return fileObj;
});
for (const file of files) {
if (file.startsWith('.')) continue;
const mediaType = getMediaType(file);
if (!mediaType) continue;
if (typeFilter !== 'all' && mediaType !== typeFilter) continue;
const filePath = join(dirPath, file);
const stat = statSync(filePath);
const postedAt = getPostDateByFilename(file);
const fileObj = {
folder: dir,
filename: file,
type: mediaType,
size: stat.size,
modified: stat.mtimeMs,
postedAt: postedAt || null,
url: `/api/gallery/media/${encodeURIComponent(dir)}/${encodeURIComponent(file)}`,
};
if ((getSetting('hls_enabled') || process.env.HLS_ENABLED) === 'true' && mediaType === 'video') {
fileObj.hlsUrl = `/api/hls/${encodeURIComponent(dir)}/${encodeURIComponent(file)}/master.m3u8`;
}
allFiles.push(fileObj);
}
}
// Sort
if (sortMode === 'shuffle') {
for (let i = allFiles.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[allFiles[i], allFiles[j]] = [allFiles[j], allFiles[i]];
}
} else {
allFiles.sort((a, b) => {
const aTime = a.postedAt ? new Date(a.postedAt).getTime() : a.modified;
const bTime = b.postedAt ? new Date(b.postedAt).getTime() : b.modified;
return bTime - aTime;
});
}
const total = allFiles.length;
const page = allFiles.slice(offsetNum, offsetNum + limitNum);
res.json({ total, offset: offsetNum, limit: limitNum, files: page });
res.json({ total, offset: offsetNum, limit: limitNum, files });
} catch (err) {
next(err);
}
});
// POST /api/gallery/rescan — trigger a media index rescan
let rescanState = { running: false, lastRun: null, fileCount: 0, elapsed: null };
router.post('/api/gallery/rescan', (req, res) => {
if (rescanState.running) {
return res.json({ status: 'already_running', ...rescanState });
}
rescanState = { running: true, lastRun: null, fileCount: 0, elapsed: null };
res.json({ status: 'started' });
setImmediate(() => {
try {
scanMediaFiles();
rescanState.fileCount = getMediaFileCount();
} catch (err) {
console.error('[gallery] Rescan failed:', err.message);
} finally {
rescanState.running = false;
rescanState.lastRun = new Date().toISOString();
}
});
});
router.get('/api/gallery/rescan/status', (req, res) => {
res.json({ ...rescanState, fileCount: rescanState.running ? rescanState.fileCount : getMediaFileCount() });
});
// GET /api/gallery/media/:folder/:filename — serve actual file
router.get('/api/gallery/media/:folder/:filename', (req, res) => {
const { folder, filename } = req.params;
@@ -140,4 +205,303 @@ router.get('/api/gallery/media/:folder/:filename', (req, res) => {
});
});
// --- Video Thumbnails ---
function getThumbPath(folder, filename) {
const thumbDir = join(MEDIA_PATH, folder, THUMB_DIR);
const thumbName = filename.replace(/\.[^.]+$/, '.jpg');
return { thumbDir, thumbPath: join(thumbDir, thumbName) };
}
async function generateThumb(folder, filename) {
const videoPath = join(MEDIA_PATH, folder, filename);
const { thumbDir, thumbPath } = getThumbPath(folder, filename);
if (existsSync(thumbPath)) return thumbPath;
// Dedup concurrent requests
const key = `${folder}/${filename}`;
if (thumbInFlight.has(key)) return thumbInFlight.get(key);
const promise = (async () => {
try {
if (!existsSync(thumbDir)) mkdirSync(thumbDir, { recursive: true });
await execFileAsync('ffmpeg', [
'-ss', '1',
'-i', videoPath,
'-frames:v', '1',
'-vf', 'scale=320:-1',
'-q:v', '6',
'-y',
thumbPath,
], { timeout: 10000 });
return thumbPath;
} catch (err) {
console.error(`[gallery] thumb failed for ${key}:`, err.message);
return null;
} finally {
thumbInFlight.delete(key);
}
})();
thumbInFlight.set(key, promise);
return promise;
}
// GET /api/gallery/thumb/:folder/:filename — serve or generate a video thumbnail
router.get('/api/gallery/thumb/:folder/:filename', async (req, res) => {
const { folder, filename } = req.params;
if (folder.includes('..') || filename.includes('..')) {
return res.status(400).json({ error: 'Invalid path' });
}
const { thumbPath } = getThumbPath(folder, filename);
// Serve cached thumb immediately
if (existsSync(thumbPath)) {
return res.sendFile(thumbPath, { root: '/' }, (err) => {
if (err && !res.headersSent) res.status(404).json({ error: 'Not found' });
});
}
// Generate on-demand
const result = await generateThumb(folder, filename);
if (result && existsSync(result)) {
res.sendFile(result, { root: '/' }, (err) => {
if (err && !res.headersSent) res.status(500).json({ error: 'Failed to serve thumbnail' });
});
} else {
res.status(500).json({ error: 'Thumbnail generation failed' });
}
});
// Bulk thumbnail generation state
let thumbGenState = { running: false, total: 0, done: 0, errors: 0 };
// POST /api/gallery/generate-thumbs — bulk generate all video thumbnails
router.post('/api/gallery/generate-thumbs', (req, res) => {
if (thumbGenState.running) {
return res.json({ status: 'already_running', ...thumbGenState });
}
// Collect all videos
const videos = [];
const dirs = readdirSync(MEDIA_PATH, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && !e.name.startsWith('_'));
for (const dir of dirs) {
const dirPath = join(MEDIA_PATH, dir.name);
try {
const files = readdirSync(dirPath);
for (const file of files) {
if (file.startsWith('.')) continue;
const ext = extname(file).toLowerCase();
if (VIDEO_EXTS.has(ext)) {
const { thumbPath } = getThumbPath(dir.name, file);
if (!existsSync(thumbPath)) {
videos.push({ folder: dir.name, filename: file });
}
}
}
} catch { continue; }
}
if (videos.length === 0) {
return res.json({ status: 'done', total: 0, done: 0, errors: 0, message: 'All thumbnails already exist' });
}
thumbGenState = { running: true, total: videos.length, done: 0, errors: 0 };
res.json({ status: 'started', total: videos.length });
// Run in background with concurrency limit
(async () => {
const CONCURRENCY = 3;
let i = 0;
const next = async () => {
while (i < videos.length) {
const { folder, filename } = videos[i++];
const result = await generateThumb(folder, filename);
if (result) thumbGenState.done++;
else thumbGenState.errors++;
}
};
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, videos.length) }, () => next()));
thumbGenState.running = false;
})();
});
// GET /api/gallery/generate-thumbs/status — check bulk generation progress
router.get('/api/gallery/generate-thumbs/status', (req, res) => {
res.json(thumbGenState);
});
// --- Duplicate File Scanning ---
let duplicateScanState = { running: false, total: 0, done: 0, groups: 0 };
let duplicateGroups = [];
function hashFilePartial(filePath, bytes = 65536) {
return new Promise((resolve, reject) => {
const hash = createHash('md5');
const stream = createReadStream(filePath, { start: 0, end: bytes - 1 });
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
// POST /api/gallery/scan-duplicates — start background duplicate scan
router.post('/api/gallery/scan-duplicates', (req, res) => {
if (duplicateScanState.running) {
return res.json({ status: 'already_running', ...duplicateScanState });
}
// Phase 1: group all files by size
const bySize = new Map();
const dirs = readdirSync(MEDIA_PATH, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && !e.name.startsWith('_'));
for (const dir of dirs) {
const dirPath = join(MEDIA_PATH, dir.name);
let files;
try { files = readdirSync(dirPath); } catch { continue; }
for (const file of files) {
if (file.startsWith('.')) continue;
const mediaType = getMediaType(file);
if (!mediaType) continue;
const filePath = join(dirPath, file);
try {
const stat = statSync(filePath);
const key = stat.size;
if (!bySize.has(key)) bySize.set(key, []);
bySize.get(key).push({ folder: dir.name, filename: file, type: mediaType, size: stat.size, modified: stat.mtimeMs, filePath });
} catch { continue; }
}
}
// Filter to only sizes with multiple files (potential dupes)
const candidates = [];
for (const [, files] of bySize) {
if (files.length > 1) candidates.push(files);
}
const totalFiles = candidates.reduce((sum, g) => sum + g.length, 0);
duplicateScanState = { running: true, total: totalFiles, done: 0, groups: 0 };
duplicateGroups = [];
res.json({ status: 'started', total: totalFiles, sizeGroups: candidates.length });
// Phase 2: hash candidates in background
(async () => {
for (const sizeGroup of candidates) {
const byHash = new Map();
for (const file of sizeGroup) {
try {
const hash = await hashFilePartial(file.filePath);
if (!byHash.has(hash)) byHash.set(hash, []);
byHash.get(hash).push(file);
} catch { /* skip unreadable */ }
duplicateScanState.done++;
}
for (const [, files] of byHash) {
if (files.length > 1) {
duplicateGroups.push(files.map(({ filePath, ...rest }) => ({
...rest,
path: filePath,
url: `/api/gallery/media/${encodeURIComponent(rest.folder)}/${encodeURIComponent(rest.filename)}`,
thumbUrl: rest.type === 'video'
? `/api/gallery/thumb/${encodeURIComponent(rest.folder)}/${encodeURIComponent(rest.filename)}`
: undefined,
})));
duplicateScanState.groups = duplicateGroups.length;
}
}
}
duplicateScanState.running = false;
})();
});
// GET /api/gallery/scan-duplicates/status
router.get('/api/gallery/scan-duplicates/status', (req, res) => {
res.json(duplicateScanState);
});
// GET /api/gallery/duplicates — return found duplicate groups (paginated)
router.get('/api/gallery/duplicates', (req, res) => {
const offset = parseInt(req.query.offset || '0', 10);
const limit = parseInt(req.query.limit || '20', 10);
const page = duplicateGroups.slice(offset, offset + limit);
res.json({ total: duplicateGroups.length, offset, limit, groups: page });
});
// DELETE /api/gallery/media/:folder/:filename — delete a media file
router.delete('/api/gallery/media/:folder/:filename', (req, res) => {
const { folder, filename } = req.params;
if (folder.includes('..') || filename.includes('..')) {
return res.status(400).json({ error: 'Invalid path' });
}
const filePath = join(MEDIA_PATH, folder, filename);
if (!existsSync(filePath)) {
return res.status(404).json({ error: 'File not found' });
}
try {
unlinkSync(filePath);
removeMediaFile(folder, filename);
// Also delete cached thumbnail if it exists
const { thumbPath } = getThumbPath(folder, filename);
if (existsSync(thumbPath)) {
try { unlinkSync(thumbPath); } catch { /* ignore */ }
}
// Remove from in-memory duplicate groups
for (const group of duplicateGroups) {
const idx = group.findIndex((f) => f.folder === folder && f.filename === filename);
if (idx !== -1) { group.splice(idx, 1); break; }
}
// Remove empty or single-item groups
duplicateGroups = duplicateGroups.filter((g) => g.length > 1);
duplicateScanState.groups = duplicateGroups.length;
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/gallery/duplicates/clean — delete all duplicates, keeping one copy per group
router.post('/api/gallery/duplicates/clean', (req, res) => {
let deleted = 0;
let freed = 0;
let errors = 0;
for (const group of duplicateGroups) {
// Keep the first file, delete the rest
const toDelete = group.slice(1);
for (const file of toDelete) {
const filePath = join(MEDIA_PATH, file.folder, file.filename);
try {
if (existsSync(filePath)) {
unlinkSync(filePath);
freed += file.size;
deleted++;
}
const { thumbPath } = getThumbPath(file.folder, file.filename);
if (existsSync(thumbPath)) {
try { unlinkSync(thumbPath); } catch { /* ignore */ }
}
} catch {
errors++;
}
}
}
// Clear all groups since each now has at most 1 file
duplicateGroups = [];
duplicateScanState.groups = 0;
res.json({ ok: true, deleted, freed, errors });
});
export default router;

View File

@@ -11,6 +11,8 @@ import downloadRouter from './download.js';
import galleryRouter from './gallery.js';
import hlsRouter from './hls.js';
import settingsRouter from './settings.js';
import scrapeRouter from './scrape.js';
import { scanMediaFiles } from './gallery.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -31,6 +33,7 @@ app.use(downloadRouter);
app.use(galleryRouter);
app.use(hlsRouter);
app.use(settingsRouter);
app.use(scrapeRouter);
// Serve static client build in production
const clientDist = join(__dirname, '..', 'client', 'dist');
@@ -59,6 +62,12 @@ async function start() {
app.listen(PORT, () => {
console.log(`[server] Listening on http://localhost:${PORT}`);
// Scan filesystem and populate media index in background
setImmediate(() => {
try { scanMediaFiles(); } catch (err) {
console.error('[server] Media scan failed:', err.message);
}
});
});
// Start HTTPS server for DRM/EME support (requires secure context)

304
server/package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"better-sqlite3": "^11.0.0",
"cheerio": "^1.2.0",
"cors": "^2.8.5",
"express": "^4.21.0",
"node-fetch": "^3.3.2"
@@ -108,6 +109,12 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
@@ -170,6 +177,48 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/cheerio": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
"license": "MIT",
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"encoding-sniffer": "^0.2.1",
"htmlparser2": "^10.1.0",
"parse5": "^7.3.0",
"parse5-htmlparser2-tree-adapter": "^7.1.0",
"parse5-parser-stream": "^7.1.2",
"undici": "^7.19.0",
"whatwg-mimetype": "^4.0.0"
},
"engines": {
"node": ">=20.18.1"
},
"funding": {
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
}
},
"node_modules/cheerio-select": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-select": "^5.1.0",
"css-what": "^6.1.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
@@ -229,6 +278,34 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@@ -299,6 +376,61 @@
"node": ">=8"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -328,6 +460,31 @@
"node": ">= 0.8"
}
},
"node_modules/encoding-sniffer": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.3",
"whatwg-encoding": "^3.1.1"
},
"funding": {
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
}
},
"node_modules/encoding-sniffer/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -337,6 +494,18 @@
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -608,6 +777,37 @@
"node": ">= 0.4"
}
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -848,6 +1048,18 @@
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -890,6 +1102,55 @@
"wrappy": "1"
}
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-parser-stream": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
"license": "MIT",
"dependencies": {
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1317,6 +1578,15 @@
"node": ">= 0.6"
}
},
"node_modules/undici": {
"version": "7.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -1359,6 +1629,40 @@
"node": ">= 8"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",

View File

@@ -7,9 +7,10 @@
"dev": "node --watch index.js"
},
"dependencies": {
"express": "^4.21.0",
"better-sqlite3": "^11.0.0",
"node-fetch": "^3.3.2",
"cors": "^2.8.5"
"cheerio": "^1.2.0",
"cors": "^2.8.5",
"express": "^4.21.0",
"node-fetch": "^3.3.2"
}
}

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Helper script called by Node.js to get Widevine content keys.
Routes license requests through the local proxy which handles auth/signing.
Usage: python3 pywidevine_helper.py <wvd_path> <pssh_b64> <proxy_license_url>
Outputs JSON: {"keys": [{"kid": "hex", "key": "hex", "type": "CONTENT"}]}
"""
import sys
import json
import requests
from pywidevine.cdm import Cdm
from pywidevine.device import Device
from pywidevine.pssh import PSSH
def main():
if len(sys.argv) < 4:
print(json.dumps({"error": "Usage: pywidevine_helper.py <wvd_path> <pssh_b64> <proxy_license_url>"}))
sys.exit(1)
wvd_path = sys.argv[1]
pssh_b64 = sys.argv[2]
proxy_url = sys.argv[3] # e.g. http://localhost:3001/api/drm-license?mediaId=...
try:
device = Device.load(wvd_path)
cdm = Cdm.from_device(device)
session_id = cdm.open()
pssh = PSSH(pssh_b64)
# Step 1: Get service certificate via proxy
cert_res = requests.post(
proxy_url,
data=Cdm.service_certificate_challenge,
headers={"Content-Type": "application/octet-stream"},
timeout=30,
)
if cert_res.ok:
try:
cdm.set_service_certificate(session_id, cert_res.content)
except Exception:
pass # Continue without privacy mode
# Step 2: Get license via proxy
challenge = cdm.get_license_challenge(session_id, pssh)
lic_res = requests.post(
proxy_url,
data=challenge,
headers={"Content-Type": "application/octet-stream"},
timeout=30,
)
if not lic_res.ok:
print(json.dumps({"error": f"License failed: {lic_res.status_code} {lic_res.text[:200]}"}))
sys.exit(1)
cdm.parse_license(session_id, lic_res.content)
keys = []
for key in cdm.get_keys(session_id):
keys.append({
"kid": key.kid.hex,
"key": key.key.hex(),
"type": key.type,
})
cdm.close(session_id)
print(json.dumps({"keys": keys}))
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
if __name__ == "__main__":
main()

322
server/scrape.js Normal file
View File

@@ -0,0 +1,322 @@
import { Router } from 'express';
import { mkdirSync } from 'fs';
import { join } from 'path';
import { scrapeForumPage, getPageUrl, detectMaxPage } from './scrapers/forum.js';
import { parseUserUrl, fetchAllPosts, downloadFiles } from './scrapers/coomer.js';
import { parseMediaUrl, fetchAllMedia, downloadMedia } from './scrapers/medialink.js';
const router = Router();
const MEDIA_PATH = process.env.MEDIA_PATH || './data/media';
const jobsMap = new Map();
let jobCounter = 0;
const MAX_COMPLETED = 50;
const MAX_LOGS = 200;
function createJob(type, config) {
const id = `scrape_${Date.now()}_${++jobCounter}`;
const job = {
id,
type,
config,
progress: { total: 0, completed: 0, errors: 0 },
running: true,
cancelled: false,
logs: [],
startedAt: new Date().toISOString(),
completedAt: null,
folderName: config.folderName || 'scrape',
};
jobsMap.set(id, job);
return job;
}
function addLog(job, msg) {
const ts = new Date().toLocaleTimeString('en-US', { hour12: false });
job.logs.push(`[${ts}] ${msg}`);
if (job.logs.length > MAX_LOGS) job.logs.shift();
}
function pruneCompleted() {
const completed = [...jobsMap.values()]
.filter(j => !j.running)
.sort((a, b) => new Date(b.completedAt) - new Date(a.completedAt));
if (completed.length > MAX_COMPLETED) {
for (const old of completed.slice(MAX_COMPLETED)) {
jobsMap.delete(old.id);
}
}
}
function jobToJson(job) {
return {
id: job.id,
type: job.type,
config: job.config,
progress: job.progress,
running: job.running,
cancelled: job.cancelled,
folderName: job.folderName,
startedAt: job.startedAt,
completedAt: job.completedAt,
logCount: job.logs.length,
};
}
// --- Forum Scrape ---
async function runForumScrape(job) {
const { url, startPage, endPage, delay, folderName } = job.config;
const outputDir = join(MEDIA_PATH, folderName);
mkdirSync(outputDir, { recursive: true });
const downloadedSet = new Set();
let totalImages = 0;
addLog(job, `Starting forum scrape: pages ${startPage}-${endPage}`);
addLog(job, `Output: ${outputDir}`);
job.progress.total = endPage - startPage + 1;
try {
for (let page = startPage; page <= endPage; page++) {
if (job.cancelled) {
addLog(job, 'Cancelled by user');
break;
}
const pageUrl = getPageUrl(url, page);
addLog(job, `--- Page ${page}/${endPage} ---`);
const count = await scrapeForumPage(pageUrl, outputDir, downloadedSet, (msg) => addLog(job, msg));
totalImages += count;
job.progress.completed = page - startPage + 1;
if (page < endPage && !job.cancelled) {
await new Promise(r => setTimeout(r, delay * 1000));
}
}
} catch (err) {
addLog(job, `Error: ${err.message}`);
job.progress.errors++;
} finally {
job.running = false;
job.completedAt = new Date().toISOString();
addLog(job, `Done! ${totalImages} images saved to ${folderName}/`);
pruneCompleted();
}
}
// --- Coomer Scrape ---
async function runCoomerScrape(job) {
const { url, pages, workers, folderName } = job.config;
const outputDir = join(MEDIA_PATH, folderName);
mkdirSync(outputDir, { recursive: true });
addLog(job, `Starting coomer scrape: ${url}`);
addLog(job, `Pages: ${pages}, Workers: ${workers}`);
try {
const { base, service, userId } = parseUserUrl(url);
addLog(job, `Site: ${base}, Service: ${service}, User: ${userId}`);
// Phase 1: Collect files
addLog(job, `Fetching up to ${pages} pages...`);
const files = await fetchAllPosts(base, service, userId, pages,
(msg) => addLog(job, msg),
() => job.cancelled
);
if (job.cancelled) {
addLog(job, 'Cancelled by user');
return;
}
if (files.length === 0) {
addLog(job, 'No files found');
return;
}
job.progress.total = files.length;
addLog(job, `Found ${files.length} files. Starting downloads...`);
// Phase 2: Download
const result = await downloadFiles(files, outputDir, workers,
(msg) => addLog(job, msg),
(completed, errors, total) => {
job.progress.completed = completed;
job.progress.errors = errors;
job.progress.total = total;
},
() => job.cancelled
);
addLog(job, `Done! ${result.completed} downloaded, ${result.errors} failed, ${result.skipped} skipped`);
} catch (err) {
addLog(job, `Error: ${err.message}`);
job.progress.errors++;
} finally {
job.running = false;
job.completedAt = new Date().toISOString();
pruneCompleted();
}
}
// --- MediaLink Scrape ---
async function runMediaLinkScrape(job) {
const { url, pages, workers, delay, folderName } = job.config;
const outputDir = join(MEDIA_PATH, folderName);
mkdirSync(outputDir, { recursive: true });
addLog(job, `Starting medialink scrape: ${url}`);
addLog(job, `Pages: ${pages}, Workers: ${workers}, Delay: ${delay}ms`);
try {
const { base, userId } = parseMediaUrl(url);
addLog(job, `Site: ${base}, User ID: ${userId}`);
// Phase 1: Collect all media via JSON API
addLog(job, `Fetching up to ${pages} pages from API...`);
const items = await fetchAllMedia(base, userId, pages, delay,
(msg) => addLog(job, msg),
() => job.cancelled
);
if (job.cancelled) {
addLog(job, 'Cancelled by user');
return;
}
if (items.length === 0) {
addLog(job, 'No media found');
return;
}
job.progress.total = items.length;
addLog(job, `Found ${items.length} media items. Downloading...`);
// Phase 2: Download all media files
const result = await downloadMedia(items, outputDir, workers,
(msg) => addLog(job, msg),
(completed, errors, total) => {
job.progress.completed = completed;
job.progress.errors = errors;
job.progress.total = total;
},
() => job.cancelled
);
addLog(job, `Done! ${result.completed} downloaded, ${result.errors} failed, ${result.skipped} skipped`);
} catch (err) {
addLog(job, `Error: ${err.message}`);
job.progress.errors++;
} finally {
job.running = false;
job.completedAt = new Date().toISOString();
pruneCompleted();
}
}
// --- Endpoints ---
router.post('/api/scrape/forum', (req, res) => {
const { url, folderName, startPage, endPage, delay } = req.body;
if (!url) return res.status(400).json({ error: 'URL is required' });
if (!folderName) return res.status(400).json({ error: 'Folder name is required' });
const config = {
url: url.includes('page-') ? url : `${url.replace(/\/$/, '')}/page-1`,
folderName,
startPage: parseInt(startPage) || 1,
endPage: parseInt(endPage) || 10,
delay: parseFloat(delay) || 1.0,
};
const job = createJob('forum', config);
runForumScrape(job).catch(err => {
addLog(job, `Fatal error: ${err.message}`);
job.running = false;
job.completedAt = new Date().toISOString();
});
res.json({ jobId: job.id, message: 'Forum scrape started' });
});
router.post('/api/scrape/coomer', (req, res) => {
const { url, folderName, pages, workers } = req.body;
if (!url) return res.status(400).json({ error: 'URL is required' });
if (!folderName) return res.status(400).json({ error: 'Folder name is required' });
const config = {
url,
folderName,
pages: parseInt(pages) || 10,
workers: Math.min(Math.max(parseInt(workers) || 10, 1), 20),
};
const job = createJob('coomer', config);
runCoomerScrape(job).catch(err => {
addLog(job, `Fatal error: ${err.message}`);
job.running = false;
job.completedAt = new Date().toISOString();
});
res.json({ jobId: job.id, message: 'Coomer scrape started' });
});
router.post('/api/scrape/medialink', (req, res) => {
const { url, folderName, pages, workers, delay } = req.body;
if (!url) return res.status(400).json({ error: 'URL is required' });
if (!folderName) return res.status(400).json({ error: 'Folder name is required' });
const config = {
url,
folderName,
pages: parseInt(pages) || 50,
workers: Math.min(Math.max(parseInt(workers) || 3, 1), 10),
delay: parseInt(delay) || 500,
};
const job = createJob('medialink', config);
runMediaLinkScrape(job).catch(err => {
addLog(job, `Fatal error: ${err.message}`);
job.running = false;
job.completedAt = new Date().toISOString();
});
res.json({ jobId: job.id, message: 'MediaLink scrape started' });
});
router.get('/api/scrape/jobs', (_req, res) => {
const jobs = [...jobsMap.values()].map(jobToJson);
jobs.sort((a, b) => new Date(b.startedAt) - new Date(a.startedAt));
res.json(jobs);
});
router.get('/api/scrape/jobs/:jobId', (req, res) => {
const job = jobsMap.get(req.params.jobId);
if (!job) return res.status(404).json({ error: 'Job not found' });
res.json({ ...jobToJson(job), logs: job.logs });
});
router.post('/api/scrape/jobs/:jobId/cancel', (req, res) => {
const job = jobsMap.get(req.params.jobId);
if (!job) return res.status(404).json({ error: 'Job not found' });
if (!job.running) return res.status(400).json({ error: 'Job is not running' });
job.cancelled = true;
addLog(job, 'Cancel requested');
res.json({ message: 'Cancel requested' });
});
// Auto-detect max page for forum URLs
router.post('/api/scrape/forum/detect-pages', async (req, res) => {
const { url } = req.body;
if (!url) return res.status(400).json({ error: 'URL is required' });
const logs = [];
const maxPage = await detectMaxPage(url, (msg) => logs.push(msg));
res.json({ maxPage, logs });
});
export default router;

201
server/scrapers/coomer.js Normal file
View File

@@ -0,0 +1,201 @@
import { existsSync, mkdirSync, writeFileSync } from 'fs';
import { basename, join, extname } from 'path';
import { upsertMediaFile } from '../db.js';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
export function parseUserUrl(url) {
const parsed = new URL(url);
const base = `${parsed.protocol}//${parsed.hostname}`;
const m = parsed.pathname.match(/^\/([^/]+)\/user\/([^/?#]+)/);
if (!m) throw new Error(`Can't parse URL. Expected: https://coomer.su/SERVICE/user/USER_ID`);
return { base, service: m[1], userId: m[2] };
}
async function fetchApi(apiUrl, logFn, retries = 3) {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const resp = await fetch(apiUrl, {
headers: { 'User-Agent': UA, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000),
});
if (resp.ok) return await resp.json();
if (resp.status === 404) return [];
if (resp.status === 429) {
const wait = 5 * (attempt + 1);
logFn(`Rate limited, waiting ${wait}s...`);
await sleep(wait * 1000);
continue;
}
if (resp.status >= 500) {
await sleep(2000);
continue;
}
logFn(`API error ${resp.status}: ${apiUrl}`);
return null;
} catch (err) {
if (attempt < retries - 1) {
await sleep(2000);
} else {
throw err;
}
}
}
return null;
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
export function collectFiles(posts, cdnBase) {
const files = [];
const seen = new Set();
for (const post of posts) {
const items = [];
if (post.file && post.file.path) items.push(post.file);
if (post.attachments) {
for (const att of post.attachments) {
if (att.path) items.push(att);
}
}
for (const f of items) {
const fileUrl = `${cdnBase}${f.path}`;
if (seen.has(fileUrl)) continue;
seen.add(fileUrl);
const name = f.name || basename(f.path);
files.push({ url: fileUrl, name });
}
}
return files;
}
async function downloadFile(url, outputDir, name, logFn) {
let filepath = join(outputDir, name);
if (existsSync(filepath)) {
// File already exists, skip
return { skipped: true };
}
try {
const resp = await fetch(url, {
headers: { 'User-Agent': UA },
signal: AbortSignal.timeout(60000),
});
if (!resp.ok) {
logFn(`FAILED (${resp.status}): ${name}`);
return { error: true };
}
const buf = Buffer.from(await resp.arrayBuffer());
// Handle filename collision (different content)
if (existsSync(filepath)) {
const ext = extname(name);
const base = name.slice(0, -ext.length);
let i = 1;
while (existsSync(filepath)) {
filepath = join(outputDir, `${base}_${i}${ext}`);
i++;
}
}
writeFileSync(filepath, buf);
const savedName = basename(filepath);
const folderName = basename(outputDir);
const ext = extname(savedName).toLowerCase();
const fileType = ['.mp4', '.mov', '.avi', '.webm', '.mkv', '.m4v'].includes(ext) ? 'video' : 'image';
try { upsertMediaFile(folderName, savedName, fileType, buf.length, Date.now(), null); } catch { /* ignore */ }
const sizeKb = (buf.length / 1024).toFixed(1);
return { filename: savedName, sizeKb };
} catch (err) {
logFn(`FAILED: ${name} - ${err.message}`);
return { error: true };
}
}
export async function fetchAllPosts(base, service, userId, maxPages, logFn, checkCancelled) {
const allFiles = [];
for (let page = 0; page < maxPages; page++) {
if (checkCancelled()) break;
const offset = page * 50;
const apiUrl = `${base}/api/v1/${service}/user/${userId}/posts?o=${offset}`;
let posts;
try {
posts = await fetchApi(apiUrl, logFn);
} catch (err) {
logFn(`API failed: ${err.message}`);
break;
}
if (!posts || posts.length === 0) break;
const parsed = new URL(base);
const cdnHost = `n1.${parsed.hostname}`;
const cdnBase = `${parsed.protocol}//${cdnHost}/data`;
const files = collectFiles(posts, cdnBase);
allFiles.push(...files);
logFn(`Page ${page + 1}: ${posts.length} posts (${allFiles.length} files total)`);
if (posts.length < 50) break;
}
return allFiles;
}
export async function downloadFiles(files, outputDir, concurrency, logFn, progressFn, checkCancelled) {
mkdirSync(outputDir, { recursive: true });
// Filter out already existing files
const toDownload = [];
let skipped = 0;
for (const f of files) {
if (existsSync(join(outputDir, f.name))) {
skipped++;
} else {
toDownload.push(f);
}
}
if (skipped > 0) logFn(`Skipping ${skipped} already downloaded files`);
logFn(`Downloading ${toDownload.length} files with ${concurrency} workers...`);
let completed = 0;
let errors = 0;
let active = 0;
let index = 0;
// Simple semaphore-based concurrency
async function processNext() {
while (index < toDownload.length) {
if (checkCancelled()) return;
const current = index++;
const file = toDownload[current];
const result = await downloadFile(file.url, outputDir, file.name, logFn);
if (result.error) {
errors++;
} else if (!result.skipped) {
completed++;
logFn(`[${completed}/${toDownload.length}] ${result.filename} (${result.sizeKb} KB)`);
}
progressFn(completed + skipped, errors, files.length);
}
}
const workers = [];
for (let i = 0; i < Math.min(concurrency, toDownload.length); i++) {
workers.push(processNext());
}
await Promise.all(workers);
return { completed, errors, skipped, total: files.length };
}

230
server/scrapers/forum.js Normal file
View File

@@ -0,0 +1,230 @@
import * as cheerio from 'cheerio';
import { createWriteStream, existsSync, mkdirSync, statSync } from 'fs';
import { basename, join, extname } from 'path';
import { pipeline } from 'stream/promises';
import { upsertMediaFile } from '../db.js';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff']);
const SKIP_PATTERNS = ['avatar', 'smilie', 'emoji', 'icon', 'logo', 'button', 'sprite', 'badge', 'rank', 'star'];
function isImageUrl(url) {
try {
const path = new URL(url).pathname.toLowerCase();
return [...IMAGE_EXTS].some(ext => path.endsWith(ext));
} catch { return false; }
}
export function getPageUrl(baseUrl, pageNum) {
const url = baseUrl.replace(/page-\d+/, `page-${pageNum}`);
return url.split('#')[0];
}
export async function detectMaxPage(baseUrl, logFn) {
try {
const resp = await fetch(baseUrl, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(15000) });
if (!resp.ok) return null;
const html = await resp.text();
const $ = cheerio.load(html);
let maxPage = 1;
// XenForo-style
$('a.pageNav-page, .pageNav a[href*="page-"], .pagination a[href*="page-"]').each((_, el) => {
const href = $(el).attr('href') || '';
const m = href.match(/page-(\d+)/);
if (m) maxPage = Math.max(maxPage, parseInt(m[1], 10));
});
// Generic pagination text
$('a').each((_, el) => {
const text = $(el).text().trim();
if (/^\d+$/.test(text)) {
const n = parseInt(text, 10);
if (n > maxPage && n < 10000) maxPage = n;
}
});
if (maxPage > 1) {
logFn(`Detected ${maxPage} pages`);
return maxPage;
}
return null;
} catch (err) {
logFn(`Page detection failed: ${err.message}`);
return null;
}
}
function tryFullSizeUrl(thumbUrl) {
const candidates = [];
if (thumbUrl.includes('.th.')) candidates.push(thumbUrl.replace('.th.', '.'));
if (/_thumb\./i.test(thumbUrl)) candidates.push(thumbUrl.replace(/_thumb\./i, '.'));
if (thumbUrl.includes('/thumbs/')) {
candidates.push(thumbUrl.replace('/thumbs/', '/images/'));
candidates.push(thumbUrl.replace('/thumbs/', '/full/'));
}
try {
const parsed = new URL(thumbUrl);
const base = basename(parsed.pathname);
if (base.startsWith('thumb_')) {
candidates.push(thumbUrl.replace(`/${base}`, `/${base.slice(6)}`));
}
if (parsed.search) candidates.push(thumbUrl.split('?')[0]);
} catch {}
return candidates;
}
async function downloadImage(url, outputDir, downloadedSet, logFn) {
if (downloadedSet.has(url)) return false;
if (!isImageUrl(url)) return false;
const lower = url.toLowerCase();
if (SKIP_PATTERNS.some(p => lower.includes(p))) return false;
downloadedSet.add(url);
let filename;
try {
filename = basename(new URL(url).pathname);
} catch { return false; }
if (!filename) return false;
filename = filename.replace('.th.', '.');
let filepath = join(outputDir, filename);
if (existsSync(filepath)) {
const ext = extname(filename);
const name = filename.slice(0, -ext.length);
let i = 1;
while (existsSync(filepath)) {
filepath = join(outputDir, `${name}_${i}${ext}`);
i++;
}
}
try {
const resp = await fetch(url, {
headers: { 'User-Agent': UA },
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) {
logFn(`FAILED (${resp.status}): ${url}`);
return false;
}
// Read full body to check size
const buf = Buffer.from(await resp.arrayBuffer());
if (buf.length < 1000) {
downloadedSet.delete(url);
return false;
}
const { writeFileSync } = await import('fs');
writeFileSync(filepath, buf);
const savedName = basename(filepath);
const folderName = basename(outputDir);
try { upsertMediaFile(folderName, savedName, 'image', buf.length, Date.now(), null); } catch { /* ignore */ }
const sizeKb = (buf.length / 1024).toFixed(1);
logFn(`Downloaded: ${savedName} (${sizeKb} KB)`);
return true;
} catch (err) {
logFn(`FAILED: ${basename(filepath)} - ${err.message}`);
return false;
}
}
export async function scrapeForumPage(pageUrl, outputDir, downloadedSet, logFn) {
logFn(`Fetching page: ${pageUrl}`);
let html;
try {
const resp = await fetch(pageUrl, {
headers: { 'User-Agent': UA },
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) {
logFn(`Failed to fetch page (${resp.status})`);
return 0;
}
html = await resp.text();
} catch (err) {
logFn(`Failed to fetch page: ${err.message}`);
return 0;
}
const $ = cheerio.load(html);
// Try known content selectors, fall back to whole page
const selectors = '.message-body, .post-body, .post_body, .postcontent, .messageContent, .bbWrapper, article, .entry-content, .post_message, .post-content, #posts, .threadBody';
let contentAreas = $(selectors).toArray();
if (contentAreas.length === 0) {
contentAreas = [$.root().get(0)];
}
const imageUrls = [];
for (const area of contentAreas) {
const $area = $(area);
// Pass 1: <img> tags
$area.find('img').each((_, el) => {
const $img = $(el);
const src = $img.attr('src') || $img.attr('data-src') || $img.attr('data-url') || '';
if (!src) return;
let absSrc;
try { absSrc = new URL(src, pageUrl).href; } catch { return; }
// Check parent <a> for direct image link
const $parentA = $img.closest('a');
if ($parentA.length && $parentA.attr('href')) {
try {
const aHref = new URL($parentA.attr('href'), pageUrl).href;
if (isImageUrl(aHref)) {
imageUrls.push(aHref);
return;
}
} catch {}
}
// Try to derive full-size from thumbnail URL
const fullCandidates = tryFullSizeUrl(absSrc);
if (fullCandidates.length > 0) {
imageUrls.push(...fullCandidates);
} else {
imageUrls.push(absSrc);
}
// Also check data attributes
for (const attr of ['data-src', 'data-url', 'data-orig', 'data-original', 'data-full-url', 'data-zoom-src']) {
const val = $img.attr(attr);
if (val && val !== src) {
try { imageUrls.push(new URL(val, pageUrl).href); } catch {}
}
}
});
// Pass 2: <a href> pointing directly to images (no child <img>)
$area.find('a[href]').each((_, el) => {
const $a = $(el);
if ($a.find('img').length) return;
try {
const href = new URL($a.attr('href'), pageUrl).href;
if (isImageUrl(href)) imageUrls.push(href);
} catch {}
});
}
logFn(`Found ${imageUrls.length} candidate URLs`);
let count = 0;
for (const imgUrl of imageUrls) {
if (await downloadImage(imgUrl, outputDir, downloadedSet, logFn)) {
count++;
}
}
logFn(`${count} images from this page`);
return count;
}

View File

@@ -0,0 +1,187 @@
import { existsSync, writeFileSync, mkdirSync } from 'fs';
import { basename, join, extname } from 'path';
import { upsertMediaFile } from '../db.js';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const VIDEO_EXTS = new Set(['.mp4', '.mov', '.avi', '.webm', '.mkv', '.m4v']);
export function parseMediaUrl(url) {
const parsed = new URL(url);
const base = `${parsed.protocol}//${parsed.hostname}`;
// Support /model/{id} or /media/{id}
const m = parsed.pathname.match(/\/(?:model|media)\/(\d+)/);
if (!m) throw new Error(`Can't parse URL. Expected: https://fapello.to/model/12345`);
return { base, userId: m[1] };
}
// Fetch JSON from the API endpoint
// API: GET /api/media/{userId}/{page}/{order}
// Requires X-Requested-With and Referer headers to avoid 403
async function fetchApiPage(base, userId, page, order, logFn) {
const apiUrl = `${base}/api/media/${userId}/${page}/${order}`;
try {
const resp = await fetch(apiUrl, {
headers: {
'User-Agent': UA,
'Accept': 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
'Referer': `${base}/model/${userId}`,
},
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) {
if (resp.status === 404) return null;
logFn(`API error (${resp.status}): ${apiUrl}`);
return null;
}
const data = await resp.json();
return data;
} catch (err) {
logFn(`API fetch error: ${err.message}`);
return null;
}
}
// Collect all media items by paginating through the API
export async function fetchAllMedia(base, userId, maxPages, delay, logFn, checkCancelled) {
const allItems = [];
const seen = new Set();
for (let page = 1; page <= maxPages; page++) {
if (checkCancelled()) break;
logFn(`Fetching page ${page}...`);
const data = await fetchApiPage(base, userId, page, 1, logFn);
if (!data || data.length === 0) {
logFn(`Page ${page}: no more items — done`);
break;
}
let newCount = 0;
for (const item of data) {
if (seen.has(item.id)) continue;
seen.add(item.id);
newCount++;
// type "2" = video (newUrl is mp4), type "1" = image (newUrl is full-size jpg)
const isVideo = item.type === '2' || item.type === 2;
const fullUrl = item.newUrl;
if (!fullUrl) continue;
allItems.push({
id: item.id,
url: fullUrl,
type: isVideo ? 'video' : 'image',
});
}
if (newCount === 0) {
logFn(`Page ${page}: all duplicates — stopping`);
break;
}
logFn(`Page ${page}: ${data.length} items (${newCount} new, ${allItems.length} total)`);
if (page < maxPages && !checkCancelled()) {
await new Promise(r => setTimeout(r, delay));
}
}
return allItems;
}
// Download all collected media items with concurrency
export async function downloadMedia(items, outputDir, workers, logFn, progressFn, checkCancelled) {
mkdirSync(outputDir, { recursive: true });
let completed = 0;
let errors = 0;
let skipped = 0;
let index = 0;
async function processNext() {
while (index < items.length) {
if (checkCancelled()) return;
const current = index++;
const item = items[current];
let filename;
try {
filename = basename(new URL(item.url).pathname);
if (!filename || filename === '/') {
filename = `${item.id}.${item.type === 'video' ? 'mp4' : 'jpg'}`;
}
} catch {
filename = `${item.id}.${item.type === 'video' ? 'mp4' : 'jpg'}`;
}
let filepath = join(outputDir, filename);
if (existsSync(filepath)) {
skipped++;
progressFn(completed + skipped, errors, items.length);
continue;
}
try {
const resp = await fetch(item.url, {
headers: {
'User-Agent': UA,
'Referer': 'https://fapello.to/',
},
signal: AbortSignal.timeout(60000),
});
if (!resp.ok) {
logFn(`FAILED (${resp.status}): ${filename}`);
errors++;
progressFn(completed + skipped, errors, items.length);
continue;
}
const buf = Buffer.from(await resp.arrayBuffer());
if (buf.length < 500) {
skipped++;
progressFn(completed + skipped, errors, items.length);
continue;
}
// Handle filename collision
if (existsSync(filepath)) {
const ext = extname(filename);
const name = filename.slice(0, -ext.length);
let i = 1;
while (existsSync(filepath)) {
filepath = join(outputDir, `${name}_${i}${ext}`);
i++;
}
}
writeFileSync(filepath, buf);
const savedName = basename(filepath);
const folderName = basename(outputDir);
const fileExt = extname(savedName).toLowerCase();
const fileType = VIDEO_EXTS.has(fileExt) ? 'video' : 'image';
try { upsertMediaFile(folderName, savedName, fileType, buf.length, Date.now(), null); } catch {}
completed++;
const sizeKb = (buf.length / 1024).toFixed(1);
logFn(`[${completed}/${items.length}] ${savedName} (${sizeKb} KB)`);
progressFn(completed + skipped, errors, items.length);
} catch (err) {
logFn(`FAILED: ${filename} - ${err.message}`);
errors++;
progressFn(completed + skipped, errors, items.length);
}
}
}
const workerPromises = [];
for (let i = 0; i < Math.min(workers, items.length); i++) {
workerPromises.push(processNext());
}
await Promise.all(workerPromises);
return { completed, errors, skipped, total: items.length };
}

330
server/widevine.js Normal file
View File

@@ -0,0 +1,330 @@
import crypto from 'node:crypto';
import { readFileSync, existsSync } from 'node:fs';
// ==================== Minimal Protobuf Codec ====================
function encodeVarint(value) {
const bytes = [];
value = value >>> 0;
do {
let b = value & 0x7f;
value >>>= 7;
if (value > 0) b |= 0x80;
bytes.push(b);
} while (value > 0);
return Buffer.from(bytes);
}
function encodeVarintField(fieldNumber, value) {
const tag = encodeVarint((fieldNumber << 3) | 0);
const val = encodeVarint(value);
return Buffer.concat([tag, val]);
}
function encodeBytesField(fieldNumber, data) {
if (!Buffer.isBuffer(data)) data = Buffer.from(data);
const tag = encodeVarint((fieldNumber << 3) | 2);
const len = encodeVarint(data.length);
return Buffer.concat([tag, len, data]);
}
function decodeVarintAt(buf, offset) {
let value = 0, shift = 0;
while (offset < buf.length) {
const b = buf[offset++];
value |= (b & 0x7f) << shift;
if (!(b & 0x80)) break;
shift += 7;
if (shift > 35) throw new Error('Varint too long');
}
return [value >>> 0, offset];
}
function decodeProtobuf(buf) {
const fields = [];
let offset = 0;
while (offset < buf.length) {
const [tag, off1] = decodeVarintAt(buf, offset);
offset = off1;
const fieldNum = tag >>> 3;
const wireType = tag & 0x7;
if (wireType === 0) {
const [value, off2] = decodeVarintAt(buf, offset);
offset = off2;
fields.push({ field: fieldNum, type: wireType, value });
} else if (wireType === 2) {
const [length, off2] = decodeVarintAt(buf, offset);
offset = off2;
fields.push({ field: fieldNum, type: wireType, data: Buffer.from(buf.subarray(offset, offset + length)) });
offset += length;
} else if (wireType === 1) {
offset += 8;
} else if (wireType === 5) {
offset += 4;
} else {
break;
}
}
return fields;
}
// ==================== PSSH Box Parser ====================
export function parsePsshBox(base64Data) {
const box = Buffer.from(base64Data, 'base64');
let offset = 0;
const size = box.readUInt32BE(offset); offset += 4;
const type = box.subarray(offset, offset + 4).toString('ascii'); offset += 4;
if (type !== 'pssh') throw new Error('Not a PSSH box');
const version = box[offset]; offset += 4; // version (1) + flags (3)
const systemId = box.subarray(offset, offset + 16); offset += 16;
if (version === 1) {
const kidCount = box.readUInt32BE(offset); offset += 4;
offset += kidCount * 16;
}
const dataSize = box.readUInt32BE(offset); offset += 4;
const initData = Buffer.from(box.subarray(offset, offset + dataSize));
return { version, systemId: systemId.toString('hex'), initData };
}
// ==================== WVD File Parser ====================
function parseWvdFile(path) {
const data = readFileSync(path);
let offset = 0;
const magic = data.subarray(0, 3).toString('ascii');
if (magic !== 'WVD') throw new Error('Invalid WVD file');
offset += 3;
const version = data[offset++];
if (version !== 2) throw new Error(`Unsupported WVD version: ${version}`);
const deviceType = data[offset++];
const securityLevel = data[offset++];
const flags = data[offset++];
const pkLen = data.readUInt16BE(offset); offset += 2;
const privateKeyDer = Buffer.from(data.subarray(offset, offset + pkLen)); offset += pkLen;
const cidLen = data.readUInt16BE(offset); offset += 2;
const clientId = Buffer.from(data.subarray(offset, offset + cidLen));
// Try PKCS8 first, fall back to PKCS1 (RSA)
let privateKey;
try {
privateKey = crypto.createPrivateKey({ key: privateKeyDer, format: 'der', type: 'pkcs8' });
} catch {
privateKey = crypto.createPrivateKey({ key: privateKeyDer, format: 'der', type: 'pkcs1' });
}
return { deviceType, securityLevel, flags, privateKey, clientId };
}
// ==================== Service Certificate Parser ====================
function parseServiceCertResponse(responseBuffer) {
// Response is a SignedMessage: type(1), msg(2), signature(3)
const signed = decodeProtobuf(responseBuffer);
let msgBytes = null;
for (const f of signed) {
if (f.field === 2 && f.type === 2) msgBytes = f.data;
}
if (!msgBytes) throw new Error('No msg in service cert response');
// msg is a SignedDrmCertificate: drm_certificate(1), signature(2)
const signedCert = decodeProtobuf(msgBytes);
let certBytes = null;
for (const f of signedCert) {
if (f.field === 1 && f.type === 2) certBytes = f.data;
}
if (!certBytes) throw new Error('No certificate in service cert response');
// DrmCertificate: type(1), serial_number(2), creation_time(3), public_key(4),
// system_id(7), provider_id(6 or bytes field)
const cert = decodeProtobuf(certBytes);
let publicKeyDer = null;
let serialNumber = null;
let providerId = null;
for (const f of cert) {
if (f.field === 4 && f.type === 2) publicKeyDer = f.data;
if (f.field === 2 && f.type === 2) serialNumber = f.data;
if (f.field === 6 && f.type === 2) providerId = f.data;
}
if (!publicKeyDer) throw new Error('No public key in service certificate');
// Try SPKI first, fall back to PKCS1
let publicKey;
try {
publicKey = crypto.createPublicKey({ key: publicKeyDer, format: 'der', type: 'spki' });
} catch {
publicKey = crypto.createPublicKey({ key: publicKeyDer, format: 'der', type: 'pkcs1' });
}
return { publicKey, serialNumber, providerId };
}
// ==================== Widevine CDM ====================
export class WidevineCDM {
constructor(wvdPath) {
if (!existsSync(wvdPath)) throw new Error(`WVD file not found: ${wvdPath}`);
const device = parseWvdFile(wvdPath);
this.privateKey = device.privateKey;
this.clientId = device.clientId;
this.securityLevel = device.securityLevel;
console.log(`[widevine] CDM initialized (L${device.securityLevel})`);
}
static get SERVICE_CERTIFICATE_CHALLENGE() {
return Buffer.from([0x08, 0x04]);
}
parseServiceCertificate(responseBuffer) {
return parseServiceCertResponse(responseBuffer);
}
generateChallenge(psshInitData, serviceCert) {
// WidevinePsshContentId: pssh_data(1), license_type(2), request_id(3)
const requestId = crypto.randomBytes(16);
const wvPsshData = Buffer.concat([
encodeBytesField(1, psshInitData),
encodeVarintField(2, 1), // STREAMING
encodeBytesField(3, requestId),
]);
// ContentIdentification: widevine_pssh_data(1)
const contentId = encodeBytesField(1, wvPsshData);
// Build client identification (privacy mode if service cert available)
let clientIdField;
if (serviceCert) {
// Privacy mode: encrypt client ID with service certificate's public key
const privacyKey = crypto.randomBytes(16);
const privacyIv = crypto.randomBytes(16);
// AES-128-CBC encrypt the client ID
const cipher = crypto.createCipheriv('aes-128-cbc', privacyKey, privacyIv);
const encryptedClientId = Buffer.concat([cipher.update(this.clientId), cipher.final()]);
// RSA-OAEP encrypt the AES key with service cert's public key
const encryptedPrivacyKey = crypto.publicEncrypt(
{
key: serviceCert.publicKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha1',
},
privacyKey,
);
// EncryptedClientIdentification:
// provider_id(1), service_certificate_serial_number(2),
// encrypted_client_id(3), encrypted_client_id_iv(4),
// encrypted_privacy_key(5)
const encClientId = Buffer.concat([
serviceCert.providerId ? encodeBytesField(1, serviceCert.providerId) : Buffer.alloc(0),
serviceCert.serialNumber ? encodeBytesField(2, serviceCert.serialNumber) : Buffer.alloc(0),
encodeBytesField(3, encryptedClientId),
encodeBytesField(4, privacyIv),
encodeBytesField(5, encryptedPrivacyKey),
]);
// LicenseRequest field 8 = encrypted_client_id
clientIdField = encodeBytesField(8, encClientId);
} else {
// No privacy mode: send raw client ID
// LicenseRequest field 1 = client_id
clientIdField = encodeBytesField(1, this.clientId);
}
// LicenseRequest: content_id(2), type(3), request_time(4),
// protocol_version(6), key_control_nonce(7)
const licenseRequest = Buffer.concat([
clientIdField,
encodeBytesField(2, contentId),
encodeVarintField(3, 1), // NEW
encodeVarintField(4, Math.floor(Date.now() / 1000)),
encodeVarintField(6, 21),
encodeVarintField(7, crypto.randomInt(1, 2 ** 31)),
]);
// Sign with RSA PKCS1v15 SHA1
const signature = crypto.sign('sha1', licenseRequest, {
key: this.privateKey,
padding: crypto.constants.RSA_PKCS1_PADDING,
});
// SignedMessage: type(1)=LICENSE_REQUEST, msg(2), signature(3)
return Buffer.concat([
encodeVarintField(1, 1),
encodeBytesField(2, licenseRequest),
encodeBytesField(3, signature),
]);
}
parseLicenseResponse(responseBuffer) {
const signed = decodeProtobuf(responseBuffer);
let encSessionKey = null;
let licenseMsg = null;
for (const f of signed) {
if (f.field === 4 && f.type === 2) encSessionKey = f.data;
if (f.field === 2 && f.type === 2) licenseMsg = f.data;
}
if (!encSessionKey) throw new Error('No session key in license response');
if (!licenseMsg) throw new Error('No license message in response');
// Decrypt session key with RSA-OAEP SHA1
const sessionKey = crypto.privateDecrypt(
{
key: this.privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha1',
},
encSessionKey,
);
// Parse License — KeyContainer is at field 3
const license = decodeProtobuf(licenseMsg);
const keys = [];
for (const f of license) {
if (f.field === 3 && f.type === 2) {
const kc = decodeProtobuf(f.data);
let kid = null, iv = null, encKey = null, keyType = 0;
for (const kf of kc) {
if (kf.field === 1 && kf.type === 2) kid = kf.data;
if (kf.field === 2 && kf.type === 2) iv = kf.data;
if (kf.field === 3 && kf.type === 2) encKey = kf.data;
if (kf.field === 4 && kf.type === 0) keyType = kf.value;
}
if (encKey && iv) {
const algo = sessionKey.length === 16 ? 'aes-128-cbc' : 'aes-256-cbc';
const decipher = crypto.createDecipheriv(algo, sessionKey, iv);
const decrypted = Buffer.concat([decipher.update(encKey), decipher.final()]);
keys.push({
kid: kid ? kid.toString('hex') : null,
key: decrypted.toString('hex'),
type: keyType, // 2 = CONTENT
});
}
}
}
const contentKeys = keys.filter(k => k.type === 2);
return contentKeys.length > 0 ? contentKeys : keys;
}
}