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 0995e2d..316e03f 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -35,6 +35,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; @@ -68,6 +79,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, @@ -449,6 +463,31 @@ export const usePlayerStore = defineStore('player', { this.fetchQueue(); }, + 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 @@ -536,6 +575,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 369805e..07ea9cd 100644 --- a/web/src/views/Home.vue +++ b/web/src/views/Home.vue @@ -82,6 +82,27 @@ + +
+

+ + 我的收藏 + {{ store.favoritedPlaylists.length }} +

+
+ + +
{{ fav.name }}
+
{{ fav.songCount }} 首
+
+
+
+

@@ -135,6 +156,7 @@