Add app auth, dashboard, scheduler, video management, and new scrapers

- 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>
This commit is contained in:
Trey T
2026-04-16 07:48:10 -05:00
parent 4903b84aef
commit 236f36aae6
54 changed files with 9986 additions and 420 deletions
+48 -2
View File
@@ -7,9 +7,16 @@ const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (
export function parseUserUrl(url) {
const parsed = new URL(url);
const base = `${parsed.protocol}//${parsed.hostname}`;
// Search URL: /posts?q=query
if (parsed.pathname === '/posts' && parsed.searchParams.get('q')) {
return { base, mode: 'search', query: parsed.searchParams.get('q') };
}
// User URL: /SERVICE/user/USER_ID
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] };
if (!m) throw new Error(`Can't parse URL. Expected: https://coomer.su/SERVICE/user/USER_ID or https://coomer.su/posts?q=QUERY`);
return { base, mode: 'user', service: m[1], userId: m[2] };
}
async function fetchApi(apiUrl, logFn, retries = 3) {
@@ -150,6 +157,45 @@ export async function fetchAllPosts(base, service, userId, maxPages, logFn, chec
return allFiles;
}
export async function fetchSearchPosts(base, query, maxPages, logFn, checkCancelled) {
const allFiles = [];
for (let page = 0; page < maxPages; page++) {
if (checkCancelled()) break;
const offset = page * 50;
const apiUrl = `${base}/api/v1/posts?q=${encodeURIComponent(query)}&o=${offset}`;
let data;
try {
data = await fetchApi(apiUrl, logFn);
} catch (err) {
logFn(`API failed: ${err.message}`);
break;
}
// Search API returns { count, posts: [...] } not a plain array
const posts = data?.posts || data;
if (!posts || !Array.isArray(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);
if (page === 0 && data?.count) {
logFn(`Search found ${data.count} total results`);
}
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 });