314 lines
9.1 KiB
JavaScript
314 lines
9.1 KiB
JavaScript
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import pg from "pg";
|
|
import config from "../built/config/index.js";
|
|
|
|
const { Client } = pg;
|
|
const marker = "karaoke-service-rights-safe-test-data";
|
|
const createdAt = new Date();
|
|
|
|
function genId(date = new Date()) {
|
|
const time2000 = 946684800000;
|
|
const timestamp = Math.max(date.getTime() - time2000, 0).toString(36).padStart(8, "0");
|
|
const random = crypto.randomBytes(8).toString("base64url").replace(/[^a-z0-9]/gi, "").toLowerCase().slice(0, 8).padEnd(8, "0");
|
|
return `${timestamp}${random}`;
|
|
}
|
|
|
|
function md5(buffer) {
|
|
return crypto.createHash("md5").update(buffer).digest("hex");
|
|
}
|
|
|
|
function saveInternalFile(name, buffer) {
|
|
const accessKey = crypto.randomUUID();
|
|
fs.mkdirSync(config.mediaDir, { recursive: true });
|
|
fs.writeFileSync(path.join(config.mediaDir, accessKey), buffer);
|
|
return {
|
|
accessKey,
|
|
url: `${config.url}/files/${accessKey}`,
|
|
name,
|
|
size: buffer.byteLength,
|
|
md5: md5(buffer),
|
|
};
|
|
}
|
|
|
|
function createWav() {
|
|
const sampleRate = 44100;
|
|
const seconds = 16;
|
|
const samples = sampleRate * seconds;
|
|
const data = Buffer.alloc(samples * 2);
|
|
const notes = [261.63, 293.66, 329.63, 392.0, 349.23, 329.63, 293.66, 261.63];
|
|
|
|
for (let i = 0; i < samples; i++) {
|
|
const t = i / sampleRate;
|
|
const beat = Math.floor(t / 2) % notes.length;
|
|
const root = notes[beat] / 2;
|
|
const value =
|
|
Math.sin(2 * Math.PI * root * t) * 0.22 +
|
|
Math.sin(2 * Math.PI * root * 1.5 * t) * 0.12 +
|
|
Math.sin(2 * Math.PI * root * 2 * t) * 0.08;
|
|
data.writeInt16LE(Math.round(Math.max(-1, Math.min(1, value)) * 0x7fff), i * 2);
|
|
}
|
|
|
|
const header = Buffer.alloc(44);
|
|
header.write("RIFF", 0);
|
|
header.writeUInt32LE(36 + data.length, 4);
|
|
header.write("WAVE", 8);
|
|
header.write("fmt ", 12);
|
|
header.writeUInt32LE(16, 16);
|
|
header.writeUInt16LE(1, 20);
|
|
header.writeUInt16LE(1, 22);
|
|
header.writeUInt32LE(sampleRate, 24);
|
|
header.writeUInt32LE(sampleRate * 2, 28);
|
|
header.writeUInt16LE(2, 32);
|
|
header.writeUInt16LE(16, 34);
|
|
header.write("data", 36);
|
|
header.writeUInt32LE(data.length, 40);
|
|
return Buffer.concat([header, data]);
|
|
}
|
|
|
|
function varLen(value) {
|
|
let buffer = value & 0x7f;
|
|
while ((value >>= 7) > 0) {
|
|
buffer <<= 8;
|
|
buffer |= (value & 0x7f) | 0x80;
|
|
}
|
|
const bytes = [];
|
|
for (;;) {
|
|
bytes.push(buffer & 0xff);
|
|
if (buffer & 0x80) buffer >>= 8;
|
|
else break;
|
|
}
|
|
return Buffer.from(bytes);
|
|
}
|
|
|
|
function createMidi() {
|
|
const ticksPerQuarter = 480;
|
|
const events = [];
|
|
const push = (...bytes) => events.push(Buffer.from(bytes));
|
|
const pushDelta = (delta) => events.push(varLen(delta));
|
|
|
|
pushDelta(0);
|
|
push(0xff, 0x51, 0x03, 0x07, 0xa1, 0x20); // 120 BPM
|
|
pushDelta(0);
|
|
push(0xc0, 0x00);
|
|
|
|
const melody = [60, 62, 64, 67, 65, 64, 62, 60];
|
|
for (const note of melody) {
|
|
pushDelta(0);
|
|
push(0x90, note, 0x64);
|
|
pushDelta(ticksPerQuarter * 2);
|
|
push(0x80, note, 0x40);
|
|
}
|
|
|
|
pushDelta(0);
|
|
push(0xff, 0x2f, 0x00);
|
|
|
|
const track = Buffer.concat(events);
|
|
const header = Buffer.alloc(14);
|
|
header.write("MThd", 0);
|
|
header.writeUInt32BE(6, 4);
|
|
header.writeUInt16BE(0, 8);
|
|
header.writeUInt16BE(1, 10);
|
|
header.writeUInt16BE(ticksPerQuarter, 12);
|
|
|
|
const trackHeader = Buffer.alloc(8);
|
|
trackHeader.write("MTrk", 0);
|
|
trackHeader.writeUInt32BE(track.length, 4);
|
|
return Buffer.concat([header, trackHeader, track]);
|
|
}
|
|
|
|
function textBuffer(text) {
|
|
return Buffer.from(text.replace(/\n/g, "\r\n"), "utf8");
|
|
}
|
|
|
|
async function main() {
|
|
const client = new Client({
|
|
host: config.db.host,
|
|
port: config.db.port,
|
|
user: config.db.user,
|
|
password: config.db.pass,
|
|
database: config.db.db,
|
|
...config.db.extra,
|
|
});
|
|
|
|
await client.connect();
|
|
try {
|
|
const admin = await client.query(
|
|
`SELECT "id", "username", "host" FROM "user" WHERE "usernameLower" = 'admin' AND "host" IS NULL LIMIT 1`,
|
|
);
|
|
if (admin.rowCount === 0) {
|
|
throw new Error("Local @admin user was not found. Create @admin first, then rerun this script.");
|
|
}
|
|
const adminUser = admin.rows[0];
|
|
|
|
const existing = await client.query(
|
|
`SELECT "id", "fileIds" FROM "note" WHERE "userId" = $1 AND "text" ILIKE $2 LIMIT 1`,
|
|
[adminUser.id, `%${marker}%`],
|
|
);
|
|
|
|
const assets = [
|
|
{
|
|
name: "karaoke-test-accompaniment.wav",
|
|
type: "audio/wav",
|
|
comment: "KaraokeService test accompaniment. Original generated tone progression.",
|
|
buffer: createWav(),
|
|
},
|
|
{
|
|
name: "karaoke-test-lyrics.lrc",
|
|
type: "text/plain",
|
|
comment: "KaraokeService timed lyrics.",
|
|
buffer: textBuffer(`[00:00.000]Original karaoke test
|
|
[00:02.000]Sing the first clear tone
|
|
[00:04.000]Move to the next note
|
|
[00:06.000]Hold the bright third
|
|
[00:08.000]Reach the open fifth
|
|
[00:10.000]Return with steady timing
|
|
[00:12.000]Finish on the home note
|
|
[00:14.000]This data is rights-safe`),
|
|
},
|
|
{
|
|
name: "karaoke-test-pitch.mid",
|
|
type: "audio/midi",
|
|
comment: "KaraokeService MIDI pitch and tempo data.",
|
|
buffer: createMidi(),
|
|
},
|
|
{
|
|
name: "karaoke-test-score.json",
|
|
type: "application/json",
|
|
comment: "KaraokeService scoring metadata.",
|
|
buffer: textBuffer(JSON.stringify({
|
|
version: 1,
|
|
scoring: "midi-pitch",
|
|
pitchFile: "karaoke-test-pitch.mid",
|
|
toleranceCents: 50,
|
|
partialToleranceCents: 100,
|
|
extraSingingPenalty: true,
|
|
}, null, 2)),
|
|
},
|
|
{
|
|
name: "karaoke-test-song-info.txt",
|
|
type: "text/plain",
|
|
comment: "KaraokeService song metadata.",
|
|
buffer: textBuffer(`Title: Original Karaoke Test
|
|
Artist: FrozenFriendsYume test data
|
|
License: Public-domain equivalent test fixture generated for this repository
|
|
BPM: 120
|
|
Key: C major
|
|
Notes: All melody, lyrics, accompaniment, and metadata were generated locally for testing.`),
|
|
},
|
|
];
|
|
|
|
const files = assets.map((asset) => ({
|
|
id: genId(createdAt),
|
|
createdAt,
|
|
userId: adminUser.id,
|
|
userHost: null,
|
|
...saveInternalFile(asset.name, asset.buffer),
|
|
type: asset.type,
|
|
comment: asset.comment,
|
|
}));
|
|
|
|
await client.query("BEGIN");
|
|
for (const file of files) {
|
|
await client.query(
|
|
`INSERT INTO "drive_file" (
|
|
"id", "createdAt", "userId", "userHost", "md5", "name", "type", "size", "comment",
|
|
"blurhash", "properties", "storedInternal", "url", "thumbnailUrl", "webpublicUrl",
|
|
"webpublicType", "accessKey", "thumbnailAccessKey", "webpublicAccessKey", "uri", "src",
|
|
"folderId", "isSensitive", "allowDownload", "isLink", "requestHeaders", "requestIp"
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
|
NULL, '{}', TRUE, $10, NULL, NULL,
|
|
NULL, $11, NULL, NULL, NULL, NULL,
|
|
NULL, FALSE, FALSE, FALSE, NULL, NULL
|
|
)`,
|
|
[
|
|
file.id,
|
|
file.createdAt,
|
|
file.userId,
|
|
file.userHost,
|
|
file.md5,
|
|
file.name,
|
|
file.type,
|
|
file.size,
|
|
file.comment,
|
|
file.url,
|
|
file.accessKey,
|
|
],
|
|
);
|
|
}
|
|
|
|
const noteText = `Original Karaoke Test
|
|
Rights-safe generated karaoke test data.
|
|
Marker: ${marker}
|
|
#KaraokeService`;
|
|
|
|
if (existing.rowCount > 0) {
|
|
const noteId = existing.rows[0].id;
|
|
const oldFileIds = existing.rows[0].fileIds ?? [];
|
|
const oldFiles = oldFileIds.length > 0
|
|
? await client.query(`SELECT "id", "accessKey" FROM "drive_file" WHERE "id" = ANY($1)`, [oldFileIds])
|
|
: { rows: [] };
|
|
|
|
await client.query(
|
|
`UPDATE "note" SET "text" = $2, "fileIds" = $3, "attachedFileTypes" = $4, "updatedAt" = $5 WHERE "id" = $1`,
|
|
[noteId, noteText, files.map((file) => file.id), files.map((file) => file.type), new Date()],
|
|
);
|
|
if (oldFileIds.length > 0) {
|
|
await client.query(`DELETE FROM "drive_file" WHERE "id" = ANY($1)`, [oldFileIds]);
|
|
for (const file of oldFiles.rows) {
|
|
if (!file.accessKey) continue;
|
|
fs.rmSync(path.join(config.mediaDir, file.accessKey), { force: true });
|
|
}
|
|
}
|
|
await client.query("COMMIT");
|
|
console.log(`Recreated KaraokeService test media for existing @admin note: ${noteId}`);
|
|
return;
|
|
}
|
|
|
|
const noteId = genId(createdAt);
|
|
await client.query(
|
|
`INSERT INTO "note" (
|
|
"id", "createdAt", "replyId", "renoteId", "threadId", "text", "name", "cw",
|
|
"userId", "groupId", "localOnly", "renoteCount", "repliesCount", "viewCount",
|
|
"reactions", "visibility", "uri", "url", "score", "fileIds", "attachedFileTypes",
|
|
"visibleUserIds", "mentions", "mentionedRemoteUsers", "emojis", "tags", "hasPoll",
|
|
"channelId", "quoteAuthorization", "canQuote", "userHost", "replyUserId",
|
|
"replyUserHost", "renoteUserId", "renoteUserHost", "updatedAt"
|
|
) VALUES (
|
|
$1, $2, NULL, NULL, NULL, $3, NULL, NULL,
|
|
$4, NULL, FALSE, 0, 0, 0,
|
|
'{}', 'public', NULL, NULL, 0, $5, $6,
|
|
'{}', '{}', '[]', '{}', $7, FALSE,
|
|
NULL, NULL, TRUE, NULL, NULL,
|
|
NULL, NULL, NULL, NULL
|
|
)`,
|
|
[
|
|
noteId,
|
|
createdAt,
|
|
noteText,
|
|
adminUser.id,
|
|
files.map((file) => file.id),
|
|
files.map((file) => file.type),
|
|
["karaokeservice"],
|
|
],
|
|
);
|
|
|
|
await client.query(`UPDATE "user" SET "notesCount" = "notesCount" + 1 WHERE "id" = $1`, [adminUser.id]);
|
|
await client.query("COMMIT");
|
|
console.log(`Created KaraokeService test note as @admin: ${noteId}`);
|
|
} catch (err) {
|
|
await client.query("ROLLBACK").catch(() => undefined);
|
|
throw err;
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|