diff --git a/.github/misskey/test.yml b/.github/misskey/test.yml index 513bfb1ac0c..96cf1ee15ec 100644 --- a/.github/misskey/test.yml +++ b/.github/misskey/test.yml @@ -17,3 +17,9 @@ redis: id: aidx proxyRemoteFiles: true + +# Calls API tests do not establish real audio connections. +cloudflareRealtime: + enabled: true + appId: test-app + appSecret: test-secret diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index bb5a79d6231..d6ac8632e35 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -3996,3 +3996,29 @@ _events: tagsCaption: "カンマ区切りで入力" rateLimitReached: "本日のイベント作成上限に達しました" unknownError: "エラーが発生しました" + +_watchTogether: + title: "Watch Together" + description: "YouTubeの動画を一緒に見る" + videoUrl: "YouTubeの動画URL" + shareVideo: "この動画を見る" + invalidUrl: "YouTubeの動画URLを入力してください" + empty: "ホスト・モデレーターが動画を選ぶと、ここに表示されます" + hostControls: "ホスト・モデレーターの操作に同期します" + sharingControls: "YouTubeの操作を共有中" + changeVideo: "動画を変更" + playNow: "今すぐ再生" + addToQueue: "キューに追加" + removeFromQueue: "キューから削除" + queue: "次に再生" + emptyQueue: "キューは空です。動画を追加すると、再生終了後に順番に再生します。" + queueFull: "キューに追加できるのは50本までです" + startWatching: "視聴を開始" + startWatchingDescription: "YouTubeのプレーヤーに接続します。再生位置はルームに合わせます。" + clear: "動画を閉じる" + playerError: "動画を再生できません。動画が非公開・削除済み、または埋め込みが許可されていない可能性があります。" + apiError: "視聴状態を取得・更新できませんでした" + autoplayBlocked: "ブラウザーが自動再生を止めました。「再生を許可」を押してください。" + allowPlayback: "再生を許可" + openYouTube: "YouTubeで開く" + ended: "ルームが終了しました" diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 7bd51eda2ef..99507dd62eb 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -90,6 +90,7 @@ import { LoginBonusService } from './LoginBonusService.js'; import { EventService } from './EventService.js'; import { CallsLiveConnectionService } from './calls/CallsLiveConnectionService.js'; import { CallsRoomService } from './calls/CallsRoomService.js'; +import { CallsWatchTogetherService } from './calls/CallsWatchTogetherService.js'; import { CloudflareRealtimeClient } from './calls/CloudflareRealtimeClient.js'; import { CallsMediaBindingService } from './calls/CallsMediaBindingService.js'; import { CallsMediaService } from './calls/CallsMediaService.js'; @@ -427,6 +428,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting LoginBonusService, EventService, CallsRoomService, + CallsWatchTogetherService, CallsLiveConnectionService, CloudflareRealtimeClient, CallsMediaBindingService, @@ -762,6 +764,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting EventService, CallsRoomService, + CallsWatchTogetherService, CallsLiveConnectionService, CloudflareRealtimeClient, CallsMediaBindingService, diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 5490c80b497..a732ce170da 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -21,6 +21,7 @@ import type { MiWebhook } from '@/models/Webhook.js'; import type { MiSystemWebhook } from '@/models/SystemWebhook.js'; import type { MiMeta } from '@/models/Meta.js'; import { MiAvatarDecoration, MiChatMessage, MiChatRoom, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js'; +import type { WatchTogetherSnapshot } from '@/core/calls/CallsWatchTogetherService.js'; import type { MiCallsRoom } from '@/models/CallsRoom.js'; import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; @@ -36,6 +37,7 @@ type CallsParticipantWithUser = Packed<'CallsParticipant'> & { user: (Packed<'UserLite'> & { isFollowing: boolean; isFollowed: boolean }) | null; }; export interface CallsRoomEventTypes { + watchTogether: CallsRoomEventBase & { state: WatchTogetherSnapshot }; title: CallsRoomEventBase & { title: string }; lifecycle: CallsRoomEventBase & { state: 'scheduled' | 'open' | 'ended' | 'cancelled'; reason?: 'host-timeout' }; participant: CallsRoomEventBase & { participantId: string; action: 'joined' | 'left' | 'removed' | 'updated'; participant?: CallsParticipantWithUser; moderatorUserIds?: string[] }; diff --git a/packages/backend/src/core/calls/CallsRoomService.ts b/packages/backend/src/core/calls/CallsRoomService.ts index d9dde1ca0f5..d2d9e8e8042 100644 --- a/packages/backend/src/core/calls/CallsRoomService.ts +++ b/packages/backend/src/core/calls/CallsRoomService.ts @@ -764,7 +764,7 @@ export class CallsRoomService implements OnModuleInit, OnApplicationShutdown { this.callsTelemetryService.lifecycle({ action: 'participant-removed', roomId, participantId: participant.id, reason: 'moderation' }); } - private async assertCanModerateParticipants(actor: MiUser, room: MiCallsRoom): Promise { + public async assertCanModerateParticipants(actor: MiUser, room: MiCallsRoom): Promise { if (room.state !== 'open') throw new CallsRoomError('invalid-state'); if (room.ownerUserId === actor.id || await this.roleService.isModerator(actor)) return; if (!room.moderatorUserIds.includes(actor.id) || !(await this.callsParticipantsRepository.existsBy({ roomId: room.id, userId: actor.id, state: 'active' }))) throw new CallsRoomError('access-denied'); diff --git a/packages/backend/src/core/calls/CallsWatchTogetherService.ts b/packages/backend/src/core/calls/CallsWatchTogetherService.ts new file mode 100644 index 00000000000..143fb412c55 --- /dev/null +++ b/packages/backend/src/core/calls/CallsWatchTogetherService.ts @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import { DI } from '@/di-symbols.js'; +import type { CallsParticipantsRepository, MiUser } from '@/models/_.js'; +import { CallsRoomError, CallsRoomService } from './CallsRoomService.js'; +import { CallsLiveConnectionService } from './CallsLiveConnectionService.js'; +import { CallsEventService } from './CallsEventService.js'; + +export type WatchTogetherState = { + queue: string[]; + videoId: string | null; + playing: boolean; + position: number; + updatedAt: number; + revision: number; +}; +export type WatchTogetherSnapshot = WatchTogetherState & { serverTime: number }; + +@Injectable() +export class CallsWatchTogetherService { + constructor( + @Inject(DI.redis) private redis: Redis.Redis, + @Inject(DI.callsParticipantsRepository) private participants: CallsParticipantsRepository, + private rooms: CallsRoomService, + private connections: CallsLiveConnectionService, + private events: CallsEventService, + ) {} + + public async assertAccess(user: MiUser, roomId: string) { + const room = await this.rooms.getRoom(roomId); + await this.rooms.assertCanAccess(user, room); + if (await this.participants.existsBy({ roomId, userId: user.id, state: 'removed' })) throw new CallsRoomError('access-denied'); + return room; + } + + private async read(roomId: string): Promise { + const raw = await this.redis.get(`calls:watch-together:${roomId}`); + return { queue: [], ...(raw == null ? { videoId: null, playing: false, position: 0, updatedAt: Date.now(), revision: 0 } : JSON.parse(raw) as WatchTogetherState) }; + } + + public async show(user: MiUser, roomId: string): Promise { + const room = await this.assertAccess(user, roomId); + const state = await this.read(roomId); + if (room.state === 'open' && room.ownerUserId === user.id) await this.connections.touchHost(roomId); + if (room.state !== 'open' && state.playing) { + state.position += Math.max(0, (room.endedAt?.getTime() ?? Date.now()) - state.updatedAt) / 1000; + state.playing = false; + } + return { ...state, serverTime: Date.now() }; + } + + public async update(user: MiUser, roomId: string, params: { expectedRevision: number; videoId?: string | null; playing?: boolean; position?: number; queue?: string[] }): Promise { + return this.connections.withRoomLock(roomId, async assertHeld => { + const room = await this.assertAccess(user, roomId); + await this.rooms.assertCanModerateParticipants(user, room); + const previous = await this.read(roomId); + if (previous.revision !== params.expectedRevision) throw new CallsRoomError('stale-revision'); + const now = Date.now(); + const changingVideo = params.videoId !== undefined; + const videoId = changingVideo ? params.videoId! : previous.videoId; + const state: WatchTogetherState = { + queue: params.queue ?? previous.queue, + videoId, + playing: videoId != null && (params.playing ?? (changingVideo ? false : previous.playing)), + position: videoId == null ? 0 : params.position ?? (changingVideo ? 0 : previous.position + (previous.playing ? (now - previous.updatedAt) / 1000 : 0)), + updatedAt: now, + revision: previous.revision + 1, + }; + await assertHeld(); + await this.redis.set(`calls:watch-together:${roomId}`, JSON.stringify(state), 'EX', 7 * 24 * 60 * 60); + if (room.ownerUserId === user.id) await this.connections.touchHost(roomId); + const snapshot = { ...state, serverTime: Date.now() }; + await this.events.publish(roomId, room.revision, 'watchTogether', { state: snapshot }); + return snapshot; + }); + } +} diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index afc9fa38fc0..c1c1e2e85a5 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -428,6 +428,8 @@ export * as 'calls/media/tracks/close' from './endpoints/calls/media/close.js'; export * as 'calls/media/reconcile' from './endpoints/calls/media/reconcile.js'; export * as 'calls/media/turn-credentials' from './endpoints/calls/media/turn-credentials.js'; export * as 'calls/media/credential/refresh' from './endpoints/calls/media/credential-refresh.js'; +export * as 'calls/watch-together/show' from './endpoints/calls/watch-together/show.js'; +export * as 'calls/watch-together/update' from './endpoints/calls/watch-together/update.js'; export * as 'point/send' from './endpoints/point/send.js'; export * as 'pinned-users' from './endpoints/pinned-users.js'; export * as 'promo/read' from './endpoints/promo/read.js'; diff --git a/packages/backend/src/server/api/endpoints/calls/watch-together/_shared.ts b/packages/backend/src/server/api/endpoints/calls/watch-together/_shared.ts new file mode 100644 index 00000000000..d7849c5be9f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/calls/watch-together/_shared.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const watchTogetherSchema = { + type: 'object', optional: false, nullable: false, + properties: { + queue: { type: 'array', optional: false, nullable: false, items: { type: 'string', optional: false, nullable: false } }, + videoId: { type: 'string', optional: false, nullable: true }, + playing: { type: 'boolean', optional: false, nullable: false }, + position: { type: 'number', optional: false, nullable: false }, + updatedAt: { type: 'number', optional: false, nullable: false }, + revision: { type: 'integer', optional: false, nullable: false }, + serverTime: { type: 'number', optional: false, nullable: false }, + }, +} as const; diff --git a/packages/backend/src/server/api/endpoints/calls/watch-together/show.ts b/packages/backend/src/server/api/endpoints/calls/watch-together/show.ts new file mode 100644 index 00000000000..4e0ed74787f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/calls/watch-together/show.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { CallsWatchTogetherService } from '@/core/calls/CallsWatchTogetherService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { callsApiError, callsErrors } from '../_shared.js'; +import { watchTogetherSchema } from './_shared.js'; + +export const meta = { + tags: ['calls'], stability: 'experimental', requireCredential: true, kind: 'read:calls', + + errors: callsErrors, res: watchTogetherSchema, +} as const; +export const paramDef = { + type: 'object', properties: { roomId: { type: 'string', format: 'misskey:id' } }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor(service: CallsWatchTogetherService) { + super(meta, paramDef, async (ps, me) => { + try { return await service.show(me, ps.roomId); } catch (error) { callsApiError(error); } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/calls/watch-together/update.ts b/packages/backend/src/server/api/endpoints/calls/watch-together/update.ts new file mode 100644 index 00000000000..06ae5a9eda7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/calls/watch-together/update.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { CallsWatchTogetherService } from '@/core/calls/CallsWatchTogetherService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { callsApiError, callsErrors } from '../_shared.js'; +import { watchTogetherSchema } from './_shared.js'; + +export const meta = { + tags: ['calls'], stability: 'experimental', requireCredential: true, kind: 'write:calls', + prohibitMoved: true, limit: { duration: 60000, max: 60 }, + errors: callsErrors, res: watchTogetherSchema, +} as const; +export const paramDef = { + type: 'object', properties: { + roomId: { type: 'string', format: 'misskey:id' }, + queue: { type: 'array', maxItems: 50, items: { type: 'string', pattern: '^[A-Za-z0-9_-]{11}$' } }, + expectedRevision: { type: 'integer', minimum: 0 }, + videoId: { type: 'string', nullable: true, pattern: '^[A-Za-z0-9_-]{11}$' }, + playing: { type: 'boolean' }, + position: { type: 'number', minimum: 0 }, + }, + required: ['roomId', 'expectedRevision'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor(service: CallsWatchTogetherService) { + super(meta, paramDef, async (ps, me) => { + try { return await service.update(me, ps.roomId, ps); } catch (error) { callsApiError(error); } + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/calls-room.ts b/packages/backend/src/server/api/stream/channels/calls-room.ts index 930213cba9d..b906151feae 100644 --- a/packages/backend/src/server/api/stream/channels/calls-room.ts +++ b/packages/backend/src/server/api/stream/channels/calls-room.ts @@ -8,6 +8,7 @@ import { REQUEST } from '@nestjs/core'; import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import { CallsFeatureDisabledError, CallsRoomError, CallsRoomService } from '@/core/calls/CallsRoomService.js'; +import { CallsWatchTogetherService } from '@/core/calls/CallsWatchTogetherService.js'; import { CallsMediaService } from '@/core/calls/CallsMediaService.js'; import { CallsLiveConnectionService } from '@/core/calls/CallsLiveConnectionService.js'; import { CallsEntityService } from '@/core/entities/CallsEntityService.js'; @@ -27,6 +28,7 @@ export class CallsRoomChannel extends Channel { constructor( @Inject(REQUEST) request: ChannelRequest, private callsRoomService: CallsRoomService, + private watchTogetherService: CallsWatchTogetherService, private callsMediaService: CallsMediaService, @Inject(DI.callsParticipantsRepository) private callsParticipantsRepository: CallsParticipantsRepository, @@ -53,6 +55,7 @@ export class CallsRoomChannel extends Channel { try { room = await this.callsRoomService.getRoom(this.roomId); await this.callsRoomService.assertCanAccess(this.user, room); + if (data.type === 'watchTogether') await this.watchTogetherService.assertAccess(this.user, this.roomId); } catch (error) { this.dispose(); if (error instanceof CallsFeatureDisabledError || (error instanceof CallsRoomError && error.code === 'access-denied')) { diff --git a/packages/backend/test/e2e/calls.ts b/packages/backend/test/e2e/calls.ts index 278f7a871aa..d8406ad2fa4 100644 --- a/packages/backend/test/e2e/calls.ts +++ b/packages/backend/test/e2e/calls.ts @@ -6,7 +6,7 @@ process.env.NODE_ENV = 'test'; import { beforeAll, describe, expect, test } from 'vitest'; -import { api, castAsError, signup } from '../utils.js'; +import { api, castAsError, createAppToken, signup } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Calls', () => { @@ -60,4 +60,44 @@ describe('Calls', () => { expect(castAsError(response.body ?? {}).error.code).toBe('CALLS_INVALID_STATE'); }); }); + describe('Watch Together', () => { + test('共有状態の取得と操作権限、入力、終了後の制限', async () => { + const viewer = await signup({ username: 'watch_viewer' }); + const created = await api('calls/rooms/create', { attachmentType: 'personal', title: 'Watch Together', visibility: 'public' }, alice); + expect(created.status).toBe(200); + const roomId = created.body.id; + await api('calls/rooms/open', { roomId, expectedRevision: created.body.revision }, alice); + const invalid = await api('calls/watch-together/update', { roomId, expectedRevision: 0, videoId: 'https://example.com/video' }, alice); + expect(invalid.status).toBe(400); + const selected = await api('calls/watch-together/update', { roomId, expectedRevision: 0, videoId: 'M7lc1UVf-VE' }, alice); + expect(selected.status).toBe(200); + const show = await api('calls/watch-together/show', { roomId }, viewer); + expect(show.status).toBe(200); + expect(show.body).toMatchObject({ videoId: 'M7lc1UVf-VE', playing: false, revision: 1 }); + const denied = await api('calls/watch-together/update', { roomId, expectedRevision: 1, playing: true }, viewer); + expect(castAsError(denied.body).error.code).toBe('CALLS_ACCESS_DENIED'); + const readOnlyToken = await createAppToken(alice, ['read:calls']); + expect((await api('calls/watch-together/update', { roomId, expectedRevision: 1, playing: true }, { token: readOnlyToken })).status).toBe(403); + const playing = await api('calls/watch-together/update', { roomId, expectedRevision: 1, playing: true, position: 30 }, alice); + expect(playing.body).toMatchObject({ playing: true, position: 30, revision: 2 }); + const queued = await api('calls/watch-together/update', { roomId, expectedRevision: 2, queue: ['dQw4w9WgXcQ'] }, alice); + expect(queued.status).toBe(200); + expect(queued.body).toMatchObject({ videoId: 'M7lc1UVf-VE', playing: true, queue: ['dQw4w9WgXcQ'], revision: 3 }); + expect((await api('calls/watch-together/show', { roomId }, viewer)).body.queue).toEqual(['dQw4w9WgXcQ']); + expect(castAsError((await api('calls/watch-together/update', { roomId, expectedRevision: 3, queue: [] }, viewer)).body).error.code).toBe('CALLS_ACCESS_DENIED'); + expect((await api('calls/watch-together/update', { roomId, expectedRevision: 3, queue: ['invalid'] }, alice)).status).toBe(400); + const snapshot = await api('calls/rooms/show', { roomId }, alice); + await api('calls/rooms/end', { roomId, expectedRevision: snapshot.body.room.revision }, alice); + expect((await api('calls/watch-together/show', { roomId }, viewer)).body.playing).toBe(false); + expect(castAsError((await api('calls/watch-together/update', { roomId, expectedRevision: 2, playing: true }, alice)).body).error.code).toBe('CALLS_INVALID_STATE'); + }); + + test('非公開ルームの視聴状態を外部ユーザーに公開しない', async () => { + const outsider = await signup({ username: 'watch_outsider' }); + const room = await api('calls/rooms/create', { attachmentType: 'personal', title: 'Private Watch Together', visibility: 'specified', visibleUserIds: [] }, alice); + expect(room.status).toBe(200); + expect(castAsError((await api('calls/watch-together/show', { roomId: room.body.id }, outsider)).body).error.code).toBe('CALLS_ACCESS_DENIED'); + }); + }); + }); diff --git a/packages/backend/test/unit/core/calls/CallsRoomChannel.ts b/packages/backend/test/unit/core/calls/CallsRoomChannel.ts index f55bef49570..cf1d741efbd 100644 --- a/packages/backend/test/unit/core/calls/CallsRoomChannel.ts +++ b/packages/backend/test/unit/core/calls/CallsRoomChannel.ts @@ -14,7 +14,7 @@ test('leaves the room and revokes media when a stream event detects lost access' const rooms = { getRoom: vi.fn().mockResolvedValue({ revision: 1 }), assertCanAccess: vi.fn().mockResolvedValue(undefined), leave: vi.fn().mockResolvedValue(undefined) }; const participants = { findOneBy: vi.fn() }; const entity = { packParticipants: vi.fn() }; - const channel = new CallsRoomChannel({ id: 'channel-a', connection } as never, rooms as never, {} as never, participants as never, entity as never, { isReady: async () => true } as never); + const channel = new CallsRoomChannel({ id: 'channel-a', connection } as never, rooms as never, { assertAccess: vi.fn() } as never, {} as never, participants as never, entity as never, { isReady: async () => true } as never); expect(await channel.init({ roomId: 'room-a' })).toBe(true); rooms.assertCanAccess.mockRejectedValue(new CallsRoomError('access-denied')); const handler = subscriber.on.mock.calls[0][1]; @@ -35,7 +35,7 @@ test.each(['joined', 'updated'] as const)('includes user data when a participant const packedParticipant = { id: 'participant-a', user: { id: 'user-b', avatarUrl: 'avatar' } }; const participants = { findOneBy: vi.fn().mockResolvedValue(participant) }; const entity = { packParticipants: vi.fn().mockResolvedValue([packedParticipant]) }; - const channel = new CallsRoomChannel({ id: 'channel-a', connection } as never, rooms as never, {} as never, participants as never, entity as never, { isReady: async () => true } as never); + const channel = new CallsRoomChannel({ id: 'channel-a', connection } as never, rooms as never, { assertAccess: vi.fn() } as never, {} as never, participants as never, entity as never, { isReady: async () => true } as never); expect(await channel.init({ roomId: 'room-a' })).toBe(true); const handler = subscriber.on.mock.calls[0][1]; const body = { sequence: 1, roomRevision: 1, occurredAt: new Date().toISOString(), participantId: 'participant-a', action }; @@ -53,7 +53,7 @@ test.each(['left', 'removed'] as const)('does not load user data when a particip const rooms = { getRoom: vi.fn().mockResolvedValue({ revision: 1 }), assertCanAccess: vi.fn().mockResolvedValue(undefined) }; const participants = { findOneBy: vi.fn() }; const entity = { packParticipants: vi.fn() }; - const channel = new CallsRoomChannel({ id: 'channel-a', connection } as never, rooms as never, {} as never, participants as never, entity as never, { isReady: async () => true } as never); + const channel = new CallsRoomChannel({ id: 'channel-a', connection } as never, rooms as never, { assertAccess: vi.fn() } as never, {} as never, participants as never, entity as never, { isReady: async () => true } as never); expect(await channel.init({ roomId: 'room-a' })).toBe(true); const handler = subscriber.on.mock.calls[0][1]; const body = { sequence: 1, roomRevision: 1, occurredAt: new Date().toISOString(), participantId: 'participant-a', action }; @@ -63,13 +63,28 @@ test.each(['left', 'removed'] as const)('does not load user data when a particip expect(connection.sendMessageToWs).toHaveBeenCalledWith('channel', expect.objectContaining({ type: 'participant', body })); }); + +test('rechecks Watch Together access before sending video state to removed viewers', async () => { + const subscriber = { on: vi.fn(), off: vi.fn() }; + const user = { id: 'removed-user' }; + const connection = { user, subscriber, sendMessageToWs: vi.fn() }; + const rooms = { getRoom: vi.fn().mockResolvedValue({ revision: 1 }), assertCanAccess: vi.fn(), leave: vi.fn().mockResolvedValue(undefined) }; + const watchTogether = { assertAccess: vi.fn().mockRejectedValue(new CallsRoomError('access-denied')) }; + const channel = new CallsRoomChannel({ id: 'channel-a', connection } as never, rooms as never, watchTogether as never, {} as never, {} as never, {} as never, {} as never); + await channel.init({ roomId: 'room-a' }); + await subscriber.on.mock.calls[0][1]({ type: 'watchTogether', body: { sequence: 1, roomRevision: 1, state: { videoId: 'M7lc1UVf-VE' } } }); + expect(watchTogether.assertAccess).toHaveBeenCalledWith(user, 'room-a'); + expect(connection.sendMessageToWs).toHaveBeenCalledWith('channel', expect.objectContaining({ type: 'revoked' })); + expect(connection.sendMessageToWs).not.toHaveBeenCalledWith('channel', expect.objectContaining({ type: 'watchTogether' })); +}); + test.each(['joined', 'updated'] as const)('does not expose a connecting participant on %s', async action => { const subscriber = { on: vi.fn(), off: vi.fn() }; const connection = { user: { id: 'user-a' }, subscriber, sendMessageToWs: vi.fn() }; const rooms = { getRoom: vi.fn().mockResolvedValue({ revision: 1 }), assertCanAccess: vi.fn().mockResolvedValue(undefined) }; const participants = { findOneBy: vi.fn().mockResolvedValue({ id: 'participant-a' }) }; const entity = { packParticipants: vi.fn() }; - const channel = new CallsRoomChannel({ id: 'channel-a', connection } as never, rooms as never, {} as never, participants as never, entity as never, { isReady: async () => false } as never); + const channel = new CallsRoomChannel({ id: 'channel-a', connection } as never, rooms as never, { assertAccess: vi.fn() } as never, {} as never, participants as never, entity as never, { isReady: async () => false } as never); await channel.init({ roomId: 'room-a' }); const body = { sequence: 1, roomRevision: 1, participantId: 'participant-a', action }; await subscriber.on.mock.calls[0][1]({ type: 'participant', body }); @@ -81,7 +96,7 @@ test.each([false, true])('ready requires Calls write permission (permitted: %s)' const user = { id: 'user-a' }; const connection = { user, subscriber: { on: vi.fn() }, token: { permission: permitted ? ['read:calls', 'write:calls'] : ['read:calls'] } }; const rooms = { getRoom: vi.fn().mockResolvedValue({ revision: 1 }), assertCanAccess: vi.fn().mockResolvedValue(undefined), confirmReady: vi.fn().mockResolvedValue(undefined) }; - const channel = new CallsRoomChannel({ id: 'channel-a', connection } as never, rooms as never, {} as never, {} as never, {} as never, {} as never); + const channel = new CallsRoomChannel({ id: 'channel-a', connection } as never, rooms as never, { assertAccess: vi.fn() } as never, {} as never, {} as never, {} as never, {} as never); await channel.init({ roomId: 'room-a' }); const identity = { connectionId: 'device-a', generation: 1 }; channel.onMessage('ready', identity); diff --git a/packages/backend/test/unit/core/calls/CallsWatchTogetherService.ts b/packages/backend/test/unit/core/calls/CallsWatchTogetherService.ts new file mode 100644 index 00000000000..48f0364d1a7 --- /dev/null +++ b/packages/backend/test/unit/core/calls/CallsWatchTogetherService.ts @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { CallsWatchTogetherService } from '@/core/calls/CallsWatchTogetherService.js'; +import { CallsRoomService } from '@/core/calls/CallsRoomService.js'; +import type { MiUser } from '@/models/_.js'; + +const user = (id: string) => ({ id, host: null }) as MiUser; +function fixture() { + const room = { id: 'room', ownerUserId: 'host', moderatorUserIds: ['moderator'], state: 'open', visibility: 'public', revision: 1, endedAt: null as Date | null }; + const active = new Set(['host', 'moderator', 'viewer']); + const removed = new Set(); + const participants = { existsBy: async (query: { userId: string; state: string }) => (query.state === 'active' ? active : removed).has(query.userId) }; + const stored = new Map(); + const redis = { get: async (key: string) => stored.get(key) ?? null, set: async (key: string, value: string) => { stored.set(key, value); return 'OK'; } }; + const connections = { withRoomLock: async (_id: string, callback: (assertHeld: () => Promise) => Promise) => callback(async () => {}), touchHost: vi.fn() }; + const events = { publish: vi.fn() }; + const rooms = new CallsRoomService( + { cloudflareRealtime: { enabled: true } } as never, { findOneBy: async () => room } as never, participants as never, + {} as never, {} as never, {} as never, {} as never, {} as never, { isModerator: async () => false } as never, + connections as never, events as never, {} as never, {} as never, + ); + return { service: new CallsWatchTogetherService(redis as never, participants as never, rooms, connections as never, events as never), room, active, removed, events, connections }; +} + +afterEach(() => vi.useRealTimers()); + +describe('Calls Watch Together', () => { + test('shares video, play, pause and seek anchors; late viewers receive the same state', async () => { + vi.useFakeTimers(); vi.setSystemTime(100000); + const { service, events } = fixture(); + const initial = await service.show(user('viewer'), 'room'); + expect(initial).toMatchObject({ videoId: null, revision: 0 }); + await service.update(user('host'), 'room', { expectedRevision: 0, videoId: 'M7lc1UVf-VE', playing: true }); + vi.setSystemTime(105000); + const paused = await service.update(user('moderator'), 'room', { expectedRevision: 1, playing: false }); + expect(paused).toMatchObject({ playing: false, position: 5, revision: 2 }); + const seeked = await service.update(user('host'), 'room', { expectedRevision: 2, position: 42 }); + expect(await service.show(user('viewer'), 'room')).toEqual(seeked); + expect(events.publish).toHaveBeenLastCalledWith('room', 1, 'watchTogether', { state: seeked }); + await expect(service.update(user('host'), 'room', { expectedRevision: 1, position: 0 })).rejects.toMatchObject({ code: 'stale-revision' }); + }); + + test('allows only the host and active room moderators to control playback', async () => { + const { service, active } = fixture(); + await expect(service.update(user('viewer'), 'room', { expectedRevision: 0, videoId: 'M7lc1UVf-VE' })).rejects.toMatchObject({ code: 'access-denied' }); + active.delete('moderator'); + await expect(service.update(user('moderator'), 'room', { expectedRevision: 0, videoId: 'M7lc1UVf-VE' })).rejects.toMatchObject({ code: 'access-denied' }); + }); + + test('rejects private-room outsiders and removed participants when reading', async () => { + const { service, room, removed } = fixture(); + removed.add('viewer'); + await expect(service.show(user('viewer'), 'room')).rejects.toMatchObject({ code: 'access-denied' }); + room.visibility = 'specified'; + Object.assign(room, { visibleUserIds: [] }); + await expect(service.show(user('outsider'), 'room')).rejects.toMatchObject({ code: 'access-denied' }); + }); + + test('stops at room end and rejects further controls', async () => { + vi.useFakeTimers(); vi.setSystemTime(100000); + const { service, room } = fixture(); + await service.update(user('host'), 'room', { expectedRevision: 0, videoId: 'M7lc1UVf-VE', playing: true }); + room.state = 'ended'; room.endedAt = new Date(105000); + vi.setSystemTime(120000); + expect(await service.show(user('viewer'), 'room')).toMatchObject({ playing: false, position: 5 }); + await expect(service.update(user('host'), 'room', { expectedRevision: 1, playing: true })).rejects.toMatchObject({ code: 'invalid-state' }); + }); + + test('keeps the viewing host present without an audio connection and clears a video', async () => { + const { service, connections } = fixture(); + await service.show(user('viewer'), 'room'); + expect(connections.touchHost).not.toHaveBeenCalled(); + await service.show(user('host'), 'room'); + expect(connections.touchHost).toHaveBeenCalledWith('room'); + await service.update(user('host'), 'room', { expectedRevision: 0, videoId: 'M7lc1UVf-VE' }); + expect(await service.update(user('host'), 'room', { expectedRevision: 1, videoId: null })).toMatchObject({ videoId: null, playing: false, position: 0 }); + }); + test('shares the queue without changing playback and consumes a selection atomically', async () => { + const { service } = fixture(); + const queue = ['dQw4w9WgXcQ']; + await service.update(user('host'), 'room', { expectedRevision: 0, videoId: 'M7lc1UVf-VE', playing: true }); + const queued = await service.update(user('moderator'), 'room', { expectedRevision: 1, queue }); + expect(queued).toMatchObject({ videoId: 'M7lc1UVf-VE', playing: true, queue }); + expect((await service.show(user('viewer'), 'room')).queue).toEqual(queue); + await expect(service.update(user('viewer'), 'room', { expectedRevision: 2, queue: [] })).rejects.toMatchObject({ code: 'access-denied' }); + const selected = await service.update(user('host'), 'room', { expectedRevision: 2, queue: [], videoId: queue[0], playing: true, position: 0 }); + expect(selected).toMatchObject({ queue: [], videoId: queue[0], playing: true, position: 0 }); + }); + +}); diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index 00352656bb4..4b2bd229a93 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -465,6 +465,8 @@ function toStories(component: string): Promise { globSync('src/components/MkRoleSelectDialog.vue'), globSync('src/components/grid/MkGrid.vue'), globSync('src/components/calls/MkCallsActivities.vue'), + globSync('src/components/calls/MkCallsWatchTogether.vue'), + globSync('src/components/calls/MkCallsWatchTogetherVideoPreview.vue'), globSync('src/pages/admin/custom-emojis-manager2.vue'), globSync('src/pages/admin/overview.ap-requests.vue'), globSync('src/pages/user/home.vue'), diff --git a/packages/frontend/src/components/MkCallsRoomWindow.vue b/packages/frontend/src/components/MkCallsRoomWindow.vue index 58023f44156..c1af0e12ee1 100644 --- a/packages/frontend/src/components/MkCallsRoomWindow.vue +++ b/packages/frontend/src/components/MkCallsRoomWindow.vue @@ -42,7 +42,11 @@ SPDX-License-Identifier: AGPL-3.0-only
- + + +