diff --git a/CHANGELOG.md b/CHANGELOG.md index 9134a905ca..eb52d832c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,14 +36,18 @@ ### Client - Enhance: 二要素認証のバックアップコード一覧をテキストファイルでダウンロード可能に +- Enhance: 動画再生時のデフォルトボリュームを30%に - Fix: リアクションしたユーザ一覧のUIが稀に左上に残ってしまう不具合を修正 ### Server - Enhance: タイムライン取得時のパフォーマンスを大幅に向上 - Enhance: ハイライト取得時のパフォーマンスを大幅に向上 - Enhance: トレンドハッシュタグ取得時のパフォーマンスを大幅に向上 +- Enhance: WebSocket接続が多い場合のパフォーマンスを向上 - Enhance: 不要なPostgreSQLのインデックスを削除しパフォーマンスを向上 - Fix: 連合なしアンケートに投票をするとUpdateがリモートに配信されてしまうのを修正 +- Fix: nodeinfoにおいてCORS用のヘッダーが設定されていないのを修正 +- Fix: 同じ種類のTLのストリーミングを複数接続できない問題を修正 ## 2023.9.3 ### General diff --git a/package.json b/package.json index 94a7198d5d..2255c1c372 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2023.10.0-beta.7-prismisskey.1", + "version": "2023.10.0-beta.8", "codename": "nasubi", "repository": { "type": "git", @@ -18,7 +18,6 @@ "build-assets": "node ./scripts/build-assets.mjs", "build": "pnpm build-pre && pnpm -r build && pnpm build-assets", "build-storybook": "pnpm --filter frontend build-storybook", - "build-and-start": "pnpm i && pnpm build && pnpm start", "start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js", "start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js", "init": "pnpm migrate", diff --git a/packages/backend/src/core/HashtagService.ts b/packages/backend/src/core/HashtagService.ts index 1a06767da8..ddff28359a 100644 --- a/packages/backend/src/core/HashtagService.ts +++ b/packages/backend/src/core/HashtagService.ts @@ -174,16 +174,15 @@ export class HashtagService { const redisPipeline = this.redisClient.pipeline(); - // TODO: これらの Set は Bloom Filter を使うようにしても良さそう - // チャート用 - redisPipeline.sadd(`hashtagUsers:${hashtag}:${window}`, userId); + redisPipeline.pfadd(`hashtagUsers:${hashtag}:${window}`, userId); redisPipeline.expire(`hashtagUsers:${hashtag}:${window}`, 60 * 60 * 24 * 3, // 3日間 'NX', // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定 ); // ユニークカウント用 + // TODO: Bloom Filter を使うようにしても良さそう redisPipeline.sadd(`hashtagUsers:${hashtag}`, userId); redisPipeline.expire(`hashtagUsers:${hashtag}`, 60 * 60, // 1時間 @@ -202,7 +201,7 @@ export class HashtagService { for (let i = 0; i < range; i++) { const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`; - redisPipeline.scard(`hashtagUsers:${hashtag}:${window}`); + redisPipeline.pfcount(`hashtagUsers:${hashtag}:${window}`); now.setMinutes(now.getMinutes() - (i * 10), 0, 0); } @@ -223,7 +222,7 @@ export class HashtagService { for (let i = 0; i < range; i++) { const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`; for (const hashtag of hashtags) { - redisPipeline.scard(`hashtagUsers:${hashtag}:${window}`); + redisPipeline.pfcount(`hashtagUsers:${hashtag}:${window}`); } now.setMinutes(now.getMinutes() - (i * 10), 0, 0); } diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 95279d2e99..a2e4fa494f 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -17,6 +17,7 @@ import type { MiNoteReaction } from '@/models/NoteReaction.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { isNotNull } from '@/misc/is-not-null.js'; +import { DebounceLoader } from '@/misc/loader.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { ReactionService } from '../ReactionService.js'; @@ -29,6 +30,7 @@ export class NoteEntityService implements OnModuleInit { private driveFileEntityService: DriveFileEntityService; private customEmojiService: CustomEmojiService; private reactionService: ReactionService; + private noteLoader = new DebounceLoader(this.findNoteOrFail); constructor( private moduleRef: ModuleRef, @@ -285,7 +287,7 @@ export class NoteEntityService implements OnModuleInit { }, options); const meId = me ? me.id : null; - const note = typeof src === 'object' ? src : await this.notesRepository.findOneOrFail({ where: { id: src }, relations: ['user'] }); + const note = typeof src === 'object' ? src : await this.noteLoader.load(src); const host = note.userHost; let text = note.text; @@ -451,4 +453,12 @@ export class NoteEntityService implements OnModuleInit { } return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[]; } + + @bindThis + private findNoteOrFail(id: string): Promise { + return this.notesRepository.findOneOrFail({ + where: { id }, + relations: ['user'], + }); + } } diff --git a/packages/backend/src/misc/loader.ts b/packages/backend/src/misc/loader.ts new file mode 100644 index 0000000000..25f7b54d31 --- /dev/null +++ b/packages/backend/src/misc/loader.ts @@ -0,0 +1,52 @@ +export type FetchFunction = (key: K) => Promise; + +type ResolveReject = Parameters>[0]>; + +type ResolverPair = { + resolve: ResolveReject[0]; + reject: ResolveReject[1]; +}; + +export class DebounceLoader { + private resolverMap = new Map>(); + private promiseMap = new Map>(); + private resolvedPromise = Promise.resolve(); + constructor(private loadFn: FetchFunction) {} + + public load(key: K): Promise { + const promise = this.promiseMap.get(key); + if (typeof promise !== 'undefined') { + return promise; + } + + const isFirst = this.promiseMap.size === 0; + const newPromise = new Promise((resolve, reject) => { + this.resolverMap.set(key, { resolve, reject }); + }); + this.promiseMap.set(key, newPromise); + + if (isFirst) { + this.enqueueDebouncedLoadJob(); + } + + return newPromise; + } + + private runDebouncedLoad(): void { + const resolvers = [...this.resolverMap]; + this.resolverMap.clear(); + this.promiseMap.clear(); + + for (const [key, { resolve, reject }] of resolvers) { + this.loadFn(key).then(resolve, reject); + } + } + + private enqueueDebouncedLoadJob(): void { + this.resolvedPromise.then(() => { + process.nextTick(() => { + this.runDebouncedLoad(); + }); + }); + } +} diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index dd2b7882a2..79b0a57f2b 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -135,7 +135,11 @@ export class NodeinfoServerService { .type( 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"', ) - .header('Cache-Control', 'public, max-age=600'); + .header('Cache-Control', 'public, max-age=600') + .header('Access-Control-Allow-Headers', 'Accept') + .header('Access-Control-Allow-Methods', 'GET, OPTIONS') + .header('Access-Control-Allow-Origin', '*') + .header('Access-Control-Expose-Headers', 'Vary'); return { version: '2.1', ...base }; }); @@ -148,7 +152,11 @@ export class NodeinfoServerService { .type( 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"', ) - .header('Cache-Control', 'public, max-age=600'); + .header('Cache-Control', 'public, max-age=600') + .header('Access-Control-Allow-Headers', 'Accept') + .header('Access-Control-Allow-Methods', 'GET, OPTIONS') + .header('Access-Control-Allow-Origin', '*') + .header('Access-Control-Expose-Headers', 'Vary'); return { version: '2.0', ...base }; }); diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 0e4a5ece3e..e598b91e51 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -199,10 +199,10 @@ export class ServerService implements OnApplicationShutdown { includeSecrets: true, })); - reply.code(200); - return 'Verify succeeded!'; + reply.code(200).send('Verification succeeded! メールアドレスの認証に成功しました。'); + return; } else { - reply.code(404); + reply.code(404).send('Verification failed. Please try again. メールアドレスの認証に失敗しました。もう一度お試しください'); return; } }); diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 772cd1784d..143c1399c6 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -98,20 +98,23 @@ export default class extends Endpoint { // eslint- const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 - const [htlNoteIds, ltlNoteIds] = await Promise.all([ - this.redisForTimelines.xrevrange( - ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, - ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', - ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-', - 'COUNT', limit, - ).then(res => res.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId)), - this.redisForTimelines.xrevrange( - ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline', - ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', - ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-', - 'COUNT', limit, - ).then(res => res.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId)), - ]); + const redisPipeline = this.redisForTimelines.pipeline(); + redisPipeline.xrevrange( + ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', + ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-', + 'COUNT', limit, + ); + redisPipeline.xrevrange( + ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline', + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', + ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-', + 'COUNT', limit, + ); + const [htlNoteIds, ltlNoteIds] = await redisPipeline.exec().then(res => res ? [ + (res[0][1] as string[][]).map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId), + (res[1][1] as string[][]).map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId), + ] : []); let noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds])); noteIds.sort((a, b) => a > b ? -1 : 1); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index 73c066c6e8..71aa6e661c 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -15,7 +15,6 @@ import { IdService } from '@/core/IdService.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { QueryService } from '@/core/QueryService.js'; import { ApiError } from '../../error.js'; -import { GetterService } from '@/server/api/GetterService.js' export const meta = { tags: ['users', 'notes'], @@ -67,9 +66,8 @@ export default class extends Endpoint { // eslint- @Inject(DI.notesRepository) private notesRepository: NotesRepository, - private getterService: GetterService, - private queryService: QueryService, private noteEntityService: NoteEntityService, + private queryService: QueryService, private cacheService: CacheService, private idService: IdService, ) { @@ -80,8 +78,6 @@ export default class extends Endpoint { // eslint- this.cacheService.userMutingsCache.fetch(me.id), ]) : [new Set()]; - let timeline: MiNote[] = []; - const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 const [noteIdsRes, repliesNoteIdsRes, channelNoteIdsRes] = await Promise.all([ @@ -117,96 +113,40 @@ export default class extends Endpoint { // eslint- noteIds.sort((a, b) => a > b ? -1 : 1); noteIds = noteIds.slice(0, ps.limit); - if (noteIds.length < limit) { + if (noteIds.length > 0) { + const isFollowing = me ? Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId) : false; - //#region Construct query - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.userId = :userId', { userId: ps.userId }) + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('note.channel', 'channel') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); - query.andWhere(new Brackets(qb => { - qb.orWhere('note.channelId IS NULL'); - qb.orWhere('channel.isSensitive = false'); - })); + let timeline = await query.getMany(); - this.queryService.generateVisibilityQuery(query, me); - if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - } + timeline = timeline.filter(note => { + if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false; - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - - if (!ps.withReplies) { - query.andWhere('note.replyId IS NULL'); - } - - if (ps.withRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere(new Brackets(qb => { - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - })); - })); - } - - if (ps.includeMyRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :userId', { userId: ps.userId }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - const ids = await query.limit(limit - noteIds.length).getMany(); - noteIds = noteIds.concat(ids.map(note => note.id)); - } - - if (noteIds.length === 0) { - return []; - } - - const isFollowing = me ? Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId) : false; - - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); - - timeline = await query.getMany(); - - timeline = timeline.filter(note => { - if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false; - - if (note.renoteId) { - if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { - if (ps.withRenotes === false) return false; + if (note.renoteId) { + if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { + if (ps.withRenotes === false) return false; + } } - } - if (note.visibility === 'followers' && !isFollowing) return false; + if (note.visibility === 'followers' && !isFollowing) return false; - return true; - }); + return true; + }); - timeline.sort((a, b) => a.id > b.id ? -1 : 1); + timeline.sort((a, b) => a.id > b.id ? -1 : 1); + + return await this.noteEntityService.packMany(timeline, me); + } else { + // fallback to database - // fallback to database - if (timeline.length === 0) { //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('note.userId = :userId', { userId: ps.userId }) @@ -217,10 +157,9 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); - query.andWhere(new Brackets(qb => { - qb.orWhere('note.channelId IS NULL'); - qb.orWhere('channel.isSensitive = false'); - })); + if (!ps.withChannelNotes) { + query.andWhere('note.channelId IS NULL'); + } this.queryService.generateVisibilityQuery(query, me); @@ -239,10 +178,10 @@ export default class extends Endpoint { // eslint- } //#endregion - timeline = await query.limit(ps.limit).getMany(); - } + const timeline = await query.limit(ps.limit).getMany(); - return await this.noteEntityService.packMany(timeline, me); + return await this.noteEntityService.packMany(timeline, me); + } }); } } diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index 5a0a09cdce..59f4666198 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -16,7 +16,7 @@ import Channel from '../channel.js'; class GlobalTimelineChannel extends Channel { public readonly chName = 'globalTimeline'; - public static shouldShare = true; + public static shouldShare = false; public static requireCredential = false; private withReplies: boolean; private withFiles: boolean; diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index 0c355b1cd5..3be8a6a520 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -14,7 +14,7 @@ import Channel from '../channel.js'; class HomeTimelineChannel extends Channel { public readonly chName = 'homeTimeline'; - public static shouldShare = true; + public static shouldShare = false; public static requireCredential = true; private withReplies: boolean; private withFiles: boolean; diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 7b0a0d47e8..030b68028d 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -16,7 +16,7 @@ import Channel from '../channel.js'; class HybridTimelineChannel extends Channel { public readonly chName = 'hybridTimeline'; - public static shouldShare = true; + public static shouldShare = false; public static requireCredential = true; private withReplies: boolean; private withFiles: boolean; diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 2c111a31e2..38a35afacd 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -15,7 +15,7 @@ import Channel from '../channel.js'; class LocalTimelineChannel extends Channel { public readonly chName = 'localTimeline'; - public static shouldShare = true; + public static shouldShare = false; public static requireCredential = false; private withReplies: boolean; private withFiles: boolean; diff --git a/packages/backend/test/unit/misc/loader.ts b/packages/backend/test/unit/misc/loader.ts new file mode 100644 index 0000000000..fa37950951 --- /dev/null +++ b/packages/backend/test/unit/misc/loader.ts @@ -0,0 +1,88 @@ +import { DebounceLoader } from '@/misc/loader.js'; + +class Mock { + loadCountByKey = new Map(); + load = async (key: number): Promise => { + const count = this.loadCountByKey.get(key); + if (typeof count === 'undefined') { + this.loadCountByKey.set(key, 1); + } else { + this.loadCountByKey.set(key, count + 1); + } + return key * 2; + }; + reset() { + this.loadCountByKey.clear(); + } +} + +describe(DebounceLoader, () => { + describe('single request', () => { + it('loads once', async () => { + const mock = new Mock(); + const loader = new DebounceLoader(mock.load); + expect(await loader.load(7)).toBe(14); + expect(mock.loadCountByKey.size).toBe(1); + expect(mock.loadCountByKey.get(7)).toBe(1); + }); + }); + + describe('two duplicated requests at same time', () => { + it('loads once', async () => { + const mock = new Mock(); + const loader = new DebounceLoader(mock.load); + const [v1, v2] = await Promise.all([ + loader.load(7), + loader.load(7), + ]); + expect(v1).toBe(14); + expect(v2).toBe(14); + expect(mock.loadCountByKey.size).toBe(1); + expect(mock.loadCountByKey.get(7)).toBe(1); + }); + }); + + describe('two different requests at same time', () => { + it('loads twice', async () => { + const mock = new Mock(); + const loader = new DebounceLoader(mock.load); + const [v1, v2] = await Promise.all([ + loader.load(7), + loader.load(13), + ]); + expect(v1).toBe(14); + expect(v2).toBe(26); + expect(mock.loadCountByKey.size).toBe(2); + expect(mock.loadCountByKey.get(7)).toBe(1); + expect(mock.loadCountByKey.get(13)).toBe(1); + }); + }); + + describe('non-continuous same two requests', () => { + it('loads twice', async () => { + const mock = new Mock(); + const loader = new DebounceLoader(mock.load); + expect(await loader.load(7)).toBe(14); + expect(mock.loadCountByKey.size).toBe(1); + expect(mock.loadCountByKey.get(7)).toBe(1); + mock.reset(); + expect(await loader.load(7)).toBe(14); + expect(mock.loadCountByKey.size).toBe(1); + expect(mock.loadCountByKey.get(7)).toBe(1); + }); + }); + + describe('non-continuous different two requests', () => { + it('loads twice', async () => { + const mock = new Mock(); + const loader = new DebounceLoader(mock.load); + expect(await loader.load(7)).toBe(14); + expect(mock.loadCountByKey.size).toBe(1); + expect(mock.loadCountByKey.get(7)).toBe(1); + mock.reset(); + expect(await loader.load(13)).toBe(26); + expect(mock.loadCountByKey.size).toBe(1); + expect(mock.loadCountByKey.get(13)).toBe(1); + }); + }); +}); diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index 10b2ac9ece..69da1a7466 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -17,7 +17,6 @@ SPDX-License-Identifier: AGPL-3.0-only :title="media.name" controls preload="metadata" - @volumechange="volumechange" /> diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index 751b5f7570..43c64b4c85 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only