File size: 11,231 Bytes
00a912e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
import {
  EVENTS,
  ERROR_CODE,
  queueAddSchema,
  queueAddBatchSchema,
  queueInsertAfterCurrentSchema,
  queueRemoveSchema,
  queueReorderSchema,
  queueUpdateMetadataSchema,
} from '@music-together/shared'
import type { Track } from '@music-together/shared'
import type { TypedServer, TypedSocket } from '../middleware/types.js'
import { createWithPermission } from '../middleware/withControl.js'
import { checkSocketRateLimit } from '../middleware/socketRateLimiter.js'
import * as chatService from '../services/chatService.js'
import * as playerService from '../services/playerService.js'
import * as queueService from '../services/queueService.js'
import { userRepo } from '../repositories/userRepository.js'
import { logger } from '../utils/logger.js'

export function registerQueueController(io: TypedServer, socket: TypedSocket) {
  const withPermission = createWithPermission(io)
  const canManageQueue = (
    userId: string,
    room: { temporaryAdminUserId: string | null },
    allowedForTemporaryAdmin: boolean,
  ) => room.temporaryAdminUserId !== userId || allowedForTemporaryAdmin || userRepo.isServerAdmin(userId)

  socket.on(
    EVENTS.QUEUE_ADD,
    withPermission('add', 'Queue', async (ctx, raw) => {
      if (!(await checkSocketRateLimit(ctx.socket))) return
      const parsed = queueAddSchema.safeParse(raw)
      if (!parsed.success) {
        socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.INVALID_DATA, message: '无效的歌曲数据' })
        return
      }
      const track: Track = { ...parsed.data.track, requestedBy: ctx.user.nickname }

      const added = queueService.addTrack(ctx.roomId, track)
      if (!added) {
        socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.QUEUE_FULL, message: '播放队列已满' })
        return
      }
      io.to(ctx.roomId).emit(EVENTS.QUEUE_UPDATED, { queue: ctx.room.queue })

      // System message
      const msg = chatService.createSystemMessage(ctx.roomId, `${ctx.user.nickname} 点了一首「${track.title}」`)
      io.to(ctx.roomId).emit(EVENTS.CHAT_MESSAGE, msg)

      // If nothing was playing, auto-play this track.
      // Uses autoPlayIfEmpty which re-checks room.currentTrack inside the
      // per-room mutex, preventing concurrent QUEUE_ADD handlers from both
      // triggering playback.
      await playerService.autoPlayIfEmpty(io, ctx.roomId, track)

      logger.info(`“${ctx.user.nickname}”点歌:《${track.title}》`, {
        event: 'queue.track_added',
        roomId: ctx.roomId,
        trackId: track.id,
        title: track.title,
        artists: track.artist,
        source: track.source,
        requestedBy: ctx.user.nickname,
        queueSize: ctx.room.queue.length,
      })
    }),
  )

  socket.on(
    EVENTS.QUEUE_INSERT_AFTER_CURRENT,
    withPermission('add', 'Queue', async (ctx, raw) => {
      if (!(await checkSocketRateLimit(ctx.socket))) return
      const parsed = queueInsertAfterCurrentSchema.safeParse(raw)
      if (!parsed.success) {
        socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.INVALID_DATA, message: '无效的歌曲数据' })
        return
      }
      const track: Track = { ...parsed.data.track, requestedBy: ctx.user.nickname }

      const added = queueService.insertAfterCurrent(ctx.roomId, track)
      if (!added) {
        socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.QUEUE_FULL, message: '播放队列已满' })
        return
      }
      io.to(ctx.roomId).emit(EVENTS.QUEUE_UPDATED, { queue: ctx.room.queue })

      // System message
      const msg = chatService.createSystemMessage(ctx.roomId, `${ctx.user.nickname} 置顶了一首「${track.title}」`)
      io.to(ctx.roomId).emit(EVENTS.CHAT_MESSAGE, msg)

      // If nothing was playing, auto-play this track.
      await playerService.autoPlayIfEmpty(io, ctx.roomId, track)

      logger.info(`“${ctx.user.nickname}”置顶下一首:《${track.title}》`, {
        event: 'queue.track_inserted_next',
        roomId: ctx.roomId,
        trackId: track.id,
        title: track.title,
        artists: track.artist,
        source: track.source,
        requestedBy: ctx.user.nickname,
        queueSize: ctx.room.queue.length,
      })
    }),
  )

  socket.on(
    EVENTS.QUEUE_ADD_BATCH,
    withPermission('add', 'Queue', async (ctx, raw) => {
      if (!(await checkSocketRateLimit(ctx.socket))) return
      const parsed = queueAddBatchSchema.safeParse(raw)
      if (!parsed.success) {
        socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.INVALID_DATA, message: '无效的歌曲数据' })
        return
      }
      const { tracks: rawTracks, playlistName } = parsed.data
      const tracks: Track[] = rawTracks.map((t) => ({ ...t, requestedBy: ctx.user.nickname }))

      const addedCount = queueService.addBatchTracks(ctx.roomId, tracks)
      if (addedCount === 0) {
        socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.QUEUE_FULL, message: '播放队列已满' })
        return
      }
      io.to(ctx.roomId).emit(EVENTS.QUEUE_UPDATED, { queue: ctx.room.queue })

      const label = playlistName ? `歌单「${playlistName}」` : '歌单'
      const msg = chatService.createSystemMessage(
        ctx.roomId,
        `${ctx.user.nickname}${label}导入了 ${addedCount} 首歌`,
      )
      io.to(ctx.roomId).emit(EVENTS.CHAT_MESSAGE, msg)

      // Auto-play first added track if nothing is playing
      if (addedCount > 0) {
        await playerService.autoPlayIfEmpty(io, ctx.roomId, tracks[0])
      }

      logger.info(`“${ctx.user.nickname}”从歌单批量加入 ${addedCount} 首歌曲`, {
        event: 'queue.batch_added',
        roomId: ctx.roomId,
        playlistName: playlistName ?? '未命名歌单',
        requestedCount: rawTracks.length,
        addedCount,
        requestedBy: ctx.user.nickname,
        queueSize: ctx.room.queue.length,
      })
    }),
  )

  socket.on(
    EVENTS.QUEUE_REMOVE,
    withPermission('remove', 'Queue', async (ctx, raw) => {
      if (!canManageQueue(ctx.user.id, ctx.room, ctx.room.allowTemporaryAdminTrackRemoval)) {
        ctx.socket.emit(EVENTS.ROOM_ERROR, {
          code: ERROR_CODE.NO_PERMISSION,
          message: '房主未允许临时管理员删除歌曲',
        })
        return
      }
      const parsed = queueRemoveSchema.safeParse(raw)
      if (!parsed.success) {
        socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.INVALID_DATA, message: '无效的移除请求' })
        return
      }
      const { trackId } = parsed.data
      const removedTrack = ctx.room.queue.find((track) => track.id === trackId)
      const isCurrentTrack = ctx.room.currentTrack?.id === trackId

      queueService.removeTrack(ctx.roomId, trackId)
      io.to(ctx.roomId).emit(EVENTS.QUEUE_UPDATED, { queue: ctx.room.queue })

      // If the removed track was currently playing, skip to next or stop.
      // skipDebounce: removing current track must always advance, regardless
      // of how recently the last NEXT was triggered.
      if (isCurrentTrack) {
        await playerService.playNextTrackInRoom(io, ctx.roomId, ctx.room.playMode, { skipDebounce: true })
      }

      logger.info(`已从播放队列移除《${removedTrack?.title ?? trackId}》`, {
        event: 'queue.track_removed',
        roomId: ctx.roomId,
        trackId,
        title: removedTrack?.title,
        operatorId: ctx.user.id,
        operator: ctx.user.nickname,
        wasPlaying: isCurrentTrack,
        queueSize: ctx.room.queue.length,
      })
    }),
  )

  socket.on(
    EVENTS.QUEUE_REORDER,
    withPermission('reorder', 'Queue', (ctx, raw) => {
      const parsed = queueReorderSchema.safeParse(raw)
      if (!parsed.success) {
        socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.INVALID_DATA, message: '无效的排序数据' })
        return
      }
      const { trackIds } = parsed.data
      queueService.reorderTracks(ctx.roomId, trackIds)
      io.to(ctx.roomId).emit(EVENTS.QUEUE_UPDATED, { queue: ctx.room.queue })
      logger.info(`房间 ${ctx.roomId} 的播放队列顺序已调整`, {
        event: 'queue.reordered',
        roomId: ctx.roomId,
        operatorId: ctx.user.id,
        operator: ctx.user.nickname,
        queueSize: ctx.room.queue.length,
      })
    }),
  )

  socket.on(
    EVENTS.QUEUE_UPDATE_METADATA,
    withPermission('add', 'Queue', async (ctx, raw) => {
      if (!(await checkSocketRateLimit(ctx.socket))) return
      const parsed = queueUpdateMetadataSchema.safeParse(raw)
      if (!parsed.success) {
        socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.INVALID_DATA, message: '无效的歌词和封面数据' })
        return
      }

      const { trackId, metadataSource, lyricId, picId, cover, clearMetadata } = parsed.data
      const existing = ctx.room.queue.find((track) => track.id === trackId)
      if (!existing || existing.source !== 'bilibili') {
        socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.INVALID_DATA, message: '只能更新队列中的 B 站视频' })
        return
      }
      if (!clearMetadata && (!metadataSource || !cover)) {
        socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.INVALID_DATA, message: '无效的歌词和封面数据' })
        return
      }

      const updated = queueService.updateBilibiliMetadata(ctx.roomId, trackId, {
        metadataSource: clearMetadata ? undefined : metadataSource,
        lyricId: clearMetadata ? undefined : lyricId,
        picId: clearMetadata ? undefined : picId,
        cover: clearMetadata ? existing.bilibiliCover || existing.cover : cover!,
      })
      if (!updated) {
        socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.INVALID_DATA, message: '只能更新队列中的 B 站视频' })
        return
      }

      io.to(ctx.roomId).emit(EVENTS.QUEUE_UPDATED, { queue: ctx.room.queue })
      if (ctx.room.currentTrack?.id === trackId) {
        io.to(ctx.roomId).emit(EVENTS.PLAYER_TRACK_METADATA_UPDATED, { track: ctx.room.currentTrack })
      }
      logger.info(`“${ctx.user.nickname}”更新了《${updated.title}》的歌词和封面`, {
        event: 'queue.bilibili_metadata_updated',
        roomId: ctx.roomId,
        trackId,
        metadataSource: metadataSource ?? 'bilibili',
        operatorId: ctx.user.id,
      })
    }),
  )

  socket.on(
    EVENTS.QUEUE_CLEAR,
    withPermission('remove', 'Queue', async (ctx) => {
      if (!canManageQueue(ctx.user.id, ctx.room, ctx.room.allowTemporaryAdminQueueClear)) {
        ctx.socket.emit(EVENTS.ROOM_ERROR, {
          code: ERROR_CODE.NO_PERMISSION,
          message: '房主未允许临时管理员清空播放列表',
        })
        return
      }
      queueService.clearQueue(ctx.roomId)
      io.to(ctx.roomId).emit(EVENTS.QUEUE_UPDATED, { queue: [] })

      // Stop playback via mutex-protected variant to prevent races with
      // concurrent autoPlayIfEmpty from a simultaneous QUEUE_ADD.
      await playerService.stopPlaybackSafe(io, ctx.roomId)

      logger.info(`“${ctx.user.nickname}”清空了房间 ${ctx.roomId} 的播放队列`, {
        event: 'queue.cleared',
        roomId: ctx.roomId,
        operatorId: ctx.user.id,
        operator: ctx.user.nickname,
      })
    }),
  )
}