mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-04 05:52:50 +08:00
772 lines
28 KiB
TypeScript
772 lines
28 KiB
TypeScript
import Database from "better-sqlite3";
|
|
import { CAPABILITIES, BOTS_ALL } from "./permissions.js";
|
|
import { GUEST_USER_ID, GUEST_USERNAME } from "./users.js";
|
|
import type { QueuedSong } from "../audio/queue.js";
|
|
|
|
/**
|
|
* Reserved owner id for chat-saved / opt-in-shared queues. A `__`-bracketed
|
|
* literal can never collide with a real WebUI user id (UUIDs), so it cleanly
|
|
* partitions "shared" saved queues from per-user private ones (issue #119).
|
|
*/
|
|
export const SHARED_QUEUE_OWNER = "__shared__";
|
|
/** Cap per owner (private user OR the shared bucket). */
|
|
export const MAX_SAVED_QUEUES = 50;
|
|
/** Cap per saved queue / persisted live-queue snapshot. */
|
|
export const MAX_QUEUE_SONGS = 1000;
|
|
|
|
/** A stored song is a QueuedSong minus the lazily-resolved `url`. */
|
|
export type StoredSong = Omit<QueuedSong, "url">;
|
|
|
|
/** Saved-queue row without the (potentially large) songs blob — for list views. */
|
|
export interface SavedQueueMeta {
|
|
id: number;
|
|
ownerId: string;
|
|
name: string;
|
|
songCount: number;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
/** Full saved queue, including its songs. */
|
|
export interface SavedQueue extends SavedQueueMeta {
|
|
songs: StoredSong[];
|
|
}
|
|
|
|
/** One-row-per-bot persisted live-queue state (Feature 2, auto-restore). */
|
|
export interface QueueStateRow {
|
|
botId: string;
|
|
songs: StoredSong[];
|
|
currentIndex: number;
|
|
mode: string;
|
|
isFmMode: boolean;
|
|
fmPlatform: string;
|
|
}
|
|
|
|
export interface PlayHistoryEntry {
|
|
botId: string;
|
|
songId: string;
|
|
songName: string;
|
|
artist: string;
|
|
album: string;
|
|
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify" | "jellyfin";
|
|
coverUrl: string;
|
|
requestedBy?: string;
|
|
}
|
|
|
|
export interface PlayHistoryRecord extends PlayHistoryEntry {
|
|
id: number;
|
|
playedAt: string;
|
|
}
|
|
|
|
export interface BotInstance {
|
|
id: string;
|
|
name: string;
|
|
serverAddress: string;
|
|
serverPort: number;
|
|
nickname: string;
|
|
defaultChannel: string;
|
|
channelId: string;
|
|
channelPassword: string;
|
|
autoStart: boolean;
|
|
/** "ts3" | "ts6" | "" (empty = auto-detect) */
|
|
serverProtocol: string;
|
|
/** API key for TS6 HTTP Query */
|
|
ts6ApiKey: string;
|
|
/** Password to join the TS server (server password) */
|
|
serverPassword: string;
|
|
identity?: string;
|
|
}
|
|
|
|
export interface ProfileConfig {
|
|
avatarEnabled: boolean;
|
|
descriptionEnabled: boolean;
|
|
nicknameEnabled: boolean;
|
|
awayStatusEnabled: boolean;
|
|
channelDescEnabled: boolean;
|
|
nowPlayingMsgEnabled: boolean;
|
|
}
|
|
|
|
export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
|
|
avatarEnabled: true,
|
|
descriptionEnabled: true,
|
|
nicknameEnabled: true,
|
|
awayStatusEnabled: true,
|
|
channelDescEnabled: true,
|
|
nowPlayingMsgEnabled: true,
|
|
};
|
|
|
|
/**
|
|
* Per-bot player settings persisted across restarts (#125): the playback volume
|
|
* and play mode. These reset to defaults on process restart when kept only in
|
|
* memory (AudioPlayer/PlayQueue), so they are stored on the bot_instances row —
|
|
* exactly like the per-bot profile flags — and restored when the bot is (re)built.
|
|
*/
|
|
export interface PlayerSettings {
|
|
/** 0-100. */
|
|
volume: number;
|
|
/** PlayMode string: "seq" | "loop" | "random" | "rloop". */
|
|
playMode: string;
|
|
}
|
|
|
|
const PLAY_MODES = new Set(["seq", "loop", "random", "rloop"]);
|
|
|
|
export const DEFAULT_PLAYER_SETTINGS: PlayerSettings = {
|
|
volume: 75,
|
|
playMode: "seq",
|
|
};
|
|
|
|
export interface FavoritePlaylist {
|
|
id: number;
|
|
userId: string;
|
|
platform: string;
|
|
playlistId: string;
|
|
name: string;
|
|
coverUrl: string;
|
|
songCount: number;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface BotDatabase {
|
|
db: Database.Database;
|
|
addPlayHistory(entry: PlayHistoryEntry): void;
|
|
getPlayHistory(botId: string, limit: number): PlayHistoryRecord[];
|
|
saveBotInstance(instance: BotInstance): void;
|
|
getBotInstances(): BotInstance[];
|
|
deleteBotInstance(id: string): boolean;
|
|
getProfileConfig(botId: string): ProfileConfig;
|
|
saveProfileConfig(botId: string, config: ProfileConfig): void;
|
|
getPlayerSettings(botId: string): PlayerSettings;
|
|
saveVolume(botId: string, volume: number): void;
|
|
savePlayMode(botId: string, playMode: string): void;
|
|
getCustomAvatarPath(botId: string): string | null;
|
|
setCustomAvatarPath(botId: string, path: string | null): void;
|
|
addFavorite(userId: string, playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }): void;
|
|
removeFavorite(userId: string, playlistId: string, platform: string): boolean;
|
|
getFavorites(userId: string): FavoritePlaylist[];
|
|
isFavorited(userId: string, playlistId: string, platform: string): boolean;
|
|
// Per-user music account cookies (#164).
|
|
getUserMusicCookie(userId: string, platform: string): string | null;
|
|
setUserMusicCookie(userId: string, platform: string, cookie: string): void;
|
|
deleteUserMusicCookie(userId: string, platform: string): boolean;
|
|
// Saved queues (Feature 1) — upsert by (ownerId, name), capped.
|
|
saveQueue(ownerId: string, name: string, songs: StoredSong[]): SavedQueue;
|
|
listSavedQueues(ownerId: string, includeShared: boolean): SavedQueueMeta[];
|
|
getSavedQueue(id: number): SavedQueue | null;
|
|
deleteSavedQueue(id: number): boolean;
|
|
// Live-queue persistence (Feature 2) — one row per bot.
|
|
saveQueueState(state: QueueStateRow): void;
|
|
getQueueState(botId: string): QueueStateRow | null;
|
|
clearQueueState(botId: string): void;
|
|
close(): void;
|
|
}
|
|
|
|
function migrateSchema(db: Database.Database): void {
|
|
const columns = db.prepare("PRAGMA table_info(bot_instances)").all() as Array<{ name: string }>;
|
|
const names = columns.map((c) => c.name);
|
|
if (!names.includes("identity")) {
|
|
db.exec("ALTER TABLE bot_instances ADD COLUMN identity TEXT");
|
|
}
|
|
if (!names.includes("serverProtocol")) {
|
|
db.exec("ALTER TABLE bot_instances ADD COLUMN serverProtocol TEXT NOT NULL DEFAULT ''");
|
|
}
|
|
if (!names.includes("ts6ApiKey")) {
|
|
db.exec("ALTER TABLE bot_instances ADD COLUMN ts6ApiKey TEXT NOT NULL DEFAULT ''");
|
|
}
|
|
if (!names.includes("serverPassword")) {
|
|
db.exec("ALTER TABLE bot_instances ADD COLUMN serverPassword TEXT NOT NULL DEFAULT ''");
|
|
}
|
|
if (!names.includes("channelId")) {
|
|
db.exec("ALTER TABLE bot_instances ADD COLUMN channelId TEXT NOT NULL DEFAULT ''");
|
|
}
|
|
// Profile feature flags
|
|
const profileCols = [
|
|
"profile_avatar_enabled",
|
|
"profile_description_enabled",
|
|
"profile_nickname_enabled",
|
|
"profile_away_enabled",
|
|
"profile_channel_desc_enabled",
|
|
"profile_now_playing_enabled",
|
|
];
|
|
for (const col of profileCols) {
|
|
if (!names.includes(col)) {
|
|
db.exec(`ALTER TABLE bot_instances ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 1`);
|
|
}
|
|
}
|
|
if (!names.includes("custom_avatar_path")) {
|
|
db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT");
|
|
}
|
|
// Per-bot persisted player settings (#125): volume + play mode. Defaults match
|
|
// AudioPlayer/PlayQueue's in-memory defaults so pre-existing rows keep behaving
|
|
// exactly as before until the user changes them.
|
|
if (!names.includes("volume")) {
|
|
db.exec("ALTER TABLE bot_instances ADD COLUMN volume INTEGER NOT NULL DEFAULT 75");
|
|
}
|
|
if (!names.includes("play_mode")) {
|
|
db.exec("ALTER TABLE bot_instances ADD COLUMN play_mode TEXT NOT NULL DEFAULT 'seq'");
|
|
}
|
|
|
|
const userColumns = db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
|
|
const userColNames = userColumns.map((c) => c.name);
|
|
if (!userColNames.includes("role")) {
|
|
db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'");
|
|
}
|
|
|
|
const historyColumns = db.prepare("PRAGMA table_info(play_history)").all() as Array<{ name: string }>;
|
|
const historyColNames = historyColumns.map((c) => c.name);
|
|
if (!historyColNames.includes("requestedBy")) {
|
|
db.exec("ALTER TABLE play_history ADD COLUMN requestedBy TEXT NOT NULL DEFAULT ''");
|
|
}
|
|
}
|
|
|
|
function initTables(db: Database.Database): void {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS play_history (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
botId TEXT NOT NULL,
|
|
songId TEXT NOT NULL,
|
|
songName TEXT NOT NULL,
|
|
artist TEXT NOT NULL,
|
|
album TEXT NOT NULL,
|
|
platform TEXT NOT NULL,
|
|
coverUrl TEXT NOT NULL,
|
|
requestedBy TEXT NOT NULL DEFAULT '',
|
|
playedAt TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS bot_instances (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
serverAddress TEXT NOT NULL,
|
|
serverPort INTEGER NOT NULL,
|
|
nickname TEXT NOT NULL,
|
|
defaultChannel TEXT NOT NULL,
|
|
channelId TEXT NOT NULL DEFAULT '',
|
|
channelPassword TEXT NOT NULL,
|
|
autoStart INTEGER NOT NULL DEFAULT 0,
|
|
serverProtocol TEXT NOT NULL DEFAULT '',
|
|
ts6ApiKey TEXT NOT NULL DEFAULT '',
|
|
serverPassword TEXT NOT NULL DEFAULT '',
|
|
volume INTEGER NOT NULL DEFAULT 75,
|
|
play_mode TEXT NOT NULL DEFAULT 'seq',
|
|
identity TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id TEXT PRIMARY KEY,
|
|
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
passwordHash TEXT NOT NULL,
|
|
createdAt INTEGER NOT NULL,
|
|
updatedAt INTEGER NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'admin'
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id TEXT PRIMARY KEY,
|
|
userId TEXT NOT NULL,
|
|
createdAt INTEGER NOT NULL,
|
|
expiresAt INTEGER NOT NULL,
|
|
lastSeenAt INTEGER NOT NULL,
|
|
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt);
|
|
|
|
CREATE TABLE IF NOT EXISTS api_keys (
|
|
id TEXT PRIMARY KEY,
|
|
userId TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
keyHash TEXT NOT NULL UNIQUE,
|
|
keyPrefix TEXT NOT NULL,
|
|
createdAt INTEGER NOT NULL,
|
|
lastUsedAt INTEGER,
|
|
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_api_keys_userId ON api_keys(userId);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_audit (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
timestamp INTEGER NOT NULL,
|
|
actorId TEXT,
|
|
actorUsername TEXT,
|
|
targetUserId TEXT,
|
|
targetUsername TEXT,
|
|
action TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_user_audit_timestamp ON user_audit(timestamp DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS favorite_playlists (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
userId TEXT NOT NULL,
|
|
platform TEXT NOT NULL,
|
|
playlistId TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
coverUrl TEXT NOT NULL DEFAULT '',
|
|
songCount INTEGER NOT NULL DEFAULT 0,
|
|
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
|
|
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE,
|
|
UNIQUE(userId, platform, playlistId)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_favorites_userId ON favorite_playlists(userId);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_permissions (
|
|
userId TEXT NOT NULL,
|
|
permission TEXT NOT NULL,
|
|
PRIMARY KEY (userId, permission),
|
|
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
CREATE TABLE IF NOT EXISTS user_bot_access (
|
|
userId TEXT NOT NULL,
|
|
botId TEXT NOT NULL,
|
|
PRIMARY KEY (userId, botId),
|
|
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
|
|
|
|
CREATE TABLE IF NOT EXISTS saved_queues (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ownerId TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
songs TEXT NOT NULL,
|
|
songCount INTEGER NOT NULL DEFAULT 0,
|
|
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(ownerId, name)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_saved_queues_ownerId ON saved_queues(ownerId);
|
|
|
|
CREATE TABLE IF NOT EXISTS queue_state (
|
|
botId TEXT PRIMARY KEY,
|
|
songs TEXT NOT NULL,
|
|
currentIndex INTEGER NOT NULL,
|
|
mode TEXT NOT NULL,
|
|
isFmMode INTEGER NOT NULL DEFAULT 0,
|
|
fmPlatform TEXT NOT NULL DEFAULT '',
|
|
updatedAt TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
-- A web user's own music-platform login (#164), used for their personal
|
|
-- FM instead of the bot's shared account. Secret: never sent to clients.
|
|
CREATE TABLE IF NOT EXISTS user_music_cookies (
|
|
userId TEXT NOT NULL,
|
|
platform TEXT NOT NULL,
|
|
cookie TEXT NOT NULL,
|
|
updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
|
|
PRIMARY KEY (userId, platform),
|
|
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
`);
|
|
}
|
|
|
|
/**
|
|
* One-time backfill: existing `member` users created before the
|
|
* account-permissions feature are granted full access (all 5 capabilities +
|
|
* the `bots.all` marker), exactly once per database. Admins are skipped (they
|
|
* bypass permission checks). New members created after this runs are not
|
|
* affected — they get the basic tier via POST /api/users. A marker row in
|
|
* `schema_meta` makes this idempotent.
|
|
*/
|
|
export function backfillMemberPermissions(db: Database.Database): void {
|
|
db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT)`);
|
|
const done = db.prepare("SELECT value FROM schema_meta WHERE key = 'perm_backfill_done'").get();
|
|
if (done) return;
|
|
const members = db.prepare("SELECT id FROM users WHERE role = 'member'").all() as { id: string }[];
|
|
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
|
|
const tokens = [...CAPABILITIES, BOTS_ALL];
|
|
const tx = db.transaction(() => {
|
|
for (const m of members) {
|
|
for (const t of tokens) insCap.run(m.id, t);
|
|
}
|
|
db.prepare("INSERT INTO schema_meta (key, value) VALUES ('perm_backfill_done', ?)").run(String(members.length));
|
|
});
|
|
tx();
|
|
}
|
|
|
|
/**
|
|
* Ensure the reserved guest principal exists. Idempotent via the PK on
|
|
* `users.id`. This row only backs login-less guest sessions; it is excluded
|
|
* from countUsers()/listUsers() so it never interferes with first-run setup
|
|
* or the user-management UI, and holds an unusable password hash.
|
|
*/
|
|
export function ensureGuestUser(db: Database.Database): void {
|
|
const now = Date.now();
|
|
db.prepare(
|
|
"INSERT OR IGNORE INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, '!', ?, ?, 'guest')"
|
|
).run(GUEST_USER_ID, GUEST_USERNAME, now, now);
|
|
}
|
|
|
|
export function createDatabase(dbPath: string): BotDatabase {
|
|
const db = new Database(dbPath);
|
|
db.pragma("journal_mode = WAL");
|
|
db.pragma("foreign_keys = ON");
|
|
initTables(db);
|
|
migrateSchema(db);
|
|
backfillMemberPermissions(db);
|
|
ensureGuestUser(db);
|
|
|
|
const insertHistory = db.prepare(`
|
|
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl, requestedBy)
|
|
VALUES (@botId, @songId, @songName, @artist, @album, @platform, @coverUrl, @requestedBy)
|
|
`);
|
|
|
|
const selectHistory = db.prepare(`
|
|
SELECT * FROM play_history WHERE botId = ? ORDER BY id DESC LIMIT ?
|
|
`);
|
|
|
|
const upsertInstance = db.prepare(`
|
|
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelId, channelPassword, autoStart, serverProtocol, ts6ApiKey, serverPassword, identity)
|
|
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelId, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey, @serverPassword, @identity)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
name = excluded.name,
|
|
serverAddress = excluded.serverAddress,
|
|
serverPort = excluded.serverPort,
|
|
nickname = excluded.nickname,
|
|
defaultChannel = excluded.defaultChannel,
|
|
channelId = excluded.channelId,
|
|
channelPassword = excluded.channelPassword,
|
|
autoStart = excluded.autoStart,
|
|
serverProtocol = excluded.serverProtocol,
|
|
ts6ApiKey = excluded.ts6ApiKey,
|
|
serverPassword = excluded.serverPassword,
|
|
identity = excluded.identity
|
|
`);
|
|
|
|
const selectInstances = db.prepare(`SELECT * FROM bot_instances`);
|
|
|
|
const deleteInstance = db.prepare(`DELETE FROM bot_instances WHERE id = ?`);
|
|
|
|
const selectProfileConfig = db.prepare(`
|
|
SELECT profile_avatar_enabled, profile_description_enabled,
|
|
profile_nickname_enabled, profile_away_enabled,
|
|
profile_channel_desc_enabled, profile_now_playing_enabled
|
|
FROM bot_instances WHERE id = ?
|
|
`);
|
|
|
|
const updateProfileConfig = db.prepare(`
|
|
UPDATE bot_instances SET
|
|
profile_avatar_enabled = @avatar,
|
|
profile_description_enabled = @description,
|
|
profile_nickname_enabled = @nickname,
|
|
profile_away_enabled = @away,
|
|
profile_channel_desc_enabled = @channelDesc,
|
|
profile_now_playing_enabled = @nowPlaying
|
|
WHERE id = @id
|
|
`);
|
|
|
|
const selectPlayerSettings = db.prepare(
|
|
`SELECT volume, play_mode FROM bot_instances WHERE id = ?`,
|
|
);
|
|
const updateVolume = db.prepare(`UPDATE bot_instances SET volume = ? WHERE id = ?`);
|
|
const updatePlayMode = db.prepare(`UPDATE bot_instances SET play_mode = ? WHERE id = ?`);
|
|
|
|
const selectCustomAvatar = db.prepare(`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`);
|
|
const updateCustomAvatar = db.prepare(`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`);
|
|
|
|
const insertFavorite = db.prepare(`
|
|
INSERT INTO favorite_playlists (userId, platform, playlistId, name, coverUrl, songCount)
|
|
VALUES (@userId, @platform, @playlistId, @name, @coverUrl, @songCount)
|
|
`);
|
|
|
|
const deleteFavorite = db.prepare(`
|
|
DELETE FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
|
|
`);
|
|
|
|
const selectFavorites = db.prepare(`
|
|
SELECT id, userId, platform, playlistId, name, coverUrl, songCount, createdAt
|
|
FROM favorite_playlists WHERE userId = ? ORDER BY createdAt DESC
|
|
`);
|
|
|
|
const checkFavorited = db.prepare(`
|
|
SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
|
|
`);
|
|
|
|
const selectUserMusicCookie = db.prepare(
|
|
`SELECT cookie FROM user_music_cookies WHERE userId = ? AND platform = ?`,
|
|
);
|
|
const upsertUserMusicCookie = db.prepare(`
|
|
INSERT INTO user_music_cookies (userId, platform, cookie) VALUES (?, ?, ?)
|
|
ON CONFLICT(userId, platform) DO UPDATE SET cookie = excluded.cookie, updatedAt = datetime('now')
|
|
`);
|
|
const deleteUserMusicCookieStmt = db.prepare(
|
|
`DELETE FROM user_music_cookies WHERE userId = ? AND platform = ?`,
|
|
);
|
|
|
|
// A corrupt/hand-edited songs blob must never throw into a route or the
|
|
// restore path — degrade to an empty list instead.
|
|
const parseSongs = (raw: string): StoredSong[] => {
|
|
try {
|
|
const v = JSON.parse(raw);
|
|
return Array.isArray(v) ? (v as StoredSong[]) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
const rowToSavedMeta = (r: {
|
|
id: number; ownerId: string; name: string; songCount: number; createdAt: string; updatedAt: string;
|
|
}): SavedQueueMeta => ({
|
|
id: r.id,
|
|
ownerId: r.ownerId,
|
|
name: r.name,
|
|
songCount: r.songCount,
|
|
createdAt: r.createdAt,
|
|
updatedAt: r.updatedAt,
|
|
});
|
|
|
|
const upsertSavedQueue = db.prepare(`
|
|
INSERT INTO saved_queues (ownerId, name, songs, songCount)
|
|
VALUES (@ownerId, @name, @songs, @songCount)
|
|
ON CONFLICT(ownerId, name) DO UPDATE SET
|
|
songs = excluded.songs,
|
|
songCount = excluded.songCount,
|
|
updatedAt = datetime('now')
|
|
`);
|
|
const selectSavedQueueByOwnerName = db.prepare(
|
|
"SELECT * FROM saved_queues WHERE ownerId = ? AND name = ?",
|
|
);
|
|
const selectSavedQueueIdByOwnerName = db.prepare(
|
|
"SELECT id FROM saved_queues WHERE ownerId = ? AND name = ?",
|
|
);
|
|
const countSavedQueues = db.prepare(
|
|
"SELECT COUNT(*) AS c FROM saved_queues WHERE ownerId = ?",
|
|
);
|
|
const listSavedQueuesOwn = db.prepare(
|
|
"SELECT id, ownerId, name, songCount, createdAt, updatedAt FROM saved_queues WHERE ownerId = ? ORDER BY updatedAt DESC",
|
|
);
|
|
const listSavedQueuesShared = db.prepare(
|
|
"SELECT id, ownerId, name, songCount, createdAt, updatedAt FROM saved_queues WHERE ownerId = ? OR ownerId = ? ORDER BY updatedAt DESC",
|
|
);
|
|
const selectSavedQueueById = db.prepare("SELECT * FROM saved_queues WHERE id = ?");
|
|
const deleteSavedQueueById = db.prepare("DELETE FROM saved_queues WHERE id = ?");
|
|
|
|
const upsertQueueState = db.prepare(`
|
|
INSERT INTO queue_state (botId, songs, currentIndex, mode, isFmMode, fmPlatform, updatedAt)
|
|
VALUES (@botId, @songs, @currentIndex, @mode, @isFmMode, @fmPlatform, datetime('now'))
|
|
ON CONFLICT(botId) DO UPDATE SET
|
|
songs = excluded.songs,
|
|
currentIndex = excluded.currentIndex,
|
|
mode = excluded.mode,
|
|
isFmMode = excluded.isFmMode,
|
|
fmPlatform = excluded.fmPlatform,
|
|
updatedAt = datetime('now')
|
|
`);
|
|
const selectQueueState = db.prepare("SELECT * FROM queue_state WHERE botId = ?");
|
|
const deleteQueueState = db.prepare("DELETE FROM queue_state WHERE botId = ?");
|
|
|
|
return {
|
|
db,
|
|
|
|
addPlayHistory(record) {
|
|
insertHistory.run({ ...record, requestedBy: record.requestedBy ?? "" });
|
|
},
|
|
|
|
getPlayHistory(botId, limit) {
|
|
return selectHistory.all(botId, limit) as PlayHistoryRecord[];
|
|
},
|
|
|
|
saveBotInstance(instance) {
|
|
upsertInstance.run({
|
|
...instance,
|
|
autoStart: instance.autoStart ? 1 : 0,
|
|
identity: instance.identity ?? null,
|
|
});
|
|
},
|
|
|
|
getBotInstances() {
|
|
const rows = selectInstances.all() as Array<
|
|
Omit<BotInstance, "autoStart" | "identity"> & { autoStart: number; identity: string | null }
|
|
>;
|
|
return rows.map((r) => ({
|
|
...r,
|
|
autoStart: r.autoStart === 1,
|
|
serverProtocol: r.serverProtocol ?? "",
|
|
ts6ApiKey: r.ts6ApiKey ?? "",
|
|
serverPassword: r.serverPassword ?? "",
|
|
channelId: r.channelId ?? "",
|
|
identity: r.identity ?? undefined,
|
|
}));
|
|
},
|
|
|
|
deleteBotInstance(id) {
|
|
const result = deleteInstance.run(id);
|
|
return result.changes > 0;
|
|
},
|
|
|
|
getProfileConfig(botId) {
|
|
const row = selectProfileConfig.get(botId) as Record<string, number> | undefined;
|
|
if (!row) return { ...DEFAULT_PROFILE_CONFIG };
|
|
return {
|
|
avatarEnabled: row.profile_avatar_enabled === 1,
|
|
descriptionEnabled: row.profile_description_enabled === 1,
|
|
nicknameEnabled: row.profile_nickname_enabled === 1,
|
|
awayStatusEnabled: row.profile_away_enabled === 1,
|
|
channelDescEnabled: row.profile_channel_desc_enabled === 1,
|
|
nowPlayingMsgEnabled: row.profile_now_playing_enabled === 1,
|
|
};
|
|
},
|
|
|
|
saveProfileConfig(botId, config) {
|
|
updateProfileConfig.run({
|
|
id: botId,
|
|
avatar: config.avatarEnabled ? 1 : 0,
|
|
description: config.descriptionEnabled ? 1 : 0,
|
|
nickname: config.nicknameEnabled ? 1 : 0,
|
|
away: config.awayStatusEnabled ? 1 : 0,
|
|
channelDesc: config.channelDescEnabled ? 1 : 0,
|
|
nowPlaying: config.nowPlayingMsgEnabled ? 1 : 0,
|
|
});
|
|
},
|
|
|
|
getPlayerSettings(botId) {
|
|
const row = selectPlayerSettings.get(botId) as
|
|
| { volume: number | null; play_mode: string | null }
|
|
| undefined;
|
|
if (!row) return { ...DEFAULT_PLAYER_SETTINGS };
|
|
// Coerce/validate: clamp volume to 0-100 and fall back to defaults for any
|
|
// NULL / out-of-range / unknown value (a hand-edited DB must never feed a
|
|
// bad value into AudioPlayer.setVolume / PlayQueue.setMode).
|
|
const rawVol = typeof row.volume === "number" ? row.volume : DEFAULT_PLAYER_SETTINGS.volume;
|
|
const volume = Number.isFinite(rawVol)
|
|
? Math.max(0, Math.min(100, Math.round(rawVol)))
|
|
: DEFAULT_PLAYER_SETTINGS.volume;
|
|
const playMode =
|
|
typeof row.play_mode === "string" && PLAY_MODES.has(row.play_mode)
|
|
? row.play_mode
|
|
: DEFAULT_PLAYER_SETTINGS.playMode;
|
|
return { volume, playMode };
|
|
},
|
|
|
|
saveVolume(botId, volume) {
|
|
const clamped = Math.max(0, Math.min(100, Math.round(volume)));
|
|
updateVolume.run(clamped, botId);
|
|
},
|
|
|
|
savePlayMode(botId, playMode) {
|
|
// Persist only recognized modes so a bad value can never poison the row.
|
|
if (!PLAY_MODES.has(playMode)) return;
|
|
updatePlayMode.run(playMode, botId);
|
|
},
|
|
|
|
getCustomAvatarPath(botId) {
|
|
const row = selectCustomAvatar.get(botId) as { custom_avatar_path: string | null } | undefined;
|
|
return row?.custom_avatar_path ?? null;
|
|
},
|
|
setCustomAvatarPath(botId, path) {
|
|
updateCustomAvatar.run(path, botId);
|
|
},
|
|
|
|
addFavorite(userId, playlist) {
|
|
insertFavorite.run({ userId, ...playlist });
|
|
},
|
|
|
|
removeFavorite(userId, playlistId, platform) {
|
|
const result = deleteFavorite.run(userId, playlistId, platform);
|
|
return result.changes > 0;
|
|
},
|
|
|
|
getFavorites(userId) {
|
|
return selectFavorites.all(userId) as FavoritePlaylist[];
|
|
},
|
|
|
|
isFavorited(userId, playlistId, platform) {
|
|
const row = checkFavorited.get(userId, playlistId, platform);
|
|
return row !== undefined;
|
|
},
|
|
|
|
getUserMusicCookie(userId, platform) {
|
|
const row = selectUserMusicCookie.get(userId, platform) as { cookie: string } | undefined;
|
|
return row?.cookie ?? null;
|
|
},
|
|
|
|
setUserMusicCookie(userId, platform, cookie) {
|
|
upsertUserMusicCookie.run(userId, platform, cookie);
|
|
},
|
|
|
|
deleteUserMusicCookie(userId, platform) {
|
|
return deleteUserMusicCookieStmt.run(userId, platform).changes > 0;
|
|
},
|
|
|
|
saveQueue(ownerId, name, songs) {
|
|
if (songs.length > MAX_QUEUE_SONGS) {
|
|
throw new Error(`保存失败:歌曲数量超过上限 ${MAX_QUEUE_SONGS}`);
|
|
}
|
|
// Strip any lazily-resolved url before persisting.
|
|
const stripped: StoredSong[] = songs.map((s) => {
|
|
const { url: _url, ...rest } = s as QueuedSong;
|
|
return rest;
|
|
});
|
|
// Enforce the per-owner cap only for a NEW name (an overwrite of an
|
|
// existing saved queue must always be allowed).
|
|
const existing = selectSavedQueueIdByOwnerName.get(ownerId, name) as
|
|
| { id: number }
|
|
| undefined;
|
|
if (!existing) {
|
|
const { c } = countSavedQueues.get(ownerId) as { c: number };
|
|
if (c >= MAX_SAVED_QUEUES) {
|
|
throw new Error(`保存失败:已保存队列数量超过上限 ${MAX_SAVED_QUEUES}`);
|
|
}
|
|
}
|
|
upsertSavedQueue.run({
|
|
ownerId,
|
|
name,
|
|
songs: JSON.stringify(stripped),
|
|
songCount: stripped.length,
|
|
});
|
|
const row = selectSavedQueueByOwnerName.get(ownerId, name) as SavedQueueMeta;
|
|
return { ...rowToSavedMeta(row), songs: stripped };
|
|
},
|
|
|
|
listSavedQueues(ownerId, includeShared) {
|
|
const rows = includeShared
|
|
? (listSavedQueuesShared.all(ownerId, SHARED_QUEUE_OWNER) as SavedQueueMeta[])
|
|
: (listSavedQueuesOwn.all(ownerId) as SavedQueueMeta[]);
|
|
return rows.map(rowToSavedMeta);
|
|
},
|
|
|
|
getSavedQueue(id) {
|
|
const row = selectSavedQueueById.get(id) as
|
|
| (SavedQueueMeta & { songs: string })
|
|
| undefined;
|
|
if (!row) return null;
|
|
return { ...rowToSavedMeta(row), songs: parseSongs(row.songs) };
|
|
},
|
|
|
|
deleteSavedQueue(id) {
|
|
return deleteSavedQueueById.run(id).changes > 0;
|
|
},
|
|
|
|
saveQueueState(state) {
|
|
upsertQueueState.run({
|
|
botId: state.botId,
|
|
songs: JSON.stringify(state.songs),
|
|
currentIndex: state.currentIndex,
|
|
mode: state.mode,
|
|
isFmMode: state.isFmMode ? 1 : 0,
|
|
fmPlatform: state.fmPlatform,
|
|
});
|
|
},
|
|
|
|
getQueueState(botId) {
|
|
const r = selectQueueState.get(botId) as
|
|
| { botId: string; songs: string; currentIndex: number; mode: string; isFmMode: number; fmPlatform: string }
|
|
| undefined;
|
|
if (!r) return null;
|
|
return {
|
|
botId: r.botId,
|
|
songs: parseSongs(r.songs),
|
|
currentIndex: r.currentIndex,
|
|
mode: r.mode,
|
|
isFmMode: r.isFmMode === 1,
|
|
fmPlatform: r.fmPlatform,
|
|
};
|
|
},
|
|
|
|
clearQueueState(botId) {
|
|
deleteQueueState.run(botId);
|
|
},
|
|
|
|
close() {
|
|
db.close();
|
|
},
|
|
};
|
|
}
|