import { log } from '$lib/server/logger'; import { db } from '$lib/server/db'; import { mappool, mappoolSlot } from '$lib/server/db/schema'; import { eq, and, asc } from 'drizzle-orm'; import { error, redirect } from '@sveltejs/kit'; import { getBeatmap } from '$lib/server/osu/api'; import { proxyImage } from '$lib/server/storage/r2'; import { requireAuth } from '$lib/server/permissions'; import { categoryToMods, isValidCategory, MOD_REGEX } from '$lib/mods'; import type { PageServerLoad, Actions } from './$types'; export const load: PageServerLoad = async ({ params, locals }) => { const pool = await db.query.mappool.findFirst({ where: eq(mappool.id, params.id), with: { slots: { orderBy: (s, { asc }) => [asc(s.category), asc(s.orderInCategory)] } } }); if (!pool) error(404, 'Mappool not found'); // Use cached metadata from DB — only fall back to osu! API for legacy slots const slotsWithBeatmaps = await Promise.all( pool.slots.map(async (slot) => { // Fast path: metadata already stored in DB if (slot.title !== null) { // Sanitize any stale ppy.sh fallback URLs (image was unavailable when first fetched) const coverUrl = slot.coverUrl?.includes('ppy.sh') ? null : (slot.coverUrl ?? null); return { ...slot, beatmap: { title: slot.title, artist: slot.artist ?? '', version: slot.version ?? '', starRating: slot.starRating ?? 0, bpm: slot.bpm ?? 0, totalLength: slot.totalLength ?? 0, coverUrl, url: `https://osu.ppy.sh/beatmaps/${slot.beatmapId}` } }; } // Slow path: legacy slot without cached metadata — fetch + backfill try { const beatmap = await getBeatmap(slot.beatmapId); const coverUrl = await proxyImage(beatmap.beatmapset.covers['card@2x']); const listCoverUrl = await proxyImage(beatmap.beatmapset.covers['list@2x']); // Backfill the slot so future loads are instant await db .update(mappoolSlot) .set({ title: beatmap.beatmapset.title, artist: beatmap.beatmapset.artist, version: beatmap.version, coverUrl, listCoverUrl, starRating: beatmap.difficulty_rating, bpm: beatmap.bpm, totalLength: beatmap.total_length }) .where(eq(mappoolSlot.id, slot.id)); return { ...slot, beatmap: { title: beatmap.beatmapset.title, artist: beatmap.beatmapset.artist, version: beatmap.version, starRating: beatmap.difficulty_rating, bpm: beatmap.bpm, totalLength: beatmap.total_length, coverUrl, url: beatmap.url } }; } catch { return { ...slot, beatmap: null }; } }) ); const isAdmin = locals.user?.role === 'admin'; const isOwner = locals.user ? pool.createdBy === locals.user.id : false; const isVerified = !!pool.verifiedAt; // Admins can always edit; owners can edit their own unverified pools const canEdit = isAdmin || (isOwner && !isVerified); return { pool: { ...pool, slots: slotsWithBeatmaps }, canEdit, isAdmin, isVerified }; }; /** Check that the user can edit this pool (admin or owner of unverified pool) */ async function requireCanEdit(locals: App.Locals, poolId: string) { requireAuth(locals); const pool = await db.query.mappool.findFirst({ where: eq(mappool.id, poolId) }); if (!pool) error(404, 'Mappool not found'); if (pool.verifiedAt) error(403, 'Verified pools cannot be edited'); const isAdmin = locals.user!.role === 'admin'; const isOwner = pool.createdBy === locals.user!.id; if (!isAdmin && !isOwner) error(403, 'Not authorized to edit this pool'); return pool; } export const actions: Actions = { rename: async ({ params, request, locals }) => { await requireCanEdit(locals, params.id); const form = await request.formData(); const name = form.get('name')?.toString()?.trim(); if (!name) return { error: 'Name is required' }; await db.update(mappool).set({ name }).where(eq(mappool.id, params.id)); return { success: true }; }, addSlot: async ({ params, request, locals }) => { await requireCanEdit(locals, params.id); const form = await request.formData(); let beatmapId = form.get('beatmapId')?.toString()?.trim(); const category = form.get('category')?.toString()?.trim()?.toUpperCase(); if (!beatmapId || !category) { return { error: 'Beatmap ID and category are required' }; } const urlMatch = beatmapId.match(/beatmaps\/(\d+)/); const setMatch = beatmapId.match(/beatmapsets\/\d+#\w+\/(\d+)/); const shortMatch = beatmapId.match(/\/b\/(\d+)/); if (urlMatch) beatmapId = urlMatch[1]; else if (setMatch) beatmapId = setMatch[1]; else if (shortMatch) beatmapId = shortMatch[1]; if (!/^\d+$/.test(beatmapId)) { return { error: 'Invalid beatmap ID' }; } let beatmap; try { beatmap = await getBeatmap(beatmapId); } catch { return { error: 'Beatmap not found on osu!' }; } const existing = await db.query.mappoolSlot.findMany({ where: and(eq(mappoolSlot.mappoolId, params.id), eq(mappoolSlot.category, category)) }); // Proxy covers to R2 at insert time — pages never hit osu! again let coverUrl: string | null = null; let listCoverUrl: string | null = null; try { coverUrl = await proxyImage(beatmap.beatmapset.covers['card@2x']); listCoverUrl = await proxyImage(beatmap.beatmapset.covers['list@2x']); } catch (err: any) { log.osu.warn({ err, beatmapId }, 'failed to proxy covers to R2'); } await db.insert(mappoolSlot).values({ mappoolId: params.id, beatmapId, category, orderInCategory: existing.length + 1, starRating: beatmap.difficulty_rating, bpm: beatmap.bpm, totalLength: beatmap.total_length, mods: categoryToMods(category), // Cached metadata — all R2 URLs title: beatmap.beatmapset.title, artist: beatmap.beatmapset.artist, version: beatmap.version, coverUrl, listCoverUrl }); return { success: true }; }, removeSlot: async ({ params, request, locals }) => { await requireCanEdit(locals, params.id); const form = await request.formData(); const slotId = form.get('slotId')?.toString(); if (!slotId) return { error: 'Missing slot ID' }; await db.delete(mappoolSlot).where(eq(mappoolSlot.id, slotId)); return { success: true }; }, verify: async ({ params, locals }) => { requireAuth(locals); if (locals.user!.role !== 'admin') error(403, 'Admins only'); await db.update(mappool).set({ verifiedAt: new Date() }).where(eq(mappool.id, params.id)); return { success: true }; }, unverify: async ({ params, locals }) => { requireAuth(locals); if (locals.user!.role !== 'admin') error(403, 'Admins only'); await db.update(mappool).set({ verifiedAt: null }).where(eq(mappool.id, params.id)); return { success: true }; }, moveSlot: async ({ params, request, locals }) => { await requireCanEdit(locals, params.id); const form = await request.formData(); const slotId = form.get('slotId')?.toString(); const targetCategory = form.get('targetCategory')?.toString()?.trim()?.toUpperCase(); const targetIndex = parseInt(form.get('targetIndex')?.toString() ?? '', 10); if (!slotId || !targetCategory || isNaN(targetIndex)) { return { error: 'Missing required fields' }; } // Fetch the slot being moved const slot = await db.query.mappoolSlot.findFirst({ where: and(eq(mappoolSlot.id, slotId), eq(mappoolSlot.mappoolId, params.id)) }); if (!slot) return { error: 'Slot not found' }; const sourceCategory = slot.category; const isSameCategory = sourceCategory === targetCategory; // Fetch target category slots (excluding the moved slot) const targetSlots = await db.query.mappoolSlot.findMany({ where: and(eq(mappoolSlot.mappoolId, params.id), eq(mappoolSlot.category, targetCategory)), orderBy: [asc(mappoolSlot.orderInCategory)] }); const filteredTarget = targetSlots.filter((s) => s.id !== slotId); // Clamp targetIndex const clampedIndex = Math.max(0, Math.min(filteredTarget.length, targetIndex)); // Build new order for target category const newTargetOrder = [...filteredTarget]; newTargetOrder.splice(clampedIndex, 0, slot); // Update the moved slot's category and mods const newMods = categoryToMods(targetCategory); await db .update(mappoolSlot) .set({ category: targetCategory, mods: newMods }) .where(eq(mappoolSlot.id, slotId)); // Re-number target category for (let i = 0; i < newTargetOrder.length; i++) { await db .update(mappoolSlot) .set({ orderInCategory: i + 1 }) .where(eq(mappoolSlot.id, newTargetOrder[i].id)); } // If cross-category move, re-number the source category too if (!isSameCategory) { const sourceSlots = await db.query.mappoolSlot.findMany({ where: and(eq(mappoolSlot.mappoolId, params.id), eq(mappoolSlot.category, sourceCategory)), orderBy: [asc(mappoolSlot.orderInCategory)] }); for (let i = 0; i < sourceSlots.length; i++) { await db .update(mappoolSlot) .set({ orderInCategory: i + 1 }) .where(eq(mappoolSlot.id, sourceSlots[i].id)); } } return { success: true }; }, bulkImport: async ({ params, request, locals }) => { await requireCanEdit(locals, params.id); const form = await request.formData(); const raw = form.get('data')?.toString()?.trim(); if (!raw) return { error: 'No data provided' }; const lines = raw .split(/\n/) .map((l) => l.trim()) .filter(Boolean); const errors: string[] = []; let successCount = 0; for (let i = 0; i < lines.length; i++) { const lineNum = i + 1; const parts = lines[i].split(/[\t\s]+/); if (parts.length < 2) { errors.push(`Line ${lineNum}: expected mod code and beatmap ID`); continue; } // Detect column order: mod+id or id+mod let modCode: string; let beatmapRaw: string; if (MOD_REGEX.test(parts[0])) { modCode = parts[0]; beatmapRaw = parts[1]; } else if (MOD_REGEX.test(parts[parts.length - 1])) { modCode = parts[parts.length - 1]; beatmapRaw = parts[0]; } else { errors.push(`Line ${lineNum}: no valid mod code found`); continue; } const modMatch = modCode.match(MOD_REGEX)!; const category = modMatch[1].toUpperCase(); let beatmapId = beatmapRaw; const urlMatch = beatmapId.match(/beatmaps\/(\d+)/); const setMatch = beatmapId.match(/beatmapsets\/\d+#\w+\/(\d+)/); const shortMatch = beatmapId.match(/\/b\/(\d+)/); if (urlMatch) beatmapId = urlMatch[1]; else if (setMatch) beatmapId = setMatch[1]; else if (shortMatch) beatmapId = shortMatch[1]; if (!/^\d+$/.test(beatmapId)) { errors.push(`Line ${lineNum}: invalid beatmap ID "${parts[1]}"`); continue; } let beatmap; try { beatmap = await getBeatmap(beatmapId); } catch { errors.push(`Line ${lineNum}: beatmap ${beatmapId} not found on osu!`); continue; } const existing = await db.query.mappoolSlot.findMany({ where: and(eq(mappoolSlot.mappoolId, params.id), eq(mappoolSlot.category, category)) }); let coverUrl: string | null = null; let listCoverUrl: string | null = null; try { coverUrl = await proxyImage(beatmap.beatmapset.covers['card@2x']); listCoverUrl = await proxyImage(beatmap.beatmapset.covers['list@2x']); } catch (err: any) { log.osu.warn({ err, beatmapId }, 'failed to proxy covers to R2'); } await db.insert(mappoolSlot).values({ mappoolId: params.id, beatmapId, category, orderInCategory: existing.length + 1, starRating: beatmap.difficulty_rating, bpm: beatmap.bpm, totalLength: beatmap.total_length, mods: categoryToMods(category), title: beatmap.beatmapset.title, artist: beatmap.beatmapset.artist, version: beatmap.version, coverUrl, listCoverUrl }); successCount++; } if (errors.length > 0) { return { bulkImportResult: { successCount, errors } }; } return { bulkImportResult: { successCount, errors: [] } }; }, deletePool: async ({ params, locals }) => { requireAuth(locals); if (locals.user!.role !== 'admin') error(403, 'Admins only'); await db.delete(mappool).where(eq(mappool.id, params.id)); redirect(303, '/mappools'); } };