feat: add installation script for yt-dlp and update package.json

This commit is contained in:
MythEclipse
2026-05-15 21:40:20 +07:00
parent 119258c2b0
commit 6ac4a5c11a
6 changed files with 200 additions and 65 deletions
+15 -2
View File
@@ -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. - Node.js versi modern yang kompatibel dengan TypeScript dan Vite.
- pnpm 10.x. Repo ini dipin ke `pnpm@10.25.0`. - 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`). - Native audio dependencies dapat dibuild di mesin lokal (`@discordjs/opus`, `better-sqlite3`, `sodium-native`).
Install FFmpeg: Install FFmpeg:
@@ -21,6 +22,14 @@ sudo apt install ffmpeg
sudo pacman -S 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 ## Setup
```bash ```bash
@@ -76,6 +85,9 @@ pnpm run test
# Build frontend + TypeScript # Build frontend + TypeScript
pnpm run build pnpm run build
# Install external yt-dlp CLI for YouTube/search/Spotify track playback
pnpm run install:yt-dlp
``` ```
## Database ## Database
@@ -104,7 +116,8 @@ pnpm run db:studio
- Attachment capture dan upload ke endpoint Picser. - Attachment capture dan upload ke endpoint Picser.
- SQLite/PostgreSQL via Drizzle ORM. - SQLite/PostgreSQL via Drizzle ORM.
- REST API dan WebSocket untuk dashboard. - 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. - Metrics Prometheus di endpoint server.
- Retry dengan backoff untuk operasi eksternal. - Retry dengan backoff untuk operasi eksternal.
- AI moderation analysis opsional via konfigurasi `AI_*`. - AI moderation analysis opsional via konfigurasi `AI_*`.
+2 -1
View File
@@ -18,7 +18,8 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:migrate:programmatic": "tsx src/database/migrate.ts", "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": { "dependencies": {
"@dank074/discord-video-stream": "workspace:*", "@dank074/discord-video-stream": "workspace:*",
+3 -3
View File
@@ -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(); } 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 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 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 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); } 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'; } 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 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}`); await postUIState({ isStreaming: false }); } } else if (!state.isStreaming && state.localStreaming) { stopStreamingLocal(); } } 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); } } 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(); } 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(); }
+34
View File
@@ -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
+94
View File
@@ -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<AnalysisWorkerResponse> {
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));
});
+52 -59
View File
@@ -1,9 +1,7 @@
import { Worker } from "node:worker_threads";
import { config } from "../config"; import { config } from "../config";
import { createChildLogger } from "../logger"; import { createChildLogger } from "../logger";
import { buildConversationPromptMessages } from "./conversationContext";
import { runModerationAnalysis } from "./llmModerationClient";
import { import {
getConversationContextBefore,
getMessageById, getMessageById,
getPendingConversationKeys, getPendingConversationKeys,
getPendingMessagesByConversation, getPendingMessagesByConversation,
@@ -38,9 +36,15 @@ const MAX_ACTIVE_REQUESTS = 1;
const DEBOUNCE_MS = 1500; const DEBOUNCE_MS = 1500;
const RECOVERY_INTERVAL_MS = 15000; const RECOVERY_INTERVAL_MS = 15000;
const ERROR_COOLDOWN_MS = 30000; const ERROR_COOLDOWN_MS = 30000;
const MAX_CONTEXT_TOKENS = 8000;
const MAX_BATCH_SIZE = 25; 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) * Gets the conversation key for a message (thread_id or channel_id)
*/ */
@@ -86,53 +90,25 @@ async function processBatch(
activeRequests++; activeRequests++;
conversationProcessing.add(conversationKey); conversationProcessing.add(conversationKey);
try { try {
// Get context before the first message const result = await runAnalysisInWorker(conversationKey, messages);
const firstMessage = messages[0];
const contextBefore = await getConversationContextBefore({
channelId: firstMessage.channel_id,
threadId: firstMessage.thread_id,
beforeCreatedAt: firstMessage.created_at,
limit: 20,
});
// Build prompt with context for (const row of result.rows) {
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) {
getModerationBroadcaster()?.messageAnalyzed(row); 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); conversationErrorCooldown.delete(conversationKey);
logger.info( logger.info(
@@ -141,13 +117,15 @@ async function processBatch(
); );
} catch (error) { } catch (error) {
lastError = error instanceof Error ? error.message : String(error); lastError = error instanceof Error ? error.message : String(error);
conversationErrorCooldown.set(
conversationKey,
Date.now() + ERROR_COOLDOWN_MS,
);
logger.error( logger.error(
{ conversationKey, error: lastError }, { conversationKey, error: lastError },
"Batch analysis failed", "Analysis worker failed",
); );
// Mark all messages in batch as error
for (const msg of messages) { for (const msg of messages) {
const row = await updateMessageAIAnalysis(msg.id, { const row = await updateMessageAIAnalysis(msg.id, {
status: "error", status: "error",
@@ -158,22 +136,37 @@ async function processBatch(
analyzedAt: Date.now(), analyzedAt: Date.now(),
error: lastError, error: lastError,
}); });
if (row) { if (row) getModerationBroadcaster()?.messageAnalyzed(row);
getModerationBroadcaster()?.messageAnalyzed(row);
} }
}
// Set error cooldown for this conversation
conversationErrorCooldown.set(
conversationKey,
Date.now() + ERROR_COOLDOWN_MS,
);
} finally { } finally {
activeRequests--; activeRequests--;
conversationProcessing.delete(conversationKey); conversationProcessing.delete(conversationKey);
} }
} }
async function runAnalysisInWorker(
conversationKey: string,
messages: MessageRecord[],
): Promise<AnalysisWorkerResponse> {
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 * Debounced analysis trigger for a conversation
*/ */