diff --git a/db/migration/019-add-tunicwilds.sql b/db/migration/019-add-tunicwilds.sql
new file mode 100644
index 0000000..0779d9c
--- /dev/null
+++ b/db/migration/019-add-tunicwilds.sql
@@ -0,0 +1,10 @@
+CREATE TABLE "Tunicwild" (
+ "id" integer PRIMARY KEY AUTOINCREMENT,
+ "memberDiscord" text NOT NULL,
+ "composer" text NOT NULL,
+ "title" text NOT NULL,
+ "game" text NOT NULL,
+ "releaseDate" text NOT NULL,
+ "officialLink" text NOT NULL,
+ FOREIGN KEY ("memberDiscord") REFERENCES "Member" ("discord")
+);
diff --git a/development.sh b/development.sh
index 92ebeb5..cecd31b 100755
--- a/development.sh
+++ b/development.sh
@@ -13,22 +13,40 @@ export CORPORATE_URL=
export DATABASE_URL="file:./$database"
export DIGITALOCEAN_DNS_TOKEN=
export LATEX_WEB_SYSTEMD_UNIT=
+export PUBLIC_DIRECTORY='public'
export SECRET_HMAC_KEY='dev'
export SIGHTS_RUN_AFTER_UPLOAD=
export SIGHTS_UPLOAD_DIRECTORY='dev/sights'
export SOUNDS_RUN_AFTER_UPLOAD=
export SOUNDS_UPLOAD_DIRECTORY='dev/sounds'
+export TUNICWILDS_DAILY_FILE='dev/tunicwilds-daily.json'
+export TUNICWILDS_RENDERED_DIRECTORY='dev/tunicwilds-rendered'
+export TUNICWILDS_RUN_AFTER_RENDER=
+export TUNICWILDS_UPLOAD_DIRECTORY='dev/tunicwilds'
+export TUNICWILDS_UPLOAD_URL=
export WORDS_RUN_AFTER_UPLOAD=
export WORDS_UPLOAD_DIRECTORY='dev/words'
-export PUBLIC_DIRECTORY='public'
+
+if test $# -gt 0; then
+ exec "$@"
+fi
# Clean up files from last run
-rm -rf "$database" "$SIGHTS_UPLOAD_DIRECTORY" "$SOUNDS_UPLOAD_DIRECTORY" "$WORDS_UPLOAD_DIRECTORY"
+rm -rf \
+ "$database" \
+ "$SIGHTS_UPLOAD_DIRECTORY" \
+ "$SOUNDS_UPLOAD_DIRECTORY" \
+ "$WORDS_UPLOAD_DIRECTORY" \
+ "$TUNICWILDS_DAILY_FILE" \
+ "$TUNICWILDS_RENDERED_DIRECTORY" \
+ "$TUNICWILDS_UPLOAD_DIRECTORY"
# Create directories
mkdir -p "$SIGHTS_UPLOAD_DIRECTORY"
mkdir -p "$SOUNDS_UPLOAD_DIRECTORY"
mkdir -p "$WORDS_UPLOAD_DIRECTORY"
+mkdir -p "$TUNICWILDS_RENDERED_DIRECTORY"
+mkdir -p "$TUNICWILDS_UPLOAD_DIRECTORY"
# Create database
node db/migrate.mjs "$database"
diff --git a/src/bin/start-tunicwild-day.ts b/src/bin/start-tunicwild-day.ts
new file mode 100644
index 0000000..b768a17
--- /dev/null
+++ b/src/bin/start-tunicwild-day.ts
@@ -0,0 +1,186 @@
+import { execFileSync, spawnSync } from "node:child_process";
+import { randomBytes } from "node:crypto";
+import { readFile, unlink, writeFile } from "node:fs/promises";
+import { join, resolve } from "node:path";
+import { notInArray, sql } from "drizzle-orm";
+import db from "../database/db";
+import { Tunicwild } from "../database/schema";
+
+if (!process.env.TUNICWILDS_DAILY_FILE) {
+ console.error("TUNICWILDS_DAILY_FILE not set");
+ process.exit(1);
+}
+
+if (!process.env.TUNICWILDS_RENDERED_DIRECTORY) {
+ console.error("TUNICWILDS_RENDERED_DIRECTORY not set");
+ process.exit(1);
+}
+
+if (!process.env.TUNICWILDS_UPLOAD_URL && !process.env.TUNICWILDS_UPLOAD_DIRECTORY) {
+ console.error("TUNICWILDS_UPLOAD_* not set");
+ process.exit(1);
+}
+
+interface DailyInfo {
+ active: {
+ audioFilenames: string[];
+ date: string;
+ id: number;
+ }[];
+ recentIds: number[];
+ startDate: string;
+}
+
+const audioLengths = [0.5, 1, 2, 4, 8, 16] as const;
+
+async function renderAudio(id: number): Promise {
+ let audioBuffer: NodeJS.ArrayBufferView;
+ if (process.env.TUNICWILDS_UPLOAD_URL) {
+ const response = await fetch(`${process.env.TUNICWILDS_UPLOAD_URL}/${id}`);
+ audioBuffer = await response.bytes();
+ } else if (process.env.TUNICWILDS_UPLOAD_DIRECTORY) {
+ audioBuffer = await readFile(join(process.env.TUNICWILDS_UPLOAD_DIRECTORY, id.toString()));
+ } else {
+ throw new Error();
+ }
+
+ const ffmpegStatsResult = spawnSync("ffmpeg", [
+ "-v", "quiet",
+ "-stats",
+ "-i", "pipe:",
+ "-map", "0:a:0",
+ "-f", "null",
+ "-",
+ ], {
+ encoding: "utf8",
+ input: audioBuffer,
+ stdio: ["pipe", "ignore", "pipe"],
+ });
+
+ if (ffmpegStatsResult.error != null) {
+ throw ffmpegStatsResult.error;
+ }
+
+ const timeMatch = ffmpegStatsResult.stderr.match(/time=(\d{2,}):(\d{2}):(\d{2}\.\d{2})[^\r]*$/);
+
+ if (timeMatch == null) {
+ throw new Error(`Unexpected ffmpeg output:\n${ffmpegStatsResult}`);
+ }
+
+ const durationSeconds =
+ Number.parseInt(timeMatch[1]!, 10) * 60 * 60 +
+ Number.parseInt(timeMatch[2]!, 10) * 60 +
+ Number.parseFloat(timeMatch[3]!);
+
+ const filenames = audioLengths.map(() => `${randomBytes(16).toString("hex")}.mp3`);
+ const maxAudioLength = audioLengths[audioLengths.length - 1]!;
+ const maxAudioLengthFilename = filenames[audioLengths.length - 1]!;
+
+ execFileSync("ffmpeg", [
+ "-v", "quiet",
+ "-ss", Math.max(0, Math.floor((durationSeconds - maxAudioLength) * Math.random())).toString(),
+ "-t", maxAudioLength.toString(),
+ "-i", "pipe:",
+ "-map", "0:a:0",
+ "-map_metadata", "-1",
+ "-c:a", "libmp3lame",
+ "-q:a", "0",
+ join(process.env.TUNICWILDS_RENDERED_DIRECTORY!, maxAudioLengthFilename),
+ ], {
+ input: audioBuffer,
+ stdio: ["pipe", "ignore", "ignore"],
+ });
+
+ for (let i = 0; i < audioLengths.length - 1; i++) {
+ execFileSync("ffmpeg", [
+ "-v", "quiet",
+ "-t", audioLengths[i]!.toString(),
+ "-i", join(process.env.TUNICWILDS_RENDERED_DIRECTORY!, maxAudioLengthFilename),
+ "-c", "copy",
+ join(process.env.TUNICWILDS_RENDERED_DIRECTORY!, filenames[i]!),
+ ], {
+ stdio: "ignore",
+ });
+ }
+
+ if (process.env.TUNICWILDS_RUN_AFTER_RENDER) {
+ execFileSync(
+ process.env.TUNICWILDS_RUN_AFTER_RENDER,
+ filenames.map(
+ (filename) => join(process.env.TUNICWILDS_RENDERED_DIRECTORY!, filename),
+ ),
+ { stdio: "ignore" },
+ );
+ }
+
+ return filenames;
+}
+
+// From "dist/server/bin/"
+process.chdir(resolve(import.meta.dirname, "../../.."));
+
+let dailyInfo: DailyInfo;
+try {
+ dailyInfo = JSON.parse(await readFile(process.env.TUNICWILDS_DAILY_FILE, "utf8"));
+} catch {
+ dailyInfo = {
+ active: [],
+ recentIds: [],
+ startDate: new Date().toISOString().slice(0, 10),
+ };
+}
+
+if (dailyInfo.active.length === 0) {
+ const tunicwilds = await db
+ .select()
+ .from(Tunicwild)
+ .orderBy(sql`RANDOM()`)
+ .limit(3);
+
+ if (tunicwilds.length !== 3) {
+ console.error("Not enough Tunicwilds in database");
+ process.exit(1);
+ }
+
+ for (let i = 0; i < tunicwilds.length; i++) {
+ dailyInfo.active.push({
+ audioFilenames: await renderAudio(tunicwilds[i]!.id),
+ date: new Date(Date.now() + (i - 1) * 1000 * 60 * 60 * 24).toISOString().slice(0, 10),
+ id: tunicwilds[i]!.id,
+ });
+ dailyInfo.recentIds.push(tunicwilds[i]!.id);
+ }
+} else {
+ const tunicwild = await db
+ .select()
+ .from(Tunicwild)
+ .where(notInArray(Tunicwild.id, dailyInfo.recentIds))
+ .orderBy(sql`RANDOM()`)
+ .get();
+
+ if (tunicwild == null) {
+ console.error("Not enough Tunicwilds in database");
+ process.exit(1);
+ }
+
+ dailyInfo.active.push({
+ audioFilenames: await renderAudio(tunicwild.id),
+ date: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString().slice(0, 10),
+ id: tunicwild.id,
+ });
+ dailyInfo.recentIds.push(tunicwild.id);
+
+ if (dailyInfo.active.length > 3) {
+ const dropped = dailyInfo.active.shift()!;
+
+ await Promise.all(dropped.audioFilenames.map(
+ (filename) => unlink(join(process.env.TUNICWILDS_RENDERED_DIRECTORY!, filename)),
+ ));
+ }
+
+ if (dailyInfo.recentIds.length > 30) {
+ dailyInfo.recentIds.shift();
+ }
+}
+
+await writeFile(process.env.TUNICWILDS_DAILY_FILE, JSON.stringify(dailyInfo));
diff --git a/src/components/Tunicwilds/Tunicwilds.svelte b/src/components/Tunicwilds/Tunicwilds.svelte
new file mode 100644
index 0000000..d673fdb
--- /dev/null
+++ b/src/components/Tunicwilds/Tunicwilds.svelte
@@ -0,0 +1,783 @@
+
+
+
+
+
+
+
+
+ {#if showSongList}
+
+
+
Song List
+
(showSongList = false)}
+ >
+ Close
+
+ {#each Object.entries(gameGroupedSongList) as [game, songs]}
+
+ {/each}
+
+
+ {/if}
+
+ {#if error}
+
Error: {error}
+ {:else if songData == null}
+
Loading today's song...
+ {:else}
+
+ {#if result != null}
+
+ {/if}
+
+
+
+
Your Guesses:
+
+ {#each Array(maxGuesses) as _, index}
+ {@const guess = guesses[index]}
+ {@const guessedSong = songList.find(
+ (song) => song.id === guess?.id,
+ )}
+
+
+ {/each}
+
+
+
+
+
+ {#if result == null}
+
+
+ Clip length: {clipLengths[guesses.length]}s
+
+
+ Attempt {guesses.length + 1} of {maxGuesses}
+
+
+ {/if}
+
+
+
+ {isPlaying ? "âšī¸ Stop" : "âļī¸ Play"}
+
+
+
+
+ {#if result == null}
+ {#if songData.tunicwild.releaseDate != null}
+
+ đĄ Hint: This song was released on
+ {new Date(
+ songData.tunicwild.releaseDate,
+ ).toLocaleDateString(undefined, {
+ dateStyle: "long",
+ timeZone: "UTC",
+ })}
+
+ {/if}
+
+ {#if songData.tunicwild.game != null && !guesses.some((guess) => guess?.result === "correctGame")}
+
+ đĄ Hint: This song is from
+ {songData.tunicwild.game}
+
+ {/if}
+ {/if}
+
+
+
+ {#each clipLengths as length}
+
+ {/each}
+
+
+
+
+
+
+
+
+
+ {#if result == null}
+
+ {/if}
+ {/if}
+
+
+
diff --git a/src/database/factories.ts b/src/database/factories.ts
index e70abd6..309d43c 100644
--- a/src/database/factories.ts
+++ b/src/database/factories.ts
@@ -1,6 +1,6 @@
import { faker } from "@faker-js/faker";
import defineFactory from "./defineFactory";
-import { Action, ActionItem, Member, Motion, Session, Sight, Sound, Ticket, Word } from "./schema";
+import { Action, ActionItem, Member, Motion, Session, Sight, Sound, Ticket, Tunicwild, Word } from "./schema";
function unique(fn: () => T): () => T {
const used = new Set();
@@ -83,6 +83,15 @@ export const TicketFactory = defineFactory(Ticket, {
hash: () => faker.string.alphanumeric(32),
});
+export const TunicwildFactory = defineFactory(Tunicwild, {
+ memberDiscord: () => { throw new Error("Not implemented"); },
+ composer: () => faker.person.firstName(),
+ title: () => faker.music.songName(),
+ game: () => faker.commerce.productName(),
+ releaseDate: () => faker.date.past(),
+ officialLink: () => faker.internet.url(),
+});
+
export const WordFactory = defineFactory(Word, {
title: () => faker.book.title(),
memberDiscord: () => { throw new Error("Not implemented"); },
diff --git a/src/database/schema.ts b/src/database/schema.ts
index 9f4f58d..6d205bb 100644
--- a/src/database/schema.ts
+++ b/src/database/schema.ts
@@ -1,6 +1,5 @@
import { relations, sql } from "drizzle-orm";
import { sqliteTable, integer, text, customType, uniqueIndex } from "drizzle-orm/sqlite-core";
-import type { SessionData } from "../server/session";
const date = customType<{ data: Date; driverData: string }>({
dataType: () => "text",
@@ -63,6 +62,7 @@ export const MemberRelations = relations(Member, ({ many }) => ({
sights: many(Sight),
sounds: many(Sound),
tickets: many(Ticket),
+ tunicwilds: many(Tunicwild),
words: many(Word),
}));
@@ -146,6 +146,23 @@ export const TicketRelations = relations(Ticket, ({ one }) => ({
}),
}));
+export const Tunicwild = sqliteTable("Tunicwild", {
+ id: integer().primaryKey({ autoIncrement: true }),
+ memberDiscord: text().notNull().references(() => Member.discord),
+ composer: text().notNull(),
+ title: text().notNull(),
+ game: text().notNull(),
+ releaseDate: date().notNull(),
+ officialLink: text().notNull(),
+});
+
+export const TunicwildRelations = relations(Tunicwild, ({ one }) => ({
+ member: one(Member, {
+ fields: [Tunicwild.memberDiscord],
+ references: [Member.discord],
+ }),
+}));
+
export const Word = sqliteTable("Word", {
id: integer().primaryKey({ autoIncrement: true }),
title: text().notNull(),
diff --git a/src/database/seed.ts b/src/database/seed.ts
index 95dc4fe..281ad28 100644
--- a/src/database/seed.ts
+++ b/src/database/seed.ts
@@ -1,5 +1,11 @@
import { faker } from "@faker-js/faker";
-import { ActionFactory, ActionItemFactory, MemberFactory, MotionFactory, SightFactory, SoundFactory, WordFactory } from "./factories";
+import { ActionFactory, ActionItemFactory, MemberFactory, MotionFactory, SightFactory, SoundFactory, TunicwildFactory, WordFactory } from "./factories";
+import { writeFile } from "fs/promises";
+import { join } from "path";
+
+if (!process.env.TUNICWILDS_UPLOAD_DIRECTORY) {
+ throw new Error("TUNICWILDS_UPLOAD_DIRECTORY not set");
+}
export default async function seed() {
const members = await new MemberFactory().count(20).create();
@@ -43,4 +49,18 @@ export default async function seed() {
await new WordFactory().count(10).create({
memberDiscord: () => faker.helpers.arrayElement(members.map((member) => member.discord)),
});
+
+ const tunicwilds = await new TunicwildFactory().count(10).create({
+ memberDiscord: () => faker.helpers.arrayElement(members.map((member) => member.discord)),
+ });
+
+ const placeholderAudioResponse = await fetch("https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3");
+ const placeholderAudio = await placeholderAudioResponse.bytes();
+
+ for (const tunicwild of tunicwilds) {
+ await writeFile(
+ join(process.env.TUNICWILDS_UPLOAD_DIRECTORY!, tunicwild.id.toString()),
+ placeholderAudio,
+ );
+ }
}
diff --git a/src/database/utils.ts b/src/database/utils.ts
new file mode 100644
index 0000000..c1f1d12
--- /dev/null
+++ b/src/database/utils.ts
@@ -0,0 +1,14 @@
+import { SQL, sql } from "drizzle-orm";
+import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
+
+export function lower(column: SQLiteColumn | string): SQL {
+ return sql`lower(${column})`;
+}
+
+export function upper(column: SQLiteColumn | string): SQL {
+ return sql`upper(${column})`;
+}
+
+export function like(column: SQLiteColumn | string, value: string): SQL {
+ return sql`${column} LIKE ${value}`;
+}
\ No newline at end of file
diff --git a/src/middleware.ts b/src/middleware.ts
index dde5b8f..6332a96 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -9,7 +9,11 @@ const checkHmacForApi = defineMiddleware(async (context, next) => {
return next();
}
- if (context.url.pathname.startsWith("/api/action") && context.request.method === "GET") {
+ if (
+ (
+ context.url.pathname.startsWith("/api/action") ||
+ context.url.pathname.startsWith("/api/tunicwild")
+ ) && context.request.method === "GET") {
return next();
}
@@ -59,6 +63,10 @@ const serveUploadedFilesInDev = defineMiddleware(async (context, next) => {
return new Response("SOUNDS_UPLOAD_DIRECTORY not set", { status: 500 });
}
+ if (!process.env.TUNICWILDS_RENDERED_DIRECTORY) {
+ return new Response("TUNICWILDS_RENDERED_DIRECTORY not set", { status: 500 });
+ }
+
if (!process.env.WORDS_UPLOAD_DIRECTORY) {
return new Response("WORDS_UPLOAD_DIRECTORY not set", { status: 500 });
}
@@ -66,6 +74,7 @@ const serveUploadedFilesInDev = defineMiddleware(async (context, next) => {
const rewrites = [
["/sights-uploads/", process.env.SIGHTS_UPLOAD_DIRECTORY + "/"],
["/sounds-uploads/", process.env.SOUNDS_UPLOAD_DIRECTORY + "/"],
+ ["/tunicwilds-rendered/", process.env.TUNICWILDS_RENDERED_DIRECTORY + "/"],
["/words-uploads/", process.env.WORDS_UPLOAD_DIRECTORY + "/"],
] as const;
diff --git a/src/pages/api/tunicwilds/games.ts b/src/pages/api/tunicwilds/games.ts
new file mode 100644
index 0000000..d8c2f96
--- /dev/null
+++ b/src/pages/api/tunicwilds/games.ts
@@ -0,0 +1,18 @@
+import type { APIRoute } from "astro";
+import db from "../../../database/db";
+import { Tunicwild } from "../../../database/schema";
+import { jsonResponse } from "../../../server/responses";
+
+export const prerender = false;
+
+export const GET: APIRoute = async () => {
+ return jsonResponse(
+ await db
+ .select({
+ game: Tunicwild.game
+ })
+ .from(Tunicwild)
+ .groupBy(Tunicwild.game)
+ .orderBy(Tunicwild.game),
+ );
+};
diff --git a/src/pages/api/tunicwilds/index.ts b/src/pages/api/tunicwilds/index.ts
new file mode 100644
index 0000000..b9b983a
--- /dev/null
+++ b/src/pages/api/tunicwilds/index.ts
@@ -0,0 +1,251 @@
+import { join } from "node:path";
+import { LibsqlError } from "@libsql/client";
+import type { APIRoute } from "astro";
+import { and, eq, type InferSelectModel, type SQLWrapper } from "drizzle-orm";
+import { jsonError, jsonResponse } from "../../../server/responses";
+import db, { retryIfDbBusy } from "../../../database/db";
+import { Member, Tunicwild } from "../../../database/schema";
+import { paginationQuery, parseNumberCursor } from "../../../server/pagination";
+import { writeBlobToFile } from "../../../server/webApi";
+import { lower } from "../../../database/utils";
+
+export const prerender = false;
+
+export const GET: APIRoute = async ({ url }) => {
+ const conditions: SQLWrapper[] = [];
+ const params = url.searchParams;
+
+ const game = params.get("game");
+ if (game)
+ conditions.push(eq(lower(Tunicwild.game), lower(game)));
+
+ const composer = params.get("composer");
+ if (composer)
+ conditions.push(eq(lower(Tunicwild.composer), lower(composer)));
+
+ const title = params.get("title");
+ if (title)
+ conditions.push(eq(lower(Tunicwild.title), lower(title)));
+
+ const releaseDate = params.get("releaseDate");
+ if (releaseDate) {
+ const date = new Date(releaseDate);
+ if (isNaN(date.getTime()))
+ return jsonError("Invalid release date");
+ conditions.push(eq(Tunicwild.releaseDate, date));
+ }
+
+ return paginationQuery(
+ params,
+ Tunicwild,
+ "id",
+ parseNumberCursor,
+ ...conditions,
+ );
+}
+
+export const POST: APIRoute = async (context) => {
+ if (!process.env.TUNICWILDS_UPLOAD_URL && !process.env.TUNICWILDS_UPLOAD_DIRECTORY) {
+ return jsonError("TUNICWILDS_UPLOAD_* not set", 500);
+ }
+
+ let formData: FormData;
+ try {
+ formData = await context.request.formData();
+ } catch {
+ return jsonError("Request body must be form data");
+ }
+
+ const discord = formData.get("discord");
+ const composer = formData.get("composer");
+ const title = formData.get("title");
+ const game = formData.get("game");
+ const releaseDate = formData.get("releaseDate");
+ const officialLink = formData.get("officialLink");
+ const file = formData.get("file") as File;
+
+ if (typeof discord !== "string" || !discord) {
+ return jsonError("Member discord is required");
+ }
+ if (typeof composer !== "string" || !composer.trim()) {
+ return jsonError("Composer is required");
+ }
+ if (typeof title !== "string" || !title.trim()) {
+ return jsonError("Title is required");
+ }
+ if (typeof game !== "string" || !game.trim()) {
+ return jsonError("Game is required");
+ }
+ if (typeof releaseDate !== "string" || !releaseDate.trim()) {
+ return jsonError("Release date is required");
+ }
+ if (typeof officialLink !== "string" || !officialLink.trim()) {
+ return jsonError("Official link is required");
+ }
+ if (!(file instanceof File) || file.size === 0) {
+ return jsonError("Valid audio file is required");
+ }
+
+ // Parse and validate release date
+ const releaseDateParsed = new Date(releaseDate);
+ if (isNaN(releaseDateParsed.getTime())) {
+ return jsonError("Invalid release date format. Use YYYY-MM-DD or ISO format");
+ }
+ if (releaseDateParsed.getTime() > Date.now()) {
+ return jsonError("Release date cannot be in the future");
+ }
+
+ // Length validation with better limits
+ const MAX_LENGTH = 2 ** 10;
+ if (composer.length > MAX_LENGTH) {
+ return jsonError(`Composer name too long (max ${MAX_LENGTH} characters)`);
+ }
+ if (title.length > MAX_LENGTH) {
+ return jsonError(`Title too long (max ${MAX_LENGTH} characters)`);
+ }
+ if (game.length > MAX_LENGTH) {
+ return jsonError(`Game name too long (max ${MAX_LENGTH} characters)`);
+ }
+
+ // File validation with size limits
+ const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
+ if (file.size > MAX_FILE_SIZE) {
+ return jsonError("File too large (max 50MB)");
+ }
+
+ const member = await db
+ .select()
+ .from(Member)
+ .where(eq(Member.discord, discord))
+ .get();
+
+ if (member == null) {
+ return jsonError("Member does not exist");
+ }
+
+ if (member.deleted) {
+ return jsonError("Member has been deleted");
+ }
+
+ // More robust file type checking
+ const allowedExtensions = [".ogg", ".opus", ".wav", ".mp3"];
+ const allowedMimeTypes = ["audio/ogg", "audio/opus", "audio/wav", "audio/mpeg", "audio/mp3"];
+
+ const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
+ const isValidExtension = allowedExtensions.includes(fileExtension);
+ const isValidMimeType = allowedMimeTypes.includes(file.type.toLowerCase());
+
+ if (!isValidExtension && !isValidMimeType) {
+ return jsonError("File must be an OGG, Opus, WAV, or MP3 file");
+ }
+
+ // Check for duplicates with case-insensitive comparison
+ const existingTunicwild = await db
+ .select()
+ .from(Tunicwild)
+ .where(
+ and(
+ eq(lower(Tunicwild.title), lower(title.trim())),
+ eq(lower(Tunicwild.game), lower(game.trim()))
+ )
+ )
+ .get();
+
+ if (existingTunicwild) {
+ return jsonError(`A song with title "${title}" already exists in game "${game}"`);
+ }
+
+ // Store to DB
+ let tunicwild: InferSelectModel;
+ try {
+ tunicwild = await retryIfDbBusy(() =>
+ db
+ .insert(Tunicwild)
+ .values({
+ memberDiscord: discord,
+ composer: composer.trim(),
+ title: title.trim(),
+ game: game.trim(),
+ releaseDate: releaseDateParsed,
+ officialLink: officialLink.trim(),
+ })
+ .returning()
+ .get()
+ );
+ } catch (error) {
+ if (error instanceof LibsqlError) {
+ if (error.code === "SQLITE_CONSTRAINT_UNIQUE") {
+ return jsonError("A song with this title and game already exists");
+ }
+ if (error.code === "SQLITE_CONSTRAINT_FOREIGNKEY") {
+ return jsonError("Invalid foreign key constraint");
+ }
+ }
+ console.error("Database error:", error);
+ return jsonError("Failed to save song to database", 500);
+ }
+
+ // Upload files with better error handling
+ try {
+ if (process.env.TUNICWILDS_UPLOAD_DIRECTORY) {
+ await writeBlobToFile(
+ join(process.env.TUNICWILDS_UPLOAD_DIRECTORY, tunicwild.id.toString()),
+ file,
+ );
+ } else if (process.env.TUNICWILDS_UPLOAD_URL) {
+ const uploadResponse = await fetch(
+ `${process.env.TUNICWILDS_UPLOAD_URL}/upload/${tunicwild.id}`,
+ {
+ method: "PUT",
+ body: file,
+ headers: {
+ "Content-Length": file.size.toString(),
+ },
+ }
+ );
+
+ if (!uploadResponse.ok) {
+ // If timed out, retry a few times
+ if (uploadResponse.status === 408 || uploadResponse.status === 504) {
+ for (let i = 0; i < 3; i++) {
+ const retryResponse = await fetch(
+ `${process.env.TUNICWILDS_UPLOAD_URL}/upload/${tunicwild.id}`,
+ {
+ method: "PUT",
+ body: file,
+ headers: {
+ "Content-Length": file.size.toString(),
+ },
+ }
+ );
+ if (retryResponse.ok)
+ break;
+
+ if (i === 2) {
+ // Rollback database entry if all retries fail
+ await db.delete(Tunicwild).where(eq(Tunicwild.id, tunicwild.id));
+ return jsonError(`Failed to upload file after retries: ${retryResponse.statusText}`, 500);
+ }
+ }
+ } else {
+ // Rollback database entry if file upload fails
+ await db.delete(Tunicwild).where(eq(Tunicwild.id, tunicwild.id));
+ return jsonError(`Failed to upload file: ${uploadResponse.statusText}`, 500);
+ }
+ }
+ } else {
+ return jsonError("", 500);
+ }
+ } catch (error) {
+ console.error("File upload error:", error);
+ // Rollback database entry if file upload fails
+ try {
+ await db.delete(Tunicwild).where(eq(Tunicwild.id, tunicwild.id));
+ } catch (rollbackError) {
+ console.error("Failed to rollback database entry:", rollbackError);
+ }
+ return jsonError("Failed to upload audio file", 500);
+ }
+
+ return jsonResponse(tunicwild);
+};
diff --git a/src/pages/corp/sso/perform.ts b/src/pages/corp/sso/perform.ts
index 73e2df5..66ca857 100644
--- a/src/pages/corp/sso/perform.ts
+++ b/src/pages/corp/sso/perform.ts
@@ -7,6 +7,12 @@ import { jsonError } from "../../../server/responses";
export const prerender = false;
+declare global {
+ interface SessionData {
+ memberDiscord?: string;
+ }
+}
+
export const POST: APIRoute = async (context) => {
let formData: FormData;
try {
diff --git a/src/pages/things.astro b/src/pages/things.astro
index 635c38b..53ae30c 100644
--- a/src/pages/things.astro
+++ b/src/pages/things.astro
@@ -12,6 +12,7 @@ import Layout from "../layouts/Layout.astro";
features/content
diff --git a/src/pages/tunicwilds/index.astro b/src/pages/tunicwilds/index.astro
new file mode 100644
index 0000000..25d3392
--- /dev/null
+++ b/src/pages/tunicwilds/index.astro
@@ -0,0 +1,25 @@
+---
+import Layout from "../../layouts/Layout.astro";
+import TunicwildsApp from "../../components/Tunicwilds/Tunicwilds.svelte";
+import db from "../../database/db";
+import { Tunicwild } from "../../database/schema";
+
+export const prerender = false;
+
+const songList = await db
+ .select({
+ id: Tunicwild.id,
+ composer: Tunicwild.composer,
+ game: Tunicwild.game,
+ officialLink: Tunicwild.officialLink,
+ title: Tunicwild.title,
+ })
+ .from(Tunicwild);
+---
+
+
+
+
diff --git a/src/pages/tunicwilds/today.ts b/src/pages/tunicwilds/today.ts
new file mode 100644
index 0000000..5f79e49
--- /dev/null
+++ b/src/pages/tunicwilds/today.ts
@@ -0,0 +1,190 @@
+import type { APIRoute } from "astro";
+import { jsonError, jsonResponse } from "../../server/responses";
+import db from "../../database/db";
+import { Tunicwild } from "../../database/schema";
+import { eq, type InferSelectModel } from "drizzle-orm";
+import { readFile } from "node:fs/promises";
+
+export const prerender = false;
+
+if (!process.env.TUNICWILDS_DAILY_FILE) {
+ throw new Error("TUNICWILDS_DAILY_FILE not set");
+}
+
+declare global {
+ interface SessionData {
+ tunicwilds?: Record;
+ }
+}
+
+interface DailyInfo {
+ active: {
+ audioFilenames: string[];
+ date: string;
+ id: number;
+ }[];
+ recentIds: number[];
+ startDate: string;
+}
+
+export const GET: APIRoute = async (context) => {
+ const date = new Date(Number.parseInt(context.url.searchParams.get("timestamp") ?? "", 10));
+
+ if (Number.isNaN(date.getTime())) {
+ return jsonError("Invalid timestamp");
+ }
+
+ let dailyInfo: DailyInfo;
+ try {
+ dailyInfo = JSON.parse(await readFile(process.env.TUNICWILDS_DAILY_FILE!, "utf8"));
+ } catch (error) {
+ console.error("Failed to read or parse daily tunicwilds file:", error);
+ return jsonError("Failed to read daily tunicwilds file");
+ }
+ const tunicwildDateString = date.toISOString().slice(0, 10);
+ const tunicwildInfo = dailyInfo.active.find((info) => info.date === tunicwildDateString);
+
+ if (tunicwildInfo == null) {
+ return jsonError("Invalid date. Check if your system clock is set correctly");
+ }
+
+ const tunicwild = await db
+ .select()
+ .from(Tunicwild)
+ .where(eq(Tunicwild.id, tunicwildInfo.id))
+ .get();
+
+ if (tunicwild == null) {
+ throw new Error("Missing daily tunicwild in database");
+ }
+
+ const tunicwildsSession = context.locals.session.data.tunicwilds?.[tunicwildDateString] ?? {
+ guesses: [],
+ result: null,
+ };
+ const responseBody: {
+ fourFourFiveEnabled: boolean;
+ session: Required["tunicwilds"][string];
+ tunicwild: Partial> & { audioUrl: string };
+ } = {
+ fourFourFiveEnabled: context.locals.session.data.memberDiscord != null,
+ session: tunicwildsSession,
+ tunicwild: {
+ audioUrl: `/tunicwilds-rendered/${tunicwildInfo.audioFilenames[
+ tunicwildsSession.result != null
+ ? tunicwildInfo.audioFilenames.length - 1
+ : tunicwildsSession.guesses.length
+ ]}`,
+ },
+ };
+
+ if (tunicwildsSession.result != null) {
+ Object.assign(responseBody.tunicwild, tunicwild);
+ delete responseBody.tunicwild.memberDiscord;
+ } else {
+ const guessCount = tunicwildsSession.guesses.length;
+
+ if (guessCount >= 2) {
+ responseBody.tunicwild.releaseDate = tunicwild.releaseDate;
+ }
+
+ if (guessCount >= 4) {
+ responseBody.tunicwild.game = tunicwild.game;
+ }
+ }
+
+ return jsonResponse(responseBody);
+};
+
+export const POST: APIRoute = async (context) => {
+ const params: Partial> = await context.request.json();
+
+ if (
+ typeof params.timestamp !== "number" ||
+ (params.guess != null && typeof params.guess !== "number")
+ ) {
+ return jsonError("Invalid body");
+ }
+
+ const date = new Date(params.timestamp);
+
+ if (Number.isNaN(date.getTime())) {
+ return jsonError("Invalid timestamp");
+ }
+
+ const dailyInfo: DailyInfo = JSON.parse(await readFile(process.env.TUNICWILDS_DAILY_FILE!, "utf8"));
+ const tunicwildDateString = date.toISOString().slice(0, 10);
+ const tunicwildId = dailyInfo.active.find((info) => info.date === tunicwildDateString)?.id;
+
+ if (tunicwildId == null) {
+ return jsonError("Invalid date. Check if your system clock is set correctly");
+ }
+
+ let tunicwildsSession = context.locals.session.data.tunicwilds?.[tunicwildDateString];
+
+ if (tunicwildsSession == null) {
+ context.locals.session.data.tunicwilds ??= {};
+ tunicwildsSession = context.locals.session.data.tunicwilds[tunicwildDateString] = {
+ guesses: [],
+ result: null,
+ };
+ }
+
+ if (tunicwildsSession.result != null || tunicwildsSession.guesses.length >= 6) {
+ return jsonError("Tunicwild has already been completed");
+ }
+
+ if (params.guess == null) {
+ tunicwildsSession.guesses.push(null);
+ } else {
+ const tunicwild = await db
+ .select()
+ .from(Tunicwild)
+ .where(eq(Tunicwild.id, tunicwildId))
+ .get();
+
+ if (tunicwild == null) {
+ throw new Error("Missing daily tunicwild in database");
+ }
+
+ const guessedTunicwild = await db
+ .select()
+ .from(Tunicwild)
+ .where(eq(Tunicwild.id, params.guess))
+ .get();
+
+ if (guessedTunicwild == null) {
+ return jsonError("Invalid guess");
+ }
+
+ tunicwildsSession.guesses.push({
+ id: params.guess,
+ result: guessedTunicwild.id === tunicwild.id
+ ? "correct"
+ : guessedTunicwild.game === tunicwild.game
+ ? "correctGame"
+ : guessedTunicwild.title.toLowerCase() === tunicwild.title.toLowerCase()
+ ? "correctTitle"
+ : "incorrect",
+ });
+ }
+
+ if (params.guess === tunicwildId) {
+ tunicwildsSession.result = true;
+ } else if (tunicwildsSession.guesses.length >= 6) {
+ tunicwildsSession.result = false;
+ }
+
+ // TODO delete older games from session
+
+ const url = new URL(context.url);
+ url.searchParams.set("timestamp", params.timestamp.toString());
+
+ return context.redirect(url.toString(), 302);
+};
diff --git a/src/server/session.ts b/src/server/session.ts
index 32aada1..2768358 100644
--- a/src/server/session.ts
+++ b/src/server/session.ts
@@ -14,10 +14,6 @@ declare global {
}
}
-export interface SessionData {
- memberDiscord?: string;
-}
-
const cookieName = "latex_session";
const maxAgeMs = 1000 * 60 * 60 * 24 * 30;