From 6ac4a5c11a274bfa3dd5e92912d236fc26cb6e57 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Fri, 15 May 2026 21:40:20 +0700 Subject: [PATCH] feat: add installation script for yt-dlp and update package.json --- README.md | 17 ++++- package.json | 3 +- public/index.html | 6 +- scripts/install-yt-dlp.sh | 34 +++++++++ src/moderation/aiAnalysisWorker.ts | 94 ++++++++++++++++++++++++ src/moderation/aiAnalyzer.ts | 111 ++++++++++++++--------------- 6 files changed, 200 insertions(+), 65 deletions(-) create mode 100755 scripts/install-yt-dlp.sh create mode 100644 src/moderation/aiAnalysisWorker.ts diff --git a/README.md b/README.md index a842ce2..38c875f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ Stack utama: Node.js, pnpm, TypeScript, `discord.js-selfbot-v13`, `@discordjs/vo - Node.js versi modern yang kompatibel dengan TypeScript dan Vite. - pnpm 10.x. Repo ini dipin ke `pnpm@10.25.0`. -- FFmpeg tersedia di `PATH` untuk proses muxing audio. +- FFmpeg tersedia di `PATH` untuk proses muxing audio dan playback media. +- `yt-dlp` tersedia di `PATH` untuk resolve audio YouTube, search result YouTube, dan Spotify track. - Native audio dependencies dapat dibuild di mesin lokal (`@discordjs/opus`, `better-sqlite3`, `sodium-native`). Install FFmpeg: @@ -21,6 +22,14 @@ sudo apt install ffmpeg sudo pacman -S ffmpeg ``` +Install `yt-dlp`: + +```bash +pnpm run install:yt-dlp +``` + +Script installer akan memakai package manager yang tersedia (`pacman`, `apt-get`, `dnf`, `brew`) atau fallback ke `pipx`/`pip`. + ## Setup ```bash @@ -76,6 +85,9 @@ pnpm run test # Build frontend + TypeScript pnpm run build + +# Install external yt-dlp CLI for YouTube/search/Spotify track playback +pnpm run install:yt-dlp ``` ## Database @@ -104,7 +116,8 @@ pnpm run db:studio - Attachment capture dan upload ke endpoint Picser. - SQLite/PostgreSQL via Drizzle ORM. - REST API dan WebSocket untuk dashboard. -- Dashboard React untuk pesan, gambar, voice, dan moderation review. +- Dashboard React untuk pesan, gambar, voice, media playback, dan moderation review. +- Media playback dari direct URL, file lokal, YouTube URL, search terms, dan Spotify track URL. - Metrics Prometheus di endpoint server. - Retry dengan backoff untuk operasi eksternal. - AI moderation analysis opsional via konfigurasi `AI_*`. diff --git a/package.json b/package.json index 85a4994..74746d7 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:migrate:programmatic": "tsx src/database/migrate.ts", - "db:studio": "drizzle-kit studio" + "db:studio": "drizzle-kit studio", + "install:yt-dlp": "sh scripts/install-yt-dlp.sh" }, "dependencies": { "@dank074/discord-video-stream": "workspace:*", diff --git a/public/index.html b/public/index.html index bdf56a4..90fe25c 100644 --- a/public/index.html +++ b/public/index.html @@ -86,7 +86,7 @@ async function apiRequest(url, options = {}) { const response = await fetch(url, { headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options }); if (!response.ok) { const error = await response.json().catch(() => ({ message: response.statusText })); throw new Error(error.message || response.statusText); } return response.json(); } function showError(message) { el.errorBox.textContent = message; el.errorBox.style.display = 'block'; setTimeout(() => { el.errorBox.style.display = 'none'; }, 4500); } - function postUIState(patch) { return apiRequest('/api/ui-state', { method: 'POST', body: JSON.stringify(patch) }); } + async function postUIState(patch) { const next = await apiRequest('/api/ui-state', { method: 'POST', body: JSON.stringify(patch) }); await applyServerState(next); return next; } function renderOptions(select, items, placeholder) { select.replaceChildren(); const first = document.createElement('option'); first.value = ''; first.textContent = placeholder; select.appendChild(first); for (const item of items) { const option = document.createElement('option'); option.value = item.id; option.textContent = item.name; select.appendChild(option); } } function appendOptions(select, items) { const existing = new Set([...select.options].map((option) => option.value)); for (const item of items) { if (existing.has(item.id)) continue; const option = document.createElement('option'); option.value = item.id; option.textContent = item.name; select.appendChild(option); } } function appendEmpty(parent, message) { const empty = document.createElement('div'); empty.className = 'empty'; empty.textContent = message; parent.appendChild(empty); } @@ -140,8 +140,8 @@ } function applyActiveTab(tab) { document.querySelectorAll('.tab-btn').forEach((item) => item.classList.toggle('active', item.dataset.tab === tab)); document.querySelectorAll('.tab-content').forEach((item) => item.classList.toggle('active', item.id === tab)); el.activeTabLabel.textContent = tab === 'text' ? 'Text' : 'Voice'; } - async function reconcileListenState() { if (state.isListening && !state.localListening) { try { await startListeningLocal(); } catch (error) { showError(`Speaker error: ${error.message}`); await postUIState({ isListening: false }); } } else if (!state.isListening && state.localListening) { stopListeningLocal(); } } - async function reconcileStreamingState() { if (state.isStreaming && !state.localStreaming) { try { await startStreamingLocal(); } catch (error) { showError(`Microphone error: ${error.message}`); await postUIState({ isStreaming: false }); } } else if (!state.isStreaming && state.localStreaming) { stopStreamingLocal(); } } + async function reconcileListenState() { if (state.isListening && !state.localListening) { try { await startListeningLocal(); } catch (error) { showError(`Speaker error: ${error.message}`); state.isListening = false; stopListeningLocal(); apiRequest('/api/ui-state', { method: 'POST', body: JSON.stringify({ isListening: false }) }).catch((postError) => showError(postError.message)); } } else if (!state.isListening && state.localListening) { stopListeningLocal(); } } + async function reconcileStreamingState() { if (state.isStreaming && !state.localStreaming) { try { await startStreamingLocal(); } catch (error) { showError(`Microphone error: ${error.message}`); state.isStreaming = false; stopStreamingLocal(); apiRequest('/api/ui-state', { method: 'POST', body: JSON.stringify({ isStreaming: false }) }).catch((postError) => showError(postError.message)); } } else if (!state.isStreaming && state.localStreaming) { stopStreamingLocal(); } } function renderUsers(users) { el.userList.replaceChildren(); if (users.length === 0) return appendEmpty(el.userList, 'No active speakers'); for (const user of users) { const row = document.createElement('div'); row.className = `user-item${user.speaking ? ' speaking' : ''}`; const img = document.createElement('img'); img.src = user.avatar || ''; img.alt = ''; const name = document.createElement('span'); name.textContent = user.username; row.append(img, name); el.userList.appendChild(row); } } async function fetchText() { if (!state.selectedTextChannel) return renderText(); const result = await apiRequest(`/api/messages?channel=${encodeURIComponent(state.selectedTextChannel)}&type=text&limit=80`); state.text = result.data || []; renderText(); } diff --git a/scripts/install-yt-dlp.sh b/scripts/install-yt-dlp.sh new file mode 100755 index 0000000..940bdbe --- /dev/null +++ b/scripts/install-yt-dlp.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env sh +set -eu + +if command -v yt-dlp >/dev/null 2>&1; then + echo "yt-dlp already installed: $(command -v yt-dlp)" + yt-dlp --version + exit 0 +fi + +if command -v pacman >/dev/null 2>&1; then + sudo pacman -S --needed yt-dlp +elif command -v apt-get >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y yt-dlp +elif command -v dnf >/dev/null 2>&1; then + sudo dnf install -y yt-dlp +elif command -v brew >/dev/null 2>&1; then + brew install yt-dlp +elif command -v pipx >/dev/null 2>&1; then + pipx install yt-dlp +elif command -v python3 >/dev/null 2>&1; then + python3 -m pip install --user --upgrade yt-dlp +else + echo "Could not find pacman, apt-get, dnf, brew, pipx, or python3 to install yt-dlp." >&2 + exit 1 +fi + +if ! command -v yt-dlp >/dev/null 2>&1; then + echo "yt-dlp installed but is not on PATH. Restart your shell or add the installer bin directory to PATH." >&2 + exit 1 +fi + +echo "yt-dlp installed: $(command -v yt-dlp)" +yt-dlp --version diff --git a/src/moderation/aiAnalysisWorker.ts b/src/moderation/aiAnalysisWorker.ts new file mode 100644 index 0000000..cf9b246 --- /dev/null +++ b/src/moderation/aiAnalysisWorker.ts @@ -0,0 +1,94 @@ +import { parentPort } from "node:worker_threads"; +import { buildConversationPromptMessages } from "./conversationContext"; +import { runModerationAnalysis } from "./llmModerationClient"; +import { + getConversationContextBefore, + updateMessageAIAnalysis, +} from "./messageStore"; +import type { MessageRecord } from "./types"; + +const MAX_CONTEXT_TOKENS = 8000; + +interface AnalysisWorkerRequest { + conversationKey: string; + messages: MessageRecord[]; +} + +type AnalysisWorkerResponse = + | { + ok: true; + conversationKey: string; + rows: MessageRecord[]; + } + | { + ok: false; + conversationKey: string; + rows: MessageRecord[]; + error: string; + }; + +async function processAnalysisRequest({ + conversationKey, + messages, +}: AnalysisWorkerRequest): Promise { + try { + const firstMessage = messages[0]; + if (!firstMessage) return { ok: true, conversationKey, rows: [] }; + + const contextBefore = await getConversationContextBefore({ + channelId: firstMessage.channel_id, + threadId: firstMessage.thread_id, + beforeCreatedAt: firstMessage.created_at, + limit: 20, + }); + + const promptMessages = buildConversationPromptMessages({ + contextBefore, + targets: messages, + maxTokens: MAX_CONTEXT_TOKENS, + }); + + const result = await runModerationAnalysis({ + targets: messages, + contextText: promptMessages.join("\n"), + }); + + const rows: MessageRecord[] = []; + for (const analysisResult of result.results) { + const row = await updateMessageAIAnalysis(analysisResult.messageId, { + status: analysisResult.status, + flags: JSON.stringify(analysisResult.flags), + score: analysisResult.score, + raw: JSON.stringify(result.raw), + analysis: analysisResult.analysis, + analyzedAt: Date.now(), + error: null, + }); + if (row) rows.push(row); + } + + return { ok: true, conversationKey, rows }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const rows: MessageRecord[] = []; + + for (const msg of messages) { + const row = await updateMessageAIAnalysis(msg.id, { + status: "error", + flags: null, + score: null, + raw: null, + analysis: null, + analyzedAt: Date.now(), + error: errorMessage, + }); + if (row) rows.push(row); + } + + return { ok: false, conversationKey, rows, error: errorMessage }; + } +} + +parentPort?.on("message", async (request: AnalysisWorkerRequest) => { + parentPort?.postMessage(await processAnalysisRequest(request)); +}); diff --git a/src/moderation/aiAnalyzer.ts b/src/moderation/aiAnalyzer.ts index bf0cf8f..950ad85 100644 --- a/src/moderation/aiAnalyzer.ts +++ b/src/moderation/aiAnalyzer.ts @@ -1,9 +1,7 @@ +import { Worker } from "node:worker_threads"; import { config } from "../config"; import { createChildLogger } from "../logger"; -import { buildConversationPromptMessages } from "./conversationContext"; -import { runModerationAnalysis } from "./llmModerationClient"; import { - getConversationContextBefore, getMessageById, getPendingConversationKeys, getPendingMessagesByConversation, @@ -38,9 +36,15 @@ const MAX_ACTIVE_REQUESTS = 1; const DEBOUNCE_MS = 1500; const RECOVERY_INTERVAL_MS = 15000; const ERROR_COOLDOWN_MS = 30000; -const MAX_CONTEXT_TOKENS = 8000; const MAX_BATCH_SIZE = 25; +interface AnalysisWorkerResponse { + ok: boolean; + conversationKey: string; + rows: MessageRecord[]; + error?: string; +} + /** * Gets the conversation key for a message (thread_id or channel_id) */ @@ -86,53 +90,25 @@ async function processBatch( activeRequests++; conversationProcessing.add(conversationKey); try { - // Get context before the first message - const firstMessage = messages[0]; - const contextBefore = await getConversationContextBefore({ - channelId: firstMessage.channel_id, - threadId: firstMessage.thread_id, - beforeCreatedAt: firstMessage.created_at, - limit: 20, - }); + const result = await runAnalysisInWorker(conversationKey, messages); - // Build prompt with context - const promptMessages = buildConversationPromptMessages({ - contextBefore, - targets: messages, - maxTokens: MAX_CONTEXT_TOKENS, - }); - - const contextText = promptMessages.join("\n"); - - // Run moderation analysis - const result = await runModerationAnalysis({ - targets: messages, - contextText, - }); - - // Store results - const analyzedRows: MessageRecord[] = []; - for (const analysisResult of result.results) { - const row = await updateMessageAIAnalysis(analysisResult.messageId, { - status: analysisResult.status, - flags: JSON.stringify(analysisResult.flags), - score: analysisResult.score, - raw: JSON.stringify(result.raw), - analysis: analysisResult.analysis, - analyzedAt: Date.now(), - error: null, - }); - if (row) { - analyzedRows.push(row); - } - } - - // Broadcast analyzed messages - for (const row of analyzedRows) { + for (const row of result.rows) { getModerationBroadcaster()?.messageAnalyzed(row); } - // Clear error cooldown on success + if (!result.ok) { + lastError = result.error ?? "Analysis worker failed"; + conversationErrorCooldown.set( + conversationKey, + Date.now() + ERROR_COOLDOWN_MS, + ); + logger.error( + { conversationKey, error: lastError }, + "Batch analysis failed", + ); + return; + } + conversationErrorCooldown.delete(conversationKey); logger.info( @@ -141,13 +117,15 @@ async function processBatch( ); } catch (error) { lastError = error instanceof Error ? error.message : String(error); - + conversationErrorCooldown.set( + conversationKey, + Date.now() + ERROR_COOLDOWN_MS, + ); logger.error( { conversationKey, error: lastError }, - "Batch analysis failed", + "Analysis worker failed", ); - // Mark all messages in batch as error for (const msg of messages) { const row = await updateMessageAIAnalysis(msg.id, { status: "error", @@ -158,22 +136,37 @@ async function processBatch( analyzedAt: Date.now(), error: lastError, }); - if (row) { - getModerationBroadcaster()?.messageAnalyzed(row); - } + if (row) getModerationBroadcaster()?.messageAnalyzed(row); } - - // Set error cooldown for this conversation - conversationErrorCooldown.set( - conversationKey, - Date.now() + ERROR_COOLDOWN_MS, - ); } finally { activeRequests--; conversationProcessing.delete(conversationKey); } } +async function runAnalysisInWorker( + conversationKey: string, + messages: MessageRecord[], +): Promise { + return new Promise((resolve, reject) => { + const worker = new Worker(new URL("./aiAnalysisWorker.ts", import.meta.url)); + + worker.once("message", (response: AnalysisWorkerResponse) => { + worker.terminate().catch((error) => { + logger.warn({ error }, "Failed to terminate analysis worker"); + }); + resolve(response); + }); + worker.once("error", reject); + worker.once("exit", (code) => { + if (code !== 0) { + reject(new Error(`Analysis worker exited with code ${code}`)); + } + }); + worker.postMessage({ conversationKey, messages }); + }); +} + /** * Debounced analysis trigger for a conversation */