diff --git a/.gitignore b/.gitignore index ce02e75..107b770 100644 --- a/.gitignore +++ b/.gitignore @@ -132,4 +132,5 @@ dist config.toml recordings -.idea \ No newline at end of file +.idea +prisma/data.db* \ No newline at end of file diff --git a/package.json b/package.json index 391e8cf..f49bb15 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "", "main": "dist/index.js", "scripts": { - "dev": "tsx src/index.ts" + "dev": "tsx src/index.ts", + "merge": "tsx src/merge.ts" }, "keywords": [], "author": "", @@ -23,10 +24,12 @@ "@discordjs/rest": "^1.7.1", "@discordjs/voice": "^0.16.0", "@discordjs/ws": "^0.8.3", + "@prisma/client": "4.16.1", "discord-api-types": "^0.37.46", "libsodium-wrappers": "^0.7.11", "node-crc": "1.3.2", "prism-media": "2.0.0-alpha.0", + "prisma": "^4.16.1", "toml": "^3.0.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a07b99..a97373e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ dependencies: '@discordjs/ws': specifier: ^0.8.3 version: 0.8.3 + '@prisma/client': + specifier: 4.16.1 + version: 4.16.1(prisma@4.16.1) discord-api-types: specifier: ^0.37.46 version: 0.37.46 @@ -35,6 +38,9 @@ dependencies: prism-media: specifier: 2.0.0-alpha.0 version: 2.0.0-alpha.0 + prisma: + specifier: ^4.16.1 + version: 4.16.1 toml: specifier: ^3.0.0 version: 3.0.0 @@ -379,6 +385,29 @@ packages: dev: true optional: true + /@prisma/client@4.16.1(prisma@4.16.1): + resolution: {integrity: sha512-CoDHu7Bt+NuDo40ijoeHP79EHtECsPBTy3yte5Yo3op8TqXt/kV0OT5OrsWewKvQGKFMHhYQ+ePed3zzjYdGAw==} + engines: {node: '>=14.17'} + requiresBuild: true + peerDependencies: + prisma: '*' + peerDependenciesMeta: + prisma: + optional: true + dependencies: + '@prisma/engines-version': 4.16.0-66.b20ead4d3ab9e78ac112966e242ded703f4a052c + prisma: 4.16.1 + dev: false + + /@prisma/engines-version@4.16.0-66.b20ead4d3ab9e78ac112966e242ded703f4a052c: + resolution: {integrity: sha512-tMWAF/qF00fbUH1HB4Yjmz6bjh7fzkb7Y3NRoUfMlHu6V+O45MGvqwYxqwBjn1BIUXkl3r04W351D4qdJjrgvA==} + dev: false + + /@prisma/engines@4.16.1: + resolution: {integrity: sha512-gpZG0kGGxfemgvK/LghHdBIz+crHkZjzszja94xp4oytpsXrgt/Ice82MvPsWMleVIniKuARrowtsIsim0PFJQ==} + requiresBuild: true + dev: false + /@sapphire/async-queue@1.5.0: resolution: {integrity: sha512-JkLdIsP8fPAdh9ZZjrbHWR/+mZj0wvKS5ICibcLrRI1j84UmLMshx5n9QmL8b95d4onJ2xxiyugTgSAX7AalmA==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} @@ -803,6 +832,15 @@ packages: duplex-child-process: 1.0.1 dev: false + /prisma@4.16.1: + resolution: {integrity: sha512-C2Xm7yxHxjFjjscBEW4tmoraPHH/Vyu/A0XABdbaFtoiOZARsxvOM7rwc2iZ0qVxbh0bGBGBWZUSXO/52/nHBQ==} + engines: {node: '>=14.17'} + hasBin: true + requiresBuild: true + dependencies: + '@prisma/engines': 4.16.1 + dev: false + /readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} diff --git a/prisma/migrations/20230628145216_init/migration.sql b/prisma/migrations/20230628145216_init/migration.sql new file mode 100644 index 0000000..9fdd9c9 --- /dev/null +++ b/prisma/migrations/20230628145216_init/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "channel" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "File" ( + "id" TEXT NOT NULL PRIMARY KEY, + "filename" TEXT NOT NULL, + "timestamp" BIGINT NOT NULL, + "sessionId" TEXT, + CONSTRAINT "File_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); diff --git a/prisma/migrations/20230628154831_wow/migration.sql b/prisma/migrations/20230628154831_wow/migration.sql new file mode 100644 index 0000000..d3712a6 --- /dev/null +++ b/prisma/migrations/20230628154831_wow/migration.sql @@ -0,0 +1,20 @@ +/* + Warnings: + + - Made the column `sessionId` on table `File` required. This step will fail if there are existing NULL values in that column. + +*/ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_File" ( + "id" TEXT NOT NULL PRIMARY KEY, + "filename" TEXT NOT NULL, + "timestamp" BIGINT NOT NULL, + "sessionId" TEXT NOT NULL, + CONSTRAINT "File_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_File" ("filename", "id", "sessionId", "timestamp") SELECT "filename", "id", "sessionId", "timestamp" FROM "File"; +DROP TABLE "File"; +ALTER TABLE "new_File" RENAME TO "File"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..e5e5c47 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "sqlite" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..4347ca2 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,27 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = "file:data.db" +} + +model Session { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + channel String + files File[] +} + +model File { + id String @id @default(uuid()) + filename String + timestamp BigInt + + session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) + sessionId String +} diff --git a/src/api.ts b/src/api.ts index 54f90ba..75e845f 100644 --- a/src/api.ts +++ b/src/api.ts @@ -4,6 +4,7 @@ import { Client, GatewayIntentBits } from "@discordjs/core" import { config } from "./config.js" import { VoiceStateManager } from "./utils/wrapper/VoiceState.js" import { RecordingSessionManager } from "./utils/sessions.js" +import { PrismaClient } from "@prisma/client" export const rest = new REST({ version: "10" }).setToken(config.token) @@ -20,3 +21,5 @@ export const client = new Client({ rest, gateway }) export const voiceStateManager = new VoiceStateManager() export const sessionManager = new RecordingSessionManager() + +export const db = new PrismaClient() diff --git a/src/commands/start.ts b/src/commands/start.ts index a9254ac..9607329 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -34,7 +34,7 @@ export const startCommand = async ({ content: "먼저 음성 채널에 들어가주세요!", }) - sessionManager.addSession(data.guild_id, vc, shardId) + await sessionManager.addSession(data.guild_id, vc, data.channel_id, shardId) await api.interactions.reply(data.id, data.token, { content: "냥냥", diff --git a/src/index.ts b/src/index.ts index bd9d35c..5ca47f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { startCommand } from "./commands/start.js" import { config } from "./config.js" import { syncCommands } from "./utils/commands.js" import { VoiceState } from "./utils/wrapper/VoiceState.js" +import { isMe } from "./utils/appid.js" process.on("uncaughtException", (error) => console.error(error)) process.on("unhandledRejection", (error) => console.error(error)) @@ -82,20 +83,41 @@ client.on(GatewayDispatchEvents.InteractionCreate, async (args) => { } }) -client.on(GatewayDispatchEvents.VoiceStateUpdate, async (args) => { - if (args.data.guild_id) { - const adapter = sessionManager.adapters.get(args.data.guild_id) - if (adapter) adapter.onVoiceStateUpdate(args.data) - const guild = voiceStateManager.guilds.get(args.data.guild_id) +client.on(GatewayDispatchEvents.VoiceStateUpdate, async ({ data, api }) => { + if (data.guild_id) { + const adapter = sessionManager.adapters.get(data.guild_id) + if (adapter) adapter.onVoiceStateUpdate(data) + const guild = voiceStateManager.guilds.get(data.guild_id) if (guild) { - const state = guild.get(args.data.user_id) + const state = guild.get(data.user_id) if (state) { - state.update(args.data) + state.update(data) } else { - guild.set( - args.data.user_id, - new VoiceState(args.data, args.data.guild_id) - ) + guild.set(data.user_id, new VoiceState(data, data.guild_id)) + } + + const session = sessionManager.sessionMap.get(data.guild_id) + + if (session) { + let counter = 0 + let hasMe = false + + for (const state of guild.values()) { + if (state.channelId !== session.voiceChannelId) continue + if (isMe(state.id)) { + hasMe = true + continue + } + counter++ + } + + if (counter === 0 || !hasMe) { + session.vc.destroy() + sessionManager.removeSession(data.guild_id) + await api.channels.createMessage(session.textChannelId, { + content: `${session.id}`, + }) + } } } } diff --git a/src/merge.ts b/src/merge.ts new file mode 100644 index 0000000..163d787 --- /dev/null +++ b/src/merge.ts @@ -0,0 +1,12 @@ +import { db } from "./api.js" +import { mergeAudio } from "./utils/merge.js" + +const id = process.argv[2] + +const session = await db.session.findUnique({ where: { id } }) + +if (!session) { + throw new Error("session not found") +} + +await mergeAudio(id) diff --git a/src/utils/appid.ts b/src/utils/appid.ts index 635ee54..8b17e9d 100644 --- a/src/utils/appid.ts +++ b/src/utils/appid.ts @@ -1,3 +1,8 @@ +import { config } from "../config.js" + export const getAppIdFromToken = (token: string) => { return atob(token.split(".")[0]) } + +export const isMe = (userId: string) => + userId === getAppIdFromToken(config.token) diff --git a/src/utils/merge.ts b/src/utils/merge.ts new file mode 100644 index 0000000..5a5063e --- /dev/null +++ b/src/utils/merge.ts @@ -0,0 +1,65 @@ +import path from "path" +import { db } from "../api.js" +import { spawn } from "child_process" +import { existsSync, mkdirSync } from "fs" + +export const mergeAudio = async (id: string) => { + const finalDir = "recordings/final" + const sourceDir = path.resolve("recordings", id) + + if (!existsSync(finalDir)) { + mkdirSync(finalDir, { recursive: true }) + } + + const files = await db.file.findMany({ + where: { + session: { + id, + }, + }, + }) + + let inputs: string[] = [] + + let filter: string[] = [] + let trackNames: string[] = [] + + let counter = 0 + + for (const file of files) { + const filePath = path.join(sourceDir, file.filename) + const index = counter++ + const trackName = `[c${index}]` + + inputs.push("-i", filePath) + + filter.push( + `[${index}:a] adelay=${file.timestamp}|${file.timestamp} ${trackName}` + ) + + trackNames.push(trackName) + } + + filter.push( + `${trackNames.join(" ")} amix=inputs=${ + trackNames.length + }:normalize=0 [out]` + ) + + const args = [ + ...inputs, + "-filter_complex", + filter.join(";"), + "-map", + "[out]", + "-async", + "1", + "-y", + path.join(finalDir, `${id}.ogg`), + ] + + const p = spawn("ffmpeg", args).on("exit", (code) => { + console.log("ffmpeg exited with code", code) + }) + p.stderr.pipe(process.stderr) +} diff --git a/src/utils/sessions.ts b/src/utils/sessions.ts index 7215c13..2639ac7 100644 --- a/src/utils/sessions.ts +++ b/src/utils/sessions.ts @@ -1,5 +1,5 @@ import { Collection } from "@discordjs/collection" -import { gateway, voiceStateManager } from "../api.js" +import { db, gateway, voiceStateManager } from "../api.js" import { AudioReceiveStream, DiscordGatewayAdapterLibraryMethods, @@ -11,9 +11,9 @@ import { Snowflake } from "discord-api-types/v10" import * as prism from "prism-media" import { pipeline } from "stream" import { createWriteStream, existsSync, mkdirSync } from "fs" -import { getAppIdFromToken } from "./appid.js" -import { config } from "../config.js" import path from "path" +import { isMe } from "./appid.js" +import { exec, spawn } from "child_process" export class RecordingSessionManager { sessionMap = new Collection() @@ -23,11 +23,16 @@ export class RecordingSessionManager { return this.sessionMap.has(guild) } - addSession(guild: Snowflake, channel: Snowflake, shardId: number) { + async addSession( + guild: Snowflake, + voiceChannel: Snowflake, + textChannel: Snowflake, + shardId: number + ) { const session = new RecordingSession( joinVoiceChannel({ guildId: guild, - channelId: channel, + channelId: voiceChannel, adapterCreator: (methods) => { this.adapters.set(guild, methods) return { @@ -43,22 +48,48 @@ export class RecordingSessionManager { selfDeaf: false, selfMute: true, debug: true, - }) + }), + voiceChannel, + textChannel ) + await session.init() + this.sessionMap.set(guild, session) } + + removeSession(guild: Snowflake) { + const session = this.sessionMap.get(guild) + if (session) session.shutdown() + this.sessionMap.delete(guild) + } } export class RecordingSession { - id: string streams = new Collection() startedAt = Date.now() + id!: string + + constructor( + public vc: VoiceConnection, + public voiceChannelId: Snowflake, + public textChannelId: Snowflake + ) {} + + async init() { + const result = await db.session.create({ + data: { + id: this.id, + channel: this.voiceChannelId, + }, + }) + this.id = result.id - constructor(public vc: VoiceConnection) { this.id = Date.now().toString() - vc.receiver.speaking.on("start", (userId) => this.addReceiver(userId)) - vc.receiver.speaking.on("end", (userId) => this.removeReceiver(userId)) + this.receiver.speaking.on("start", (userId) => this.addReceiver(userId)) + this.receiver.speaking.on("end", (userId) => + this.removeReceiver(userId) + ) } get receiver() { @@ -66,11 +97,9 @@ export class RecordingSession { } addReceiver(userId: string) { - if (userId === getAppIdFromToken(config.token)) return + if (isMe(userId)) return if (this.streams.has(userId)) return - console.log("Starting record:", userId) - const receiver = this.receiver.subscribe(userId) this.streams.set(userId, new UserStream(this, receiver, userId)) @@ -80,11 +109,12 @@ export class RecordingSession { const stream = this.streams.get(userId) if (stream) { - console.log("Stopping record:", userId) stream.subscription.push(null) this.streams.delete(userId) } } + + async shutdown() {} } class UserStream { @@ -105,7 +135,9 @@ class UserStream { }, }) - const dir = path.resolve("recordings", session.id, userId) + const dir = path.resolve("recordings", session.id) + const timestamp = Date.now() - session.startedAt + const file = `${userId}-${timestamp}.ogg` if (!existsSync(dir)) { mkdirSync(dir, { @@ -113,20 +145,36 @@ class UserStream { }) } - const filename = path.join(dir, `${Date.now() - session.startedAt}.ogg`) + const filename = path.join(dir, file) + + console.log(`Recording ${filename} started`) const outputStream = createWriteStream(filename) - pipeline(subscription, this.oggStream, outputStream, (err) => { + pipeline(subscription, this.oggStream, outputStream, async (err) => { outputStream.end() if (err) { - console.log(`Recording ${userId} ended with error: ${err}`) + console.log(`Recording ${filename} ended with error: ${err}`) subscription.destroy(err) return } - console.log(`Recording ${userId} ended`) + await db.file + .create({ + data: { + session: { + connect: { + id: session.id, + }, + }, + filename: file, + timestamp, + }, + }) + .catch(console.error) + + console.log(`Recording ${filename} ended`) }) } }