236f36aae6
- JWT-based app authentication with user roles, folder/route access control - Dashboard with storage stats, health checks, and recent activity - Auto-download/scrape scheduler (12h interval) with per-user and per-job configs - Video upload, tagging, HLS transcoding, and detail pages - New scrapers: LeakGallery, Mega (megajs), yt-dlp - FlareSolverr integration for Cloudflare-protected sites - Gallery: advanced filtering (date, size, search), sort modes, equal-mix shuffle - Forum sites management with stored cookies/auth - GridWall/GridCell components for responsive media grid - Media API with folder-access permissions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
693 lines
22 KiB
JavaScript
693 lines
22 KiB
JavaScript
import { Router } from 'express';
|
|
import { readdirSync, statSync, existsSync, mkdirSync, unlinkSync, createReadStream } from 'fs';
|
|
import { join, extname } from 'path';
|
|
import { execFile } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import { createHash } from 'crypto';
|
|
import {
|
|
getPostDateByFilename, getSetting,
|
|
upsertMediaFileBatch, removeMediaFile, removeStaleFiles,
|
|
getMediaFolders, getMediaFiles, getMediaFileCount, getAllIndexedFolders,
|
|
getNewMediaCount, getUserFolderAccess,
|
|
} 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']);
|
|
|
|
function getMediaType(filename) {
|
|
const ext = extname(filename).toLowerCase();
|
|
if (IMAGE_EXTS.has(ext)) return 'image';
|
|
if (VIDEO_EXTS.has(ext)) return 'video';
|
|
return null;
|
|
}
|
|
|
|
// --- Background filesystem scanner ---
|
|
|
|
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; }
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
|
|
// Helper: get allowed folders for current user (null = all access)
|
|
function getAllowedFolders(req) {
|
|
if (!req.user || req.user.role === 'admin') return null;
|
|
return getUserFolderAccess(req.user.id);
|
|
}
|
|
|
|
function checkFolderAccess(req, folder) {
|
|
const allowed = getAllowedFolders(req);
|
|
if (allowed === null) return true; // admin or no restrictions
|
|
return allowed.includes(folder);
|
|
}
|
|
|
|
// GET /api/gallery/new-count — count of media added since last gallery visit
|
|
router.get('/api/gallery/new-count', (req, res, next) => {
|
|
try {
|
|
const lastSeen = getSetting('gallery_last_seen');
|
|
if (!lastSeen) return res.json({ count: 0 });
|
|
const count = getNewMediaCount(lastSeen);
|
|
res.json({ count });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// GET /api/gallery/folders — list all folders with file counts (from DB index)
|
|
router.get('/api/gallery/folders', (req, res, next) => {
|
|
try {
|
|
let folders = getMediaFolders();
|
|
const allowed = getAllowedFolders(req);
|
|
if (allowed !== null) {
|
|
const set = new Set(allowed);
|
|
folders = folders.filter(f => set.has(f.name));
|
|
}
|
|
res.json(folders);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// GET /api/gallery/files?folder=&type=&sort=&offset=&limit=&dateFrom=&dateTo=&minSize=&maxSize=&search= (from DB index)
|
|
router.get('/api/gallery/files', (req, res, next) => {
|
|
try {
|
|
const { folder, type, sort, offset, limit, dateFrom, dateTo, minSize, maxSize, search } = req.query;
|
|
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';
|
|
|
|
// Enforce folder access for non-admin users
|
|
const allowed = getAllowedFolders(req);
|
|
let effectiveFolder = folder || undefined;
|
|
let effectiveFolders = foldersArr;
|
|
|
|
if (allowed !== null) {
|
|
const allowedSet = new Set(allowed);
|
|
if (effectiveFolder) {
|
|
// Requested specific folder — must be allowed
|
|
if (!allowedSet.has(effectiveFolder)) {
|
|
return res.json({ total: 0, offset: offsetNum, limit: limitNum, files: [] });
|
|
}
|
|
} else if (effectiveFolders && effectiveFolders.length > 0) {
|
|
// Requested multiple folders — intersect with allowed
|
|
effectiveFolders = effectiveFolders.filter(f => allowedSet.has(f));
|
|
if (effectiveFolders.length === 0) {
|
|
return res.json({ total: 0, offset: offsetNum, limit: limitNum, files: [] });
|
|
}
|
|
} else {
|
|
// No folder filter — restrict to allowed folders only
|
|
effectiveFolders = allowed;
|
|
if (effectiveFolders.length === 0) {
|
|
return res.json({ total: 0, offset: offsetNum, limit: limitNum, files: [] });
|
|
}
|
|
}
|
|
}
|
|
|
|
const { total, rows } = getMediaFiles({
|
|
folder: effectiveFolder,
|
|
folders: effectiveFolders,
|
|
type: type || 'all',
|
|
sort: sort || 'latest',
|
|
offset: offsetNum,
|
|
limit: limitNum,
|
|
dateFrom: dateFrom || undefined,
|
|
dateTo: dateTo || undefined,
|
|
minSize: minSize || undefined,
|
|
maxSize: maxSize || undefined,
|
|
search: search || undefined,
|
|
});
|
|
|
|
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;
|
|
});
|
|
|
|
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;
|
|
|
|
// Prevent path traversal
|
|
if (folder.includes('..') || filename.includes('..')) {
|
|
return res.status(400).json({ error: 'Invalid path' });
|
|
}
|
|
|
|
if (!checkFolderAccess(req, folder)) {
|
|
return res.status(403).json({ error: 'Access denied' });
|
|
}
|
|
|
|
const filePath = join(MEDIA_PATH, folder, filename);
|
|
res.sendFile(filePath, { root: '/' }, (err) => {
|
|
if (err && !res.headersSent) {
|
|
res.status(404).json({ error: 'File not found' });
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- 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 {
|
|
// Skip corrupt/empty files
|
|
try {
|
|
const st = statSync(videoPath);
|
|
if (st.size < 1000) return null;
|
|
} catch { return null; }
|
|
|
|
if (!existsSync(thumbDir)) mkdirSync(thumbDir, { recursive: true });
|
|
|
|
// Check if file has a video stream and get duration
|
|
let hasVideo = false;
|
|
let probeFailed = false;
|
|
let duration = 0;
|
|
try {
|
|
const probe = await execFileAsync('ffprobe', [
|
|
'-v', 'error',
|
|
'-select_streams', 'v',
|
|
'-show_entries', 'stream=codec_type',
|
|
'-show_entries', 'format=duration',
|
|
'-of', 'json',
|
|
videoPath,
|
|
], { timeout: 15000 });
|
|
const info = JSON.parse(probe.stdout);
|
|
hasVideo = info.streams && info.streams.length > 0;
|
|
duration = parseFloat(info.format?.duration || '0');
|
|
} catch {
|
|
probeFailed = true;
|
|
}
|
|
|
|
if (!hasVideo && !probeFailed) return 'audio-only'; // confirmed audio-only, skip
|
|
if (!hasVideo && probeFailed) return null; // probe failed, count as error
|
|
|
|
const seekTime = duration > 1.5 ? '1' : '0';
|
|
|
|
await execFileAsync('ffmpeg', [
|
|
'-ss', seekTime,
|
|
'-i', videoPath,
|
|
'-frames:v', '1',
|
|
'-vf', 'scale=320:-1',
|
|
'-pix_fmt', 'yuvj420p',
|
|
'-q:v', '6',
|
|
'-y',
|
|
'-update', '1',
|
|
thumbPath,
|
|
], { timeout: 30000 });
|
|
return thumbPath;
|
|
} catch (err) {
|
|
console.error(`[gallery] thumb failed for ${key}:`, err.message);
|
|
if (err.stderr) console.error(`[gallery] ffmpeg stderr:`, err.stderr.trim());
|
|
return null;
|
|
} finally {
|
|
thumbInFlight.delete(key);
|
|
}
|
|
})();
|
|
|
|
thumbInFlight.set(key, promise);
|
|
return promise;
|
|
}
|
|
|
|
async function generateImageThumb(folder, filename) {
|
|
const imagePath = join(MEDIA_PATH, folder, filename);
|
|
const { thumbDir, thumbPath } = getThumbPath(folder, filename);
|
|
|
|
if (existsSync(thumbPath)) return thumbPath;
|
|
|
|
const key = `img:${folder}/${filename}`;
|
|
if (thumbInFlight.has(key)) return thumbInFlight.get(key);
|
|
|
|
const promise = (async () => {
|
|
try {
|
|
// Skip corrupt/empty files
|
|
try {
|
|
const st = statSync(imagePath);
|
|
if (st.size < 100) return null;
|
|
} catch { return null; }
|
|
|
|
if (!existsSync(thumbDir)) mkdirSync(thumbDir, { recursive: true });
|
|
await execFileAsync('ffmpeg', [
|
|
'-i', imagePath,
|
|
'-vf', 'scale=480:-1',
|
|
'-q:v', '4',
|
|
'-y',
|
|
'-update', '1',
|
|
thumbPath,
|
|
], { timeout: 30000 });
|
|
return thumbPath;
|
|
} catch (err) {
|
|
console.error(`[gallery] image thumb failed for ${folder}/${filename}:`, err.message);
|
|
if (err.stderr) console.error(`[gallery] ffmpeg stderr:`, err.stderr.trim());
|
|
return null;
|
|
} finally {
|
|
thumbInFlight.delete(key);
|
|
}
|
|
})();
|
|
|
|
thumbInFlight.set(key, promise);
|
|
return promise;
|
|
}
|
|
|
|
function serveFile(filePath, res) {
|
|
try {
|
|
const st = statSync(filePath);
|
|
res.writeHead(200, {
|
|
'Content-Type': 'image/jpeg',
|
|
'Content-Length': st.size,
|
|
'Cache-Control': 'public, max-age=86400',
|
|
});
|
|
createReadStream(filePath).pipe(res);
|
|
} catch {
|
|
if (!res.headersSent) {
|
|
res.set('Cache-Control', 'no-cache');
|
|
res.status(404).json({ error: 'Not found' });
|
|
}
|
|
}
|
|
}
|
|
|
|
// GET /api/gallery/thumb/:folder/:filename — serve or generate a thumbnail (video or image)
|
|
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' });
|
|
}
|
|
|
|
if (!checkFolderAccess(req, folder)) {
|
|
return res.status(403).json({ error: 'Access denied' });
|
|
}
|
|
|
|
const { thumbPath } = getThumbPath(folder, filename);
|
|
|
|
// Serve cached thumb immediately
|
|
if (existsSync(thumbPath)) {
|
|
return serveFile(thumbPath, res);
|
|
}
|
|
|
|
// Determine type by extension
|
|
const ext = filename.toLowerCase().split('.').pop();
|
|
const isVideo = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'wmv', 'flv', 'm4v'].includes(ext);
|
|
const isImage = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tiff'].includes(ext);
|
|
|
|
let result;
|
|
if (isVideo) {
|
|
result = await generateThumb(folder, filename);
|
|
} else if (isImage) {
|
|
result = await generateImageThumb(folder, filename);
|
|
}
|
|
|
|
if (result && existsSync(result)) {
|
|
serveFile(result, res);
|
|
} else if (isImage) {
|
|
// Fallback: serve original image
|
|
const origPath = join(MEDIA_PATH, folder, filename);
|
|
serveFile(origPath, res);
|
|
} else {
|
|
res.set('Cache-Control', 'no-cache');
|
|
res.status(500).json({ error: 'Thumbnail generation failed' });
|
|
}
|
|
});
|
|
|
|
// Bulk thumbnail generation state
|
|
let thumbGenState = { running: false, total: 0, done: 0, errors: 0, skipped: 0 };
|
|
|
|
// POST /api/gallery/generate-thumbs — bulk generate all thumbnails (videos + images)
|
|
router.post('/api/gallery/generate-thumbs', (req, res) => {
|
|
if (thumbGenState.running) {
|
|
return res.json({ status: 'already_running', ...thumbGenState });
|
|
}
|
|
|
|
// Collect all media needing thumbs
|
|
const mediaItems = [];
|
|
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) || IMAGE_EXTS.has(ext)) {
|
|
const { thumbPath } = getThumbPath(dir.name, file);
|
|
if (!existsSync(thumbPath)) {
|
|
mediaItems.push({ folder: dir.name, filename: file, type: VIDEO_EXTS.has(ext) ? 'video' : 'image' });
|
|
}
|
|
}
|
|
}
|
|
} catch { continue; }
|
|
}
|
|
|
|
if (mediaItems.length === 0) {
|
|
return res.json({ status: 'done', total: 0, done: 0, errors: 0, message: 'All thumbnails already exist' });
|
|
}
|
|
|
|
thumbGenState = { running: true, total: mediaItems.length, done: 0, errors: 0, skipped: 0 };
|
|
res.json({ status: 'started', total: mediaItems.length });
|
|
|
|
// Run in background with concurrency limit
|
|
(async () => {
|
|
const CONCURRENCY = 2;
|
|
let i = 0;
|
|
const next = async () => {
|
|
while (i < mediaItems.length) {
|
|
const { folder, filename, type } = mediaItems[i++];
|
|
const result = type === 'video'
|
|
? await generateThumb(folder, filename)
|
|
: await generateImageThumb(folder, filename);
|
|
if (result === 'audio-only') thumbGenState.skipped++;
|
|
else if (result) thumbGenState.done++;
|
|
else thumbGenState.errors++;
|
|
}
|
|
};
|
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, mediaItems.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
|
|
// Query param: mode=everywhere (default) or mode=same-folder
|
|
router.post('/api/gallery/scan-duplicates', (req, res) => {
|
|
if (duplicateScanState.running) {
|
|
return res.json({ status: 'already_running', ...duplicateScanState });
|
|
}
|
|
|
|
const mode = req.query.mode || req.body?.mode || 'everywhere';
|
|
|
|
// Phase 1: group all files by size (optionally scoped per folder)
|
|
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);
|
|
// For same-folder mode, scope the size key by folder name
|
|
const key = mode === 'same-folder' ? `${dir.name}:${stat.size}` : 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' });
|
|
}
|
|
|
|
if (!checkFolderAccess(req, folder)) {
|
|
return res.status(403).json({ error: 'Access denied' });
|
|
}
|
|
|
|
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);
|
|
removeMediaFile(file.folder, file.filename);
|
|
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;
|