diff --git a/src/data/database.ts b/src/data/database.ts
index f911756..51e7c0e 100644
--- a/src/data/database.ts
+++ b/src/data/database.ts
@@ -51,6 +51,17 @@ export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
nowPlayingMsgEnabled: true,
};
+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;
@@ -62,6 +73,10 @@ export interface BotDatabase {
saveProfileConfig(botId: string, config: ProfileConfig): 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;
close(): void;
}
@@ -165,6 +180,20 @@ function initTables(db: Database.Database): void {
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);
`);
}
@@ -226,6 +255,24 @@ export function createDatabase(dbPath: string): BotDatabase {
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 = ?
+ `);
+
return {
db,
@@ -297,6 +344,24 @@ export function createDatabase(dbPath: string): BotDatabase {
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;
+ },
+
close() {
db.close();
},
diff --git a/src/web/api/favorites.ts b/src/web/api/favorites.ts
new file mode 100644
index 0000000..08fc292
--- /dev/null
+++ b/src/web/api/favorites.ts
@@ -0,0 +1,76 @@
+import { Router } from "express";
+import type { BotDatabase } from "../../data/database.js";
+import type { Logger } from "../../logger.js";
+
+export function createFavoritesRouter(database: BotDatabase, logger: Logger): Router {
+ const router = Router();
+
+ // GET /api/favorites — 获取当前用户的所有收藏
+ router.get("/", (req, res) => {
+ const userId = req.user!.id;
+ const favorites = database.getFavorites(userId);
+ res.json({ favorites });
+ });
+
+ // POST /api/favorites — 添加收藏
+ router.post("/", (req, res) => {
+ const userId = req.user!.id;
+ const { platform, playlistId, name, coverUrl, songCount } = req.body;
+ if (!platform || !playlistId || !name) {
+ res.status(400).json({ error: "platform, playlistId, name are required" });
+ return;
+ }
+ try {
+ database.addFavorite(userId, {
+ platform,
+ playlistId,
+ name,
+ coverUrl: coverUrl ?? "",
+ songCount: songCount ?? 0,
+ });
+ logger.info({ userId, platform, playlistId, name }, "Playlist favorited");
+ res.json({ success: true });
+ } catch (err: unknown) {
+ const e = err as { code?: string };
+ if (e?.code === "SQLITE_CONSTRAINT_UNIQUE") {
+ res.status(409).json({ error: "already favorited" });
+ return;
+ }
+ logger.error({ err }, "Failed to add favorite");
+ res.status(500).json({ error: "internal error" });
+ }
+ });
+
+ // DELETE /api/favorites/:id — 取消收藏(只允许删除自己的)
+ router.delete("/:id", (req, res) => {
+ const userId = req.user!.id;
+ const favId = parseInt(req.params.id, 10);
+ if (isNaN(favId)) {
+ res.status(400).json({ error: "invalid id" });
+ return;
+ }
+ const favorites = database.getFavorites(userId);
+ const fav = favorites.find((f) => f.id === favId);
+ if (!fav) {
+ res.status(404).json({ error: "favorite not found" });
+ return;
+ }
+ database.removeFavorite(userId, fav.playlistId, fav.platform);
+ logger.info({ userId, playlistId: fav.playlistId, platform: fav.platform }, "Playlist unfavorited");
+ res.json({ success: true });
+ });
+
+ // GET /api/favorites/check?platform=netease&playlistId=xxx — 检查是否已收藏
+ router.get("/check", (req, res) => {
+ const userId = req.user!.id;
+ const { platform, playlistId } = req.query;
+ if (!platform || !playlistId) {
+ res.status(400).json({ error: "platform and playlistId required" });
+ return;
+ }
+ const favorited = database.isFavorited(userId, playlistId as string, platform as string);
+ res.json({ favorited });
+ });
+
+ return router;
+}
\ No newline at end of file
diff --git a/src/web/server.ts b/src/web/server.ts
index 753fbe1..2cf611b 100755
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -18,6 +18,7 @@ import { createSessionRouter } from "./api/session.js";
import { createUsersRouter } from "./api/users.js";
import { createAuditStore } from "../data/audit.js";
import { createAuditRouter } from "./api/audit.js";
+import { createFavoritesRouter } from "./api/favorites.js";
import { setupWebSocket } from "./websocket.js";
import { createUserStore } from "../data/users.js";
import { createSessionStore } from "../data/sessions.js";
@@ -123,6 +124,8 @@ export function createWebServer(options: WebServerOptions): WebServer {
"/api/auth",
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
);
+ app.use("/api/favorites", createFavoritesRouter(options.database, logger));
+
// admin-only routes
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger));
app.use("/api/audit", requireAdmin, createAuditRouter(audit));
diff --git a/start.bat b/start.bat
new file mode 100644
index 0000000..dd63003
--- /dev/null
+++ b/start.bat
@@ -0,0 +1,19 @@
+@echo off
+title TSMusicBot
+
+:: Check node
+where node >nul 2>&1
+if errorlevel 1 (
+ echo Node.js not found. Run scripts\setup.bat first.
+ pause
+ exit /b 1
+)
+
+echo Starting TSMusicBot...
+echo WebUI: http://localhost:3000
+echo Press Ctrl+C to stop.
+echo.
+
+node dist\index.js
+
+pause
\ No newline at end of file
diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts
index 083262c..9dfa1d7 100644
--- a/web/src/stores/player.ts
+++ b/web/src/stores/player.ts
@@ -34,6 +34,17 @@ export interface PlaylistItem {
platform: string;
}
+export interface FavoritePlaylist {
+ id: number;
+ userId: string;
+ platform: string;
+ playlistId: string;
+ name: string;
+ coverUrl: string;
+ songCount: number;
+ createdAt: string;
+}
+
interface TimingState {
serverElapsed: number;
serverSyncTime: number;
@@ -64,6 +75,9 @@ export const usePlayerStore = defineStore('player', {
authStatus: { netease: false, qq: false },
lastFetchTime: 0,
+ // Favorited playlists (fetched from server, isolated per WebUI user)
+ favoritedPlaylists: [] as FavoritePlaylist[],
+
// Transient notification for surfacing failures (e.g., "song not playable")
// to a global Toast. Bumped `id` triggers re-render of the same message.
notification: null as { id: number; message: string; type: 'error' | 'info' } | null,
@@ -397,6 +411,31 @@ export const usePlayerStore = defineStore('player', {
if (bot) bot.playMode = mode;
},
+ async fetchFavorites() {
+ try {
+ const res = await axios.get('/api/favorites');
+ this.favoritedPlaylists = res.data.favorites ?? [];
+ } catch {
+ // not critical
+ }
+ },
+
+ async addFavorite(playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }) {
+ await axios.post('/api/favorites', playlist);
+ await this.fetchFavorites();
+ this.notify('已收藏', 'info');
+ },
+
+ async removeFavorite(id: number) {
+ await axios.delete(`/api/favorites/${id}`);
+ await this.fetchFavorites();
+ this.notify('已取消收藏', 'info');
+ },
+
+ isFavorited(playlistId: string, platform: string): boolean {
+ return this.favoritedPlaylists.some((f) => f.playlistId === playlistId && f.platform === platform);
+ },
+
async fetchHomeData() {
// Always check auth status first — if it changed since the cached
// fetch (e.g., user logged in/out as a different account), the
@@ -484,6 +523,9 @@ export const usePlayerStore = defineStore('player', {
if (authOk) {
this.lastFetchTime = Date.now();
}
+
+ // Fetch favorites in parallel — not cached by TTL; always fresh
+ this.fetchFavorites();
},
},
});
diff --git a/web/src/views/Home.vue b/web/src/views/Home.vue
index bb32d84..9404503 100644
--- a/web/src/views/Home.vue
+++ b/web/src/views/Home.vue
@@ -72,6 +72,27 @@
+
+
+
+
+ 我的收藏
+ {{ store.favoritedPlaylists.length }}
+
+
+
+
+ {{ fav.name }}
+ {{ fav.songCount }} 首
+
+
+
+