Initial commit — OFApp client + server
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
143
server/gallery.js
Normal file
143
server/gallery.js
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Router } from 'express';
|
||||
import { readdirSync, statSync } from 'fs';
|
||||
import { join, extname } from 'path';
|
||||
import { getPostDateByFilename, getSetting } from './db.js';
|
||||
|
||||
const router = Router();
|
||||
const MEDIA_PATH = process.env.MEDIA_PATH || './data/media';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 = [];
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
folders.sort((a, b) => a.name.localeCompare(b.name));
|
||||
res.json(folders);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/gallery/files?folder=&type=&sort=&offset=&limit=
|
||||
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 offsetNum = parseInt(offset || '0', 10);
|
||||
const limitNum = parseInt(limit || '50', 10);
|
||||
|
||||
let allFiles = [];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// 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' });
|
||||
}
|
||||
|
||||
const filePath = join(MEDIA_PATH, folder, filename);
|
||||
res.sendFile(filePath, { root: '/' }, (err) => {
|
||||
if (err && !res.headersSent) {
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user