390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
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');
|
|
}
|
|
};
|