diff --git a/cspell.json b/cspell.json index 38290ba4..3f0ddaae 100644 --- a/cspell.json +++ b/cspell.json @@ -65,6 +65,7 @@ "sqltag", "storyshots", "tailwindcss", + "Twicasting", "unfetch", "unmount", "unoptimized", diff --git a/packages/@neet/vschedule-api-spec/package.json b/packages/@neet/vschedule-api-spec/package.json index 09c3f4d1..8d025fb9 100644 --- a/packages/@neet/vschedule-api-spec/package.json +++ b/packages/@neet/vschedule-api-spec/package.json @@ -12,7 +12,7 @@ } }, "devDependencies": { - "@asteasolutions/zod-to-openapi": "^3.0.0", + "@asteasolutions/zod-to-openapi": "^3.1.0", "@neet/eslint-plugin-vschedule": "workspace:^", "@neet/tsconfig-vschedule": "workspace:^", "@types/eslint": "^8.4.10", diff --git a/packages/@neet/vschedule-api-spec/src/components/parameters/PathChannelId.ts b/packages/@neet/vschedule-api-spec/src/components/parameters/PathChannelId.ts new file mode 100644 index 00000000..ddfc2699 --- /dev/null +++ b/packages/@neet/vschedule-api-spec/src/components/parameters/PathChannelId.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +import { registry } from '../../api'; + +export const PathChannelId = registry.registerParameter( + 'PathChannelId', + z.string().openapi({ param: { name: 'channelId', in: 'path' } }), +); diff --git a/packages/@neet/vschedule-api-spec/src/components/schemas/Actor.ts b/packages/@neet/vschedule-api-spec/src/components/schemas/Actor.ts index c2125c5c..ceaa4648 100644 --- a/packages/@neet/vschedule-api-spec/src/components/schemas/Actor.ts +++ b/packages/@neet/vschedule-api-spec/src/components/schemas/Actor.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { registry } from '../../api'; +import { Channel } from './Channel'; import { MediaAttachment } from './MediaAttachment'; export const Actor = registry.register( @@ -14,5 +15,6 @@ export const Actor = registry.register( avatar: MediaAttachment.optional(), twitterUsername: z.string().nullable(), youtubeChannelId: z.string().nullable(), + channels: z.array(Channel), }), ); diff --git a/packages/@neet/vschedule-api-spec/src/components/schemas/Channel.ts b/packages/@neet/vschedule-api-spec/src/components/schemas/Channel.ts new file mode 100644 index 00000000..ca5c637f --- /dev/null +++ b/packages/@neet/vschedule-api-spec/src/components/schemas/Channel.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; + +import { registry } from '../../api'; + +export const BaseChannel = registry.register( + 'BaseChannel', + z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullish(), + }), +); + +export const YoutubeChannel = registry.register( + 'YoutubeChannel', + BaseChannel.extend({ + type: z.literal('youtube'), + url: z.string().url(), + }), +); + +export const TwitchChannel = registry.register( + 'TwitchChannel', + BaseChannel.extend({ + type: z.literal('twitch'), + url: z.string().url(), + }), +); + +export const TwicastingChannel = registry.register( + 'TwicastingChannel', + BaseChannel.extend({ + type: z.literal('twicasting'), + url: z.string().url(), + }), +); + +export const Channel = registry.register( + 'Channel', + z.discriminatedUnion('type', [ + YoutubeChannel, + TwitchChannel, + TwicastingChannel, + ]), +); diff --git a/packages/@neet/vschedule-api-spec/src/components/schemas/Stream.ts b/packages/@neet/vschedule-api-spec/src/components/schemas/Stream.ts index 8022815d..6a9acf7c 100644 --- a/packages/@neet/vschedule-api-spec/src/components/schemas/Stream.ts +++ b/packages/@neet/vschedule-api-spec/src/components/schemas/Stream.ts @@ -12,12 +12,13 @@ export const Stream = registry.register( url: z.string().url(), description: z.string().nullable(), thumbnail: MediaAttachment.optional(), + channelId: z.string(), createdAt: z.date(), updatedAt: z.date(), startedAt: z.date(), endedAt: z.date().nullable(), duration: z.string().nullable(), owner: Performer, - casts: z.array(Performer), + participants: z.array(Performer), }), ); diff --git a/packages/@neet/vschedule-api-spec/src/paths/rest/v1/channels.ts b/packages/@neet/vschedule-api-spec/src/paths/rest/v1/channels.ts new file mode 100644 index 00000000..835e5f25 --- /dev/null +++ b/packages/@neet/vschedule-api-spec/src/paths/rest/v1/channels.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +import { registry } from '../../../api'; +import { PathChannelId } from '../../../components/parameters/PathChannelId'; + +registry.registerPath({ + method: 'post', + path: '/rest/v1/channels/{channelId}/subscribe', + operationId: 'subscribeToChannel', + summary: 'チャンネルを購読', + request: { + params: z.object({ + channelId: PathChannelId, + }), + }, + responses: { + 200: { + description: '成功時のレスポンスです', + }, + }, +}); diff --git a/packages/@neet/vschedule-api-spec/src/paths/rest/v1/performers.ts b/packages/@neet/vschedule-api-spec/src/paths/rest/v1/performers.ts index 91ae70b8..3e280170 100644 --- a/packages/@neet/vschedule-api-spec/src/paths/rest/v1/performers.ts +++ b/packages/@neet/vschedule-api-spec/src/paths/rest/v1/performers.ts @@ -44,23 +44,3 @@ registry.registerPath({ }, }, }); - -registry.registerPath({ - method: 'post', - path: '/rest/v1/performers/{performerId}/subscribe', - operationId: 'subscribeToPerformer', - summary: 'パフォーマーを購読', - request: { - params: z.object({ - performerId: PathPerformerId, - }), - }, - responses: { - 200: { - description: '成功時のレスポンスです', - // content: { - // 'application/json': { schema: Performer }, - // }, - }, - }, -}); diff --git a/packages/@neet/vschedule-api-spec/src/paths/websub/youtube.ts b/packages/@neet/vschedule-api-spec/src/paths/websub/youtube.ts index 01b9e4cc..c5bb7f91 100644 --- a/packages/@neet/vschedule-api-spec/src/paths/websub/youtube.ts +++ b/packages/@neet/vschedule-api-spec/src/paths/websub/youtube.ts @@ -56,7 +56,8 @@ export const YoutubeAtomFeed = z.union([ export const YoutubeWebsubVerification = z.object({ 'hub.topic': z.string().url(), 'hub.challenge': z.string(), - 'hub.mode': z.union([z.literal('subscribe'), z.literal('unsubscribe')]), + // 'hub.mode': z.union([z.literal('subscribe'), z.literal('unsubscribe')]), + 'hub.mode': z.string(), 'hub.lease_seconds': z.number().int(), 'hub.verify_token': z.string(), }); diff --git a/packages/@neet/vschedule-api/package.json b/packages/@neet/vschedule-api/package.json index 0478747b..294c81e1 100644 --- a/packages/@neet/vschedule-api/package.json +++ b/packages/@neet/vschedule-api/package.json @@ -24,7 +24,7 @@ "class-validator": "^0.13.2", "color": "^4.2.3", "cors": "^2.8.5", - "cosmiconfig": "^7.1.0", + "cosmiconfig": "^8.0.0", "cosmiconfig-toml-loader": "^1.0.0", "dayjs": "^1.11.6", "express": "^4.18.2", @@ -39,7 +39,7 @@ "mkdirp": "^1.0.4", "nanoid": "^3.3.4", "node-fetch": "^2.6.7", - "openapi2aspida": "^0.19.0", + "openapi2aspida": "^0.20.0", "passport": "^0.6.0", "passport-local": "^1.0.0", "plaiceholder": "^2.5.0", @@ -58,7 +58,7 @@ "devDependencies": { "@neet/eslint-plugin-vschedule": "workspace:^", "@neet/tsconfig-vschedule": "workspace:^", - "@quramy/jest-prisma-node": "^1.1.2", + "@quramy/jest-prisma-node": "^1.2.0", "@swc/core": "^1.3.19", "@swc/jest": "^0.2.23", "@types/bcryptjs": "^2.4.2", diff --git a/packages/@neet/vschedule-api/prisma/migrations/20221122200952_add_channel/migration.sql b/packages/@neet/vschedule-api/prisma/migrations/20221122200952_add_channel/migration.sql new file mode 100644 index 00000000..8dd226cc --- /dev/null +++ b/packages/@neet/vschedule-api/prisma/migrations/20221122200952_add_channel/migration.sql @@ -0,0 +1,43 @@ +/* + Warnings: + + - Added the required column `channel_id` to the `streams` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE `streams` ADD COLUMN `channel_id` VARCHAR(191) NOT NULL; + +-- CreateTable +CREATE TABLE `channels` ( + `id` VARCHAR(21) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `status` VARCHAR(191) NOT NULL, + `description` TEXT NULL, + `performer_id` VARCHAR(191) NULL, + `organization_id` VARCHAR(191) NULL, + + INDEX `channels_performer_id_organization_id_idx`(`performer_id`, `organization_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `youtube_channels` ( + `id` VARCHAR(21) NOT NULL, + `youtubeChannelId` VARCHAR(191) NOT NULL, + + UNIQUE INDEX `youtube_channels_youtubeChannelId_key`(`youtubeChannelId`), + INDEX `youtube_channels_youtubeChannelId_idx`(`youtubeChannelId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `streams` ADD CONSTRAINT `streams_channel_id_fkey` FOREIGN KEY (`channel_id`) REFERENCES `channels`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `channels` ADD CONSTRAINT `channels_performer_id_fkey` FOREIGN KEY (`performer_id`) REFERENCES `performers`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `channels` ADD CONSTRAINT `channels_organization_id_fkey` FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `youtube_channels` ADD CONSTRAINT `youtube_channels_id_fkey` FOREIGN KEY (`id`) REFERENCES `channels`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/@neet/vschedule-api/prisma/schema.prisma b/packages/@neet/vschedule-api/prisma/schema.prisma index d392f5b4..39cc525b 100644 --- a/packages/@neet/vschedule-api/prisma/schema.prisma +++ b/packages/@neet/vschedule-api/prisma/schema.prisma @@ -41,6 +41,9 @@ model Stream { startedAt DateTime @map("started_at") endedAt DateTime? @map("ended_at") + channelId String @map("channel_id") + channel Channel @relation(fields: [channelId], references: [id]) + ownerId String @map("owner_id") owner Performer @relation(fields: [ownerId], references: [id]) @@ -75,7 +78,8 @@ model Performer { createdAt DateTime @map("created_at") updatedAt DateTime @map("updated_at") - streams Stream[] + streams Stream[] + channels Channel[] @@index([organizationId]) @@index([youtubeChannelId]) @@ -98,14 +102,42 @@ model Organization { performers Performer[] - createdAt DateTime @map("created_at") - updatedAt DateTime @map("updated_at") + createdAt DateTime @map("created_at") + updatedAt DateTime @map("updated_at") + channels Channel[] @@index([youtubeChannelId]) @@index([twitterUsername]) @@map("organizations") } +model Channel { + id String @id @db.VarChar(21) + name String + status String + description String? @db.Text + + performerId String? @map("performer_id") + performer Performer? @relation(fields: [performerId], references: [id]) + organizationId String? @map("organization_id") + organization Organization? @relation(fields: [organizationId], references: [id]) + + youtubeChannel YoutubeChannel? + streams Stream[] + + @@index([performerId, organizationId]) + @@map("channels") +} + +model YoutubeChannel { + id String @id @db.VarChar(21) + youtubeChannelId String @unique + channel Channel @relation(fields: [id], references: [id]) + + @@index([youtubeChannelId]) + @@map("youtube_channels") +} + model Token { id String @id @unique @db.VarChar(64) createdAt DateTime @map("created_at") diff --git a/packages/@neet/vschedule-api/src/adapters/controllers/rest/v1/channels.ts b/packages/@neet/vschedule-api/src/adapters/controllers/rest/v1/channels.ts new file mode 100644 index 00000000..66a6ab6e --- /dev/null +++ b/packages/@neet/vschedule-api/src/adapters/controllers/rest/v1/channels.ts @@ -0,0 +1,19 @@ +import { inject, injectable } from 'inversify'; +import { JsonController, OnUndefined, Param, Post } from 'routing-controllers'; + +import { RequestChannelSubscription } from '../../../../app/channel/request-channel-subscription'; + +@injectable() +@JsonController('/rest/v1/channels') +export class ChannelController { + constructor( + @inject(RequestChannelSubscription) + private readonly requestChannelSubscription: RequestChannelSubscription, + ) {} + + @Post('/:channelId/subscribe') + @OnUndefined(202) + async subscribe(@Param('channelId') channelId: string): Promise { + await this.requestChannelSubscription.invoke({ channelId }); + } +} diff --git a/packages/@neet/vschedule-api/src/adapters/controllers/rest/v1/index.ts b/packages/@neet/vschedule-api/src/adapters/controllers/rest/v1/index.ts index cae9647e..3e71070e 100644 --- a/packages/@neet/vschedule-api/src/adapters/controllers/rest/v1/index.ts +++ b/packages/@neet/vschedule-api/src/adapters/controllers/rest/v1/index.ts @@ -2,6 +2,7 @@ * @file Automatically generated by barrelsby. */ +export * from './channels'; export * from './media'; export * from './organizations'; export * from './performers'; diff --git a/packages/@neet/vschedule-api/src/adapters/controllers/rest/v1/performers.ts b/packages/@neet/vschedule-api/src/adapters/controllers/rest/v1/performers.ts index 274eadd2..ad01435f 100644 --- a/packages/@neet/vschedule-api/src/adapters/controllers/rest/v1/performers.ts +++ b/packages/@neet/vschedule-api/src/adapters/controllers/rest/v1/performers.ts @@ -1,19 +1,7 @@ import { inject, injectable } from 'inversify'; -import { - Authorized, - Get, - JsonController, - OnUndefined, - Param, - Params, - Post, -} from 'routing-controllers'; +import { Get, JsonController, Param, Params } from 'routing-controllers'; -import { - ListPerformers, - ShowPerformer, - SubscribeToPerformer, -} from '../../../../app'; +import { ListPerformers, ShowPerformer } from '../../../../app'; import { Methods } from '../../../generated/rest/v1/performers'; import { RestPresenter } from '../../../mappers'; @@ -27,9 +15,6 @@ export class PerformersController { @inject(ListPerformers) private readonly _listPerformers: ListPerformers, - @inject(SubscribeToPerformer) - private readonly _subscribeToPerformer: SubscribeToPerformer, - @inject(RestPresenter) private readonly _presenter: RestPresenter, ) {} @@ -46,16 +31,6 @@ export class PerformersController { return this._presenter.presentPerformer(performer); } - @Post('/:performerId/subscribe') - @Authorized() - @OnUndefined(202) - public async subscribe(@Param('performerId') performerId: string) { - await this._subscribeToPerformer.invoke({ - performerId, - }); - // return this.statusCode(202); - } - @Get('/') async list( @Params() params: Methods['get']['query'] = {}, diff --git a/packages/@neet/vschedule-api/src/adapters/controllers/websub/youtube.ts b/packages/@neet/vschedule-api/src/adapters/controllers/websub/youtube.ts index 16aeb638..d788f682 100644 --- a/packages/@neet/vschedule-api/src/adapters/controllers/websub/youtube.ts +++ b/packages/@neet/vschedule-api/src/adapters/controllers/websub/youtube.ts @@ -11,11 +11,8 @@ import { UseBefore, } from 'routing-controllers'; -import { - CreateResubscriptionTask, - RemoveStream, - UpsertStream, -} from '../../../app'; +import { RemoveStream, UpsertStream } from '../../../app'; +import { VerifyYoutubeChannelSubscription } from '../../../app/channel'; import { Methods } from '../../generated/websub/youtube'; @injectable() @@ -28,8 +25,8 @@ export class YoutubeWebsubController { @inject(RemoveStream) private readonly _removeStream: RemoveStream, - @inject(CreateResubscriptionTask) - private readonly _createResubscriptionTask: CreateResubscriptionTask, + @inject(VerifyYoutubeChannelSubscription) + private readonly _verifyChannelSubscription: VerifyYoutubeChannelSubscription, ) {} @Get('/') @@ -38,9 +35,8 @@ export class YoutubeWebsubController { @QueryParams() query: Methods['get']['query'], ) { // TODO: Unsubscribeのときのハンドリング - await this._createResubscriptionTask.invoke({ + await this._verifyChannelSubscription.invoke({ topic: query['hub.topic'], - verifyToken: query['hub.verify_token'], leaseSeconds: query['hub.lease_seconds'], }); diff --git a/packages/@neet/vschedule-api/src/adapters/mappers/prisma-dto-mapper.ts b/packages/@neet/vschedule-api/src/adapters/mappers/prisma-dto-mapper.ts index fbe5f2ea..95f8539c 100644 --- a/packages/@neet/vschedule-api/src/adapters/mappers/prisma-dto-mapper.ts +++ b/packages/@neet/vschedule-api/src/adapters/mappers/prisma-dto-mapper.ts @@ -1,14 +1,17 @@ import { + Channel, MediaAttachment, Organization, Performer, Stream, + YoutubeChannel, } from '@prisma/client'; import Color from 'color'; import dayjs from 'dayjs'; import Duration from 'dayjs/plugin/duration'; import { + ChannelDto, MediaAttachmentDto, OrganizationDto, PerformerDto, @@ -36,8 +39,29 @@ export const transferMediaAttachmentFromPrisma = ( }; }; +export const transferChannelFromPrisma = ( + channel: Channel & { youtubeChannel: YoutubeChannel | null }, +): ChannelDto => { + if (channel.youtubeChannel != null) { + return { + type: 'youtube', + id: channel.id, + name: channel.name, + description: channel.description, + youtubeChannelId: channel.youtubeChannel.youtubeChannelId, + }; + } + + throw new Error('inexhaustible'); +}; + export const transferOrganizationFromPrisma = ( - organization: Organization & { avatar: MediaAttachment | null }, + organization: Organization & { + avatar: MediaAttachment | null; + channels: (Channel & { + youtubeChannel: YoutubeChannel | null; + })[]; + }, ): OrganizationDto => { return { id: organization.id, @@ -51,6 +75,9 @@ export const transferOrganizationFromPrisma = ( url: organization.url != null ? new URL(organization.url) : null, twitterUsername: organization.twitterUsername, youtubeChannelId: organization.youtubeChannelId, + channels: organization.channels.map((channel) => + transferChannelFromPrisma(channel), + ), createdAt: dayjs(organization.createdAt), updatedAt: dayjs(organization.updatedAt), }; @@ -62,8 +89,14 @@ export const transferPerformerFromPrisma = ( organization: | (Organization & { avatar: MediaAttachment | null; + channels: (Channel & { + youtubeChannel: YoutubeChannel | null; + })[]; }) | null; + channels: (Channel & { + youtubeChannel: YoutubeChannel | null; + })[]; }, ): PerformerDto => { return { @@ -82,6 +115,9 @@ export const transferPerformerFromPrisma = ( performer.organization != null ? transferOrganizationFromPrisma(performer.organization) : null, + channels: performer.channels.map((channel) => + transferChannelFromPrisma(channel), + ), createdAt: dayjs(performer.createdAt), updatedAt: dayjs(performer.updatedAt), }; @@ -94,8 +130,14 @@ export const transferStreamFromPrisma = ( organization: | (Organization & { avatar: MediaAttachment | null; + channels: (Channel & { + youtubeChannel: YoutubeChannel | null; + })[]; }) | null; + channels: (Channel & { + youtubeChannel: YoutubeChannel | null; + })[]; }; thumbnail: MediaAttachment | null; }, @@ -110,7 +152,8 @@ export const transferStreamFromPrisma = ( ? transferMediaAttachmentFromPrisma(stream.thumbnail) : null, owner: transferPerformerFromPrisma(stream.owner), - casts: [], // TODO + participants: [], // TODO + channelId: stream.channelId, startedAt: dayjs(stream.startedAt), endedAt: stream.endedAt != null ? dayjs(stream.endedAt) : null, diff --git a/packages/@neet/vschedule-api/src/adapters/mappers/prisma-entity-mapper.ts b/packages/@neet/vschedule-api/src/adapters/mappers/prisma-entity-mapper.ts index 657c0289..402b7134 100644 --- a/packages/@neet/vschedule-api/src/adapters/mappers/prisma-entity-mapper.ts +++ b/packages/@neet/vschedule-api/src/adapters/mappers/prisma-entity-mapper.ts @@ -1,17 +1,32 @@ +import assert from 'node:assert'; + import * as Prisma from '@prisma/client'; import Color from 'color'; import dayjs from 'dayjs'; import { + Channel, + ChannelDescription, + ChannelId, + ChannelName, + ChannelStatus, + ChannelYoutube, MediaAttachment, Organization, + OrganizationId, Performer, + PerformerId, Stream, Timestamps, Token, User, + YoutubeChannelId, } from '../../domain'; +const panic = (error: unknown): never => { + throw error; +}; + export const rehydrateMediaAttachmentFromPrisma = ( mediaAttachment: Prisma.MediaAttachment, ): MediaAttachment => { @@ -96,7 +111,8 @@ export const rehydrateStreamFromPrisma = ( updatedAt: dayjs(stream.updatedAt), }), ownerId: stream.ownerId, - castIds: [], // TODO キャスト + participantIds: [], // TODO キャスト + channelId: stream.channelId, description: stream.description, title: stream.title, endedAt: stream.endedAt !== null ? dayjs(stream.endedAt) : null, @@ -126,3 +142,36 @@ export const rehydrateUserFromPrisma = (user: Prisma.User) => { }), }); }; + +export const rehydrateYoutubeChannelFromPrisma = ( + channel: Prisma.Channel & { youtubeChannel: Prisma.YoutubeChannel | null }, +): Channel => { + assert(channel.youtubeChannel != null); + return ChannelYoutube.rehydrate({ + id: new ChannelId(channel.id), + name: new ChannelName(channel.name), + description: + channel.description != null + ? new ChannelDescription(channel.description) + : null, + status: channel.status as ChannelStatus, + ownerId: + channel.performerId != null + ? new PerformerId(channel.performerId) + : channel.organizationId != null + ? new OrganizationId(channel.organizationId) + : panic('unexpected'), + youtubeChannelId: new YoutubeChannelId( + channel.youtubeChannel.youtubeChannelId, + ), + }); +}; + +export const rehydrateChannelFromPrisma = ( + channel: Prisma.Channel & { youtubeChannel: Prisma.YoutubeChannel | null }, +): Channel => { + if (channel.youtubeChannel != null) { + return rehydrateYoutubeChannelFromPrisma(channel); + } + throw new Error('inexhaustible'); +}; diff --git a/packages/@neet/vschedule-api/src/adapters/mappers/rest-presenter.ts b/packages/@neet/vschedule-api/src/adapters/mappers/rest-presenter.ts index a37922ae..36168b56 100644 --- a/packages/@neet/vschedule-api/src/adapters/mappers/rest-presenter.ts +++ b/packages/@neet/vschedule-api/src/adapters/mappers/rest-presenter.ts @@ -3,6 +3,7 @@ import { URL } from 'node:url'; import { inject, injectable } from 'inversify'; import { + ChannelDto, IConfig, MediaAttachmentDto, OrganizationDto, @@ -43,6 +44,19 @@ export class RestPresenter { }; } + public presentChannel(channel: ChannelDto): Rest.Channel { + if (channel.type === 'youtube') { + return { + type: 'youtube', + id: channel.id, + name: channel.name, + description: channel.description, + url: `https://youtube.com/channel/${channel.youtubeChannelId}`, + }; + } + throw new Error('unknown type'); + } + public presentOrganization(organization: OrganizationDto): Rest.Organization { return { id: organization.id, @@ -56,6 +70,9 @@ export class RestPresenter { organization.avatar !== null ? this.presentMediaAttachment(organization.avatar) : undefined, + channels: organization.channels.map((channel) => + this.presentChannel(channel), + ), createdAt: organization.createdAt.toISOString(), updatedAt: organization.updatedAt.toISOString(), }; @@ -76,6 +93,9 @@ export class RestPresenter { : undefined, createdAt: performer.createdAt.toISOString(), updatedAt: performer.updatedAt.toISOString(), + channels: performer.channels.map((channel) => + this.presentChannel(channel), + ), organization: performer.organization !== null ? this.presentOrganization(performer.organization) @@ -95,7 +115,8 @@ export class RestPresenter { endedAt: stream.endedAt === null ? null : stream.endedAt.toISOString(), owner: this.presentPerformer(stream.owner), duration: stream.duration === null ? null : stream.duration.toISOString(), - casts: [], // TODO + participants: [], // TODO + channelId: stream.channelId, thumbnail: stream.thumbnail !== null ? this.presentMediaAttachment(stream.thumbnail) diff --git a/packages/@neet/vschedule-api/src/adapters/query-services/organization-query-service-prisma.ts b/packages/@neet/vschedule-api/src/adapters/query-services/organization-query-service-prisma.ts index 47830c71..95747afb 100644 --- a/packages/@neet/vschedule-api/src/adapters/query-services/organization-query-service-prisma.ts +++ b/packages/@neet/vschedule-api/src/adapters/query-services/organization-query-service-prisma.ts @@ -12,6 +12,11 @@ import { transferOrganizationFromPrisma } from '../mappers'; const DEFAULT_INCLUDE = Object.freeze({ avatar: true, + channels: { + include: { + youtubeChannel: true, + }, + }, }); @injectable() diff --git a/packages/@neet/vschedule-api/src/adapters/query-services/performer-query-service-prisma.ts b/packages/@neet/vschedule-api/src/adapters/query-services/performer-query-service-prisma.ts index 6a9d77ef..edb3b124 100644 --- a/packages/@neet/vschedule-api/src/adapters/query-services/performer-query-service-prisma.ts +++ b/packages/@neet/vschedule-api/src/adapters/query-services/performer-query-service-prisma.ts @@ -10,14 +10,24 @@ import { PerformerId, YoutubeChannelId } from '../../domain'; import { TYPES } from '../../types'; import { transferPerformerFromPrisma } from '../mappers'; -const DEFAULT_INCLUDE = { +const DEFAULT_INCLUDE = Object.freeze({ avatar: true, + channels: { + include: { + youtubeChannel: true, + }, + }, organization: { include: { avatar: true, + channels: { + include: { + youtubeChannel: true, + }, + }, }, }, -}; +}); @injectable() export class PerformerQueryServicePrisma implements IPerformerQueryService { diff --git a/packages/@neet/vschedule-api/src/adapters/query-services/stream-query-service-prisma.ts b/packages/@neet/vschedule-api/src/adapters/query-services/stream-query-service-prisma.ts index 37dddad2..138db1ef 100644 --- a/packages/@neet/vschedule-api/src/adapters/query-services/stream-query-service-prisma.ts +++ b/packages/@neet/vschedule-api/src/adapters/query-services/stream-query-service-prisma.ts @@ -16,9 +16,19 @@ const SHARED_INCLUDE = { owner: { include: { avatar: true, + channels: { + include: { + youtubeChannel: true, + }, + }, organization: { include: { avatar: true, + channels: { + include: { + youtubeChannel: true, + }, + }, }, }, }, diff --git a/packages/@neet/vschedule-api/src/adapters/repositories/channel-repository-prisma.ts b/packages/@neet/vschedule-api/src/adapters/repositories/channel-repository-prisma.ts new file mode 100644 index 00000000..0288ccaf --- /dev/null +++ b/packages/@neet/vschedule-api/src/adapters/repositories/channel-repository-prisma.ts @@ -0,0 +1,141 @@ +import { PrismaClient } from '@prisma/client'; +import { inject, injectable } from 'inversify'; + +import { IChannelRepository } from '../../app'; +import { + Channel, + ChannelId, + ChannelYoutube, + OrganizationId, + PerformerId, + YoutubeChannelId, +} from '../../domain'; +import { TYPES } from '../../types'; +import { + rehydrateChannelFromPrisma, + rehydrateYoutubeChannelFromPrisma, +} from '../mappers'; + +@injectable() +export class ChannelRepositoryPrisma implements IChannelRepository { + public constructor( + @inject(TYPES.PrismaClient) + private readonly _prisma: PrismaClient, + ) {} + + async create(channel: Channel): Promise { + if (channel instanceof ChannelYoutube) { + await this._prisma.channel.create({ + data: { + id: channel.id.value, + name: channel.name.value, + description: channel.description?.value ?? null, + status: channel.status, + performerId: + channel.ownerId instanceof PerformerId + ? channel.ownerId.value + : null, + organizationId: + channel.ownerId instanceof OrganizationId + ? channel.ownerId.value + : null, + youtubeChannel: { + create: { + youtubeChannelId: channel.youtubeChannelId.value, + }, + }, + }, + }); + return; + } + + throw new Error('Inexhaustible error'); + } + + async update(channel: Channel): Promise { + if (channel instanceof ChannelYoutube) { + await this._prisma.channel.update({ + where: { + id: channel.id.value, + }, + data: { + name: channel.name.value, + description: channel.description?.value ?? null, + status: channel.status, + performerId: + channel.ownerId instanceof PerformerId + ? channel.ownerId.value + : null, + organizationId: + channel.ownerId instanceof OrganizationId + ? channel.ownerId.value + : null, + youtubeChannel: { + update: { + youtubeChannelId: channel.youtubeChannelId.value, + }, + }, + }, + }); + return; + } + + throw new Error('Inexhaustible error'); + } + + async findById(channelId: ChannelId): Promise { + const channel = await this._prisma.channel.findFirst({ + where: { + id: channelId.value, + }, + include: { + youtubeChannel: true, + }, + }); + + if (channel == null) { + return null; + } + if (channel.youtubeChannel != null) { + return rehydrateYoutubeChannelFromPrisma(channel); + } + + throw new Error('unexpected'); + } + + async findByOwnerId( + ownerId: PerformerId | OrganizationId, + ): Promise { + if (ownerId instanceof PerformerId) { + const data = await this._prisma.channel.findMany({ + where: { + performerId: ownerId.value, + }, + include: { + youtubeChannel: true, + }, + }); + return data.map((v) => rehydrateChannelFromPrisma(v)); + } + throw new Error('unexpected'); + } + + async findByYoutubeChannelId( + youtubeChannelId: YoutubeChannelId, + ): Promise { + const data = await this._prisma.channel.findFirst({ + where: { + youtubeChannel: { + youtubeChannelId: youtubeChannelId.value, + }, + }, + include: { + youtubeChannel: true, + }, + }); + if (data == null) { + return null; + } + return rehydrateChannelFromPrisma(data); + } +} diff --git a/packages/@neet/vschedule-api/src/adapters/repositories/index.ts b/packages/@neet/vschedule-api/src/adapters/repositories/index.ts index ed9ec7e9..81906b4c 100644 --- a/packages/@neet/vschedule-api/src/adapters/repositories/index.ts +++ b/packages/@neet/vschedule-api/src/adapters/repositories/index.ts @@ -2,6 +2,7 @@ * @file Automatically generated by barrelsby. */ +export * from './channel-repository-prisma'; export * from './media-attachment-repository-prisma'; export * from './organization-repository-prisma'; export * from './performer-repository-prisma'; diff --git a/packages/@neet/vschedule-api/src/adapters/repositories/media-attachment-repository-prisma.ts b/packages/@neet/vschedule-api/src/adapters/repositories/media-attachment-repository-prisma.ts index f5dbf569..0ad033f5 100644 --- a/packages/@neet/vschedule-api/src/adapters/repositories/media-attachment-repository-prisma.ts +++ b/packages/@neet/vschedule-api/src/adapters/repositories/media-attachment-repository-prisma.ts @@ -2,9 +2,8 @@ import { PrismaClient } from '@prisma/client'; import { inject, injectable } from 'inversify'; import { getPlaiceholder } from 'plaiceholder'; -import { IStorage } from '../../app'; +import { IMediaAttachmentRepository, IStorage } from '../../app'; import { - IMediaAttachmentRepository, MediaAttachment, MediaAttachmentFilename, MediaAttachmentId, @@ -54,6 +53,20 @@ export class MediaAttachmentRepositoryPrisma return rehydrateMediaAttachmentFromPrisma(data); } + async findByRemoteUrl(url: URL): Promise { + const data = await this._prisma.mediaAttachment.findFirst({ + where: { + remoteUrl: url.toString(), + }, + }); + + if (data == null) { + return; + } + + return rehydrateMediaAttachmentFromPrisma(data); + } + async save( filename: MediaAttachmentFilename, buffer: Buffer, diff --git a/packages/@neet/vschedule-api/src/adapters/repositories/organization-repository-prisma.ts b/packages/@neet/vschedule-api/src/adapters/repositories/organization-repository-prisma.ts index 69b92d67..9d57b333 100644 --- a/packages/@neet/vschedule-api/src/adapters/repositories/organization-repository-prisma.ts +++ b/packages/@neet/vschedule-api/src/adapters/repositories/organization-repository-prisma.ts @@ -1,9 +1,8 @@ import { Prisma, PrismaClient } from '@prisma/client'; import { inject, injectable } from 'inversify'; +import { FindOrganizationParams, IOrganizationRepository } from '../../app'; import { - FindOrganizationParams, - IOrganizationRepository, Organization, OrganizationId, PerformerId, diff --git a/packages/@neet/vschedule-api/src/adapters/repositories/performer-repository-prisma.ts b/packages/@neet/vschedule-api/src/adapters/repositories/performer-repository-prisma.ts index ca68a35f..4144b7a4 100644 --- a/packages/@neet/vschedule-api/src/adapters/repositories/performer-repository-prisma.ts +++ b/packages/@neet/vschedule-api/src/adapters/repositories/performer-repository-prisma.ts @@ -1,14 +1,8 @@ import { Prisma, PrismaClient } from '@prisma/client'; import { inject, injectable } from 'inversify'; -import { - FindPerformerParams, - IPerformerRepository, - Performer, - PerformerId, - unwrap, - YoutubeChannelId, -} from '../../domain'; +import { FindPerformerParams, IPerformerRepository } from '../../app'; +import { Performer, PerformerId, unwrap, YoutubeChannelId } from '../../domain'; import { TYPES } from '../../types'; import { rehydratePerformerFromPrisma } from '../mappers'; diff --git a/packages/@neet/vschedule-api/src/adapters/repositories/repositories.ts b/packages/@neet/vschedule-api/src/adapters/repositories/repositories.ts index 69bb6ff5..f66d1586 100644 --- a/packages/@neet/vschedule-api/src/adapters/repositories/repositories.ts +++ b/packages/@neet/vschedule-api/src/adapters/repositories/repositories.ts @@ -1,6 +1,7 @@ import { ContainerModule } from 'inversify'; import { + IChannelRepository, IMediaAttachmentRepository, IOrganizationRepository, IPerformerRepository, @@ -8,8 +9,9 @@ import { IStreamRepository, ITokenRepository, IUserRepository, -} from '../../domain'; +} from '../../app'; import { TYPES } from '../../types'; +import { ChannelRepositoryPrisma } from './channel-repository-prisma'; import { MediaAttachmentRepositoryPrisma } from './media-attachment-repository-prisma'; import { OrganizationRepositoryPrisma } from './organization-repository-prisma'; import { PerformerRepositoryPrisma } from './performer-repository-prisma'; @@ -23,47 +25,38 @@ const prisma = new ContainerModule((bind) => { bind(TYPES.PerformerRepository).to( PerformerRepositoryPrisma, ); - bind(TYPES.OrganizationRepository).to( OrganizationRepositoryPrisma, ); - bind(TYPES.StreamRepository).to(StreamRepositoryPrisma); - bind(TYPES.MediaAttachmentRepository).to( MediaAttachmentRepositoryPrisma, ); - bind(TYPES.ResubscriptionTaskRepository).to( ResubscriptionTaskRepositoryCloudTasks, ); - bind(TYPES.UserRepository).to(UserRepositoryPrisma); - bind(TYPES.TokenRepository).to(TokenRepositoryPrisma); + bind(TYPES.ChannelRepository).to(ChannelRepositoryPrisma); }); const withoutGcp = new ContainerModule((bind) => { bind(TYPES.PerformerRepository).to( PerformerRepositoryPrisma, ); - bind(TYPES.OrganizationRepository).to( OrganizationRepositoryPrisma, ); - bind(TYPES.StreamRepository).to(StreamRepositoryPrisma); - bind(TYPES.MediaAttachmentRepository).to( MediaAttachmentRepositoryPrisma, ); bind(TYPES.UserRepository).to(UserRepositoryPrisma); - bind(TYPES.TokenRepository).to(TokenRepositoryPrisma); - bind(TYPES.ResubscriptionTaskRepository) .to(ResubscriptionTaskRepositoryInMemory) .inSingletonScope(); + bind(TYPES.ChannelRepository).to(ChannelRepositoryPrisma); }); export const repositories = { prisma, withoutGcp }; diff --git a/packages/@neet/vschedule-api/src/adapters/repositories/resubscription-task-repository-cloud-tasks.ts b/packages/@neet/vschedule-api/src/adapters/repositories/resubscription-task-repository-cloud-tasks.ts index 2d2f38ba..674f8316 100644 --- a/packages/@neet/vschedule-api/src/adapters/repositories/resubscription-task-repository-cloud-tasks.ts +++ b/packages/@neet/vschedule-api/src/adapters/repositories/resubscription-task-repository-cloud-tasks.ts @@ -2,11 +2,13 @@ import { CloudTasksClient } from '@google-cloud/tasks'; import { google } from '@google-cloud/tasks/build/protos/protos'; import { inject, injectable } from 'inversify'; -import { IConfig, ILogger, utils } from '../../app'; import { + IConfig, + ILogger, IResubscriptionTaskRepository, - ResubscriptionTask, -} from '../../domain'; + utils, +} from '../../app'; +import { ResubscriptionTask } from '../../domain'; import { TYPES } from '../../types'; @injectable() @@ -26,7 +28,7 @@ export class ResubscriptionTaskRepositoryCloudTasks async create(task: ResubscriptionTask): Promise { const url = utils.resolvePath( this._config, - `/rest/v1/performers/${task.performerId.value}/subscribe`, + `/rest/v1/performers/${task.channelId.value}/subscribe`, ); await this._tasks.createTask({ diff --git a/packages/@neet/vschedule-api/src/adapters/repositories/resubscription-task-repository-in-memory.ts b/packages/@neet/vschedule-api/src/adapters/repositories/resubscription-task-repository-in-memory.ts index 70aeeec1..cb99d44a 100644 --- a/packages/@neet/vschedule-api/src/adapters/repositories/resubscription-task-repository-in-memory.ts +++ b/packages/@neet/vschedule-api/src/adapters/repositories/resubscription-task-repository-in-memory.ts @@ -1,9 +1,7 @@ import { injectable } from 'inversify'; -import { - IResubscriptionTaskRepository, - ResubscriptionTask, -} from '../../domain'; +import { IResubscriptionTaskRepository } from '../../app'; +import { ResubscriptionTask } from '../../domain'; @injectable() export class ResubscriptionTaskRepositoryInMemory diff --git a/packages/@neet/vschedule-api/src/adapters/repositories/stream-repository-prisma.ts b/packages/@neet/vschedule-api/src/adapters/repositories/stream-repository-prisma.ts index 74254869..4f02a3d4 100644 --- a/packages/@neet/vschedule-api/src/adapters/repositories/stream-repository-prisma.ts +++ b/packages/@neet/vschedule-api/src/adapters/repositories/stream-repository-prisma.ts @@ -2,13 +2,8 @@ import { PrismaClient } from '@prisma/client'; import { inject, injectable } from 'inversify'; import { URL } from 'url'; -import { - IStreamRepository, - ListStreamsParams, - Stream, - StreamId, - unwrap, -} from '../../domain'; +import { IStreamRepository, ListStreamsParams } from '../../app'; +import { Stream, StreamId, unwrap } from '../../domain'; import { TYPES } from '../../types'; import { rehydrateStreamFromPrisma } from '../mappers'; @@ -73,6 +68,7 @@ export class StreamRepositoryPrisma implements IStreamRepository { endedAt: stream.endedAt === null ? null : stream.endedAt.toDate(), thumbnailId: stream.thumbnail === null ? null : stream.thumbnail.id.value, + channelId: stream.channelId.value, ownerId: stream.ownerId.value, }, update: { diff --git a/packages/@neet/vschedule-api/src/adapters/repositories/token-repository-prisma.ts b/packages/@neet/vschedule-api/src/adapters/repositories/token-repository-prisma.ts index ee7c35b5..ba965e8f 100644 --- a/packages/@neet/vschedule-api/src/adapters/repositories/token-repository-prisma.ts +++ b/packages/@neet/vschedule-api/src/adapters/repositories/token-repository-prisma.ts @@ -1,7 +1,8 @@ import { PrismaClient } from '@prisma/client'; import { inject, injectable } from 'inversify'; -import { ITokenRepository, Token, TokenId } from '../../domain'; +import { ITokenRepository } from '../../app'; +import { Token, TokenId } from '../../domain'; import { TYPES } from '../../types'; import { rehydrateTokenFromPrisma } from '../mappers'; diff --git a/packages/@neet/vschedule-api/src/adapters/repositories/user-repository-prisma.ts b/packages/@neet/vschedule-api/src/adapters/repositories/user-repository-prisma.ts index e6a2e6d6..dc869973 100644 --- a/packages/@neet/vschedule-api/src/adapters/repositories/user-repository-prisma.ts +++ b/packages/@neet/vschedule-api/src/adapters/repositories/user-repository-prisma.ts @@ -1,7 +1,8 @@ import { PrismaClient } from '@prisma/client'; import { inject, injectable } from 'inversify'; -import { IUserRepository, User, UserEmail, UserId } from '../../domain'; +import { IUserRepository } from '../../app'; +import { User, UserEmail, UserId } from '../../domain'; import { TYPES } from '../../types'; import { rehydrateUserFromPrisma } from '../mappers'; diff --git a/packages/@neet/vschedule-api/src/app/_shared/config/config-base.ts b/packages/@neet/vschedule-api/src/app/_shared/config/config-base.ts index c24bacb3..5e712e5e 100644 --- a/packages/@neet/vschedule-api/src/app/_shared/config/config-base.ts +++ b/packages/@neet/vschedule-api/src/app/_shared/config/config-base.ts @@ -27,7 +27,6 @@ abstract class $ConfigBase { youtube: { dataApiKey: input.youtube.dataApiKey, websubHmacSecret: input.youtube.websubHmacSecret ?? 'secret', - websubVerifyToken: input.youtube.websubVerifyToken ?? 'token', }, server: { port: input.server?.port ?? 3000, diff --git a/packages/@neet/vschedule-api/src/app/_shared/config/config.ts b/packages/@neet/vschedule-api/src/app/_shared/config/config.ts index b2630121..9c64c87b 100644 --- a/packages/@neet/vschedule-api/src/app/_shared/config/config.ts +++ b/packages/@neet/vschedule-api/src/app/_shared/config/config.ts @@ -4,7 +4,6 @@ export const configSchema = z.object({ youtube: z.object({ dataApiKey: z.string(), websubHmacSecret: z.string(), - websubVerifyToken: z.string(), }), storage: z.object({ type: z.union([z.literal('cloud-storage'), z.literal('filesystem')]), diff --git a/packages/@neet/vschedule-api/src/app/channel/channel-repository.ts b/packages/@neet/vschedule-api/src/app/channel/channel-repository.ts new file mode 100644 index 00000000..7bd4dfee --- /dev/null +++ b/packages/@neet/vschedule-api/src/app/channel/channel-repository.ts @@ -0,0 +1,18 @@ +import { + Channel, + ChannelId, + ChannelYoutube, + OrganizationId, + PerformerId, + YoutubeChannelId, +} from '../../domain'; + +export interface IChannelRepository { + create(channel: Channel): Promise; + update(channel: Channel): Promise; + findById(channelId: ChannelId): Promise; + findByOwnerId(ownerId: PerformerId | OrganizationId): Promise; + findByYoutubeChannelId( + youtubeChannelId: YoutubeChannelId, + ): Promise; +} diff --git a/packages/@neet/vschedule-api/src/app/channel/index.ts b/packages/@neet/vschedule-api/src/app/channel/index.ts new file mode 100644 index 00000000..4e6adb89 --- /dev/null +++ b/packages/@neet/vschedule-api/src/app/channel/index.ts @@ -0,0 +1,7 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './channel-repository'; +export * from './request-channel-subscription'; +export * from './verify-youtube-channel-subscription'; diff --git a/packages/@neet/vschedule-api/src/app/channel/request-channel-subscription.ts b/packages/@neet/vschedule-api/src/app/channel/request-channel-subscription.ts new file mode 100644 index 00000000..e8894bbe --- /dev/null +++ b/packages/@neet/vschedule-api/src/app/channel/request-channel-subscription.ts @@ -0,0 +1,51 @@ +import { inject, injectable } from 'inversify'; + +import { ChannelId } from '../../domain'; +import { TYPES } from '../../types'; +import { IYoutubeWebsubService } from '../_external'; +import { AppError } from '../_shared'; +import { IChannelRepository } from './channel-repository'; + +export class RequestChannelSubscriptionNotFoundError extends AppError { + readonly name = 'RequestChannelSubscriptionNotFoundError'; + + constructor(readonly id: ChannelId) { + super(`Channel with id ${id} was not found`); + } +} + +export type RequestChannelSubscriptionCommand = { + readonly channelId: string; +}; + +@injectable() +export class RequestChannelSubscription { + constructor( + @inject(TYPES.ChannelRepository) + private readonly _channelRepository: IChannelRepository, + + @inject(TYPES.YoutubeWebsubService) + private readonly _youtubeWebsubService: IYoutubeWebsubService, + ) {} + + async invoke(params: RequestChannelSubscriptionCommand): Promise { + const channelId = new ChannelId(params.channelId); + const channel = await this._channelRepository.findById(channelId); + if (channel == null) { + throw new RequestChannelSubscriptionNotFoundError(channelId); + } + + try { + const updatedChannel = channel.requestSubscription(); + await this._channelRepository.update(updatedChannel); + + // FIXME リポジトリでやるのが正解な気がする + await this._youtubeWebsubService.subscribeToChannel( + updatedChannel.youtubeChannelId.value, + ); + } catch (error) { + const updatedChannel = channel.resetSubscription(); + await this._channelRepository.update(updatedChannel); + } + } +} diff --git a/packages/@neet/vschedule-api/src/app/channel/verify-youtube-channel-subscription.ts b/packages/@neet/vschedule-api/src/app/channel/verify-youtube-channel-subscription.ts new file mode 100644 index 00000000..8452c8ed --- /dev/null +++ b/packages/@neet/vschedule-api/src/app/channel/verify-youtube-channel-subscription.ts @@ -0,0 +1,91 @@ +import dayjs from 'dayjs'; +import { inject, injectable } from 'inversify'; + +import { ChannelService, YoutubeChannelId } from '../../domain'; +import { TYPES } from '../../types'; +import { AppError, ILogger } from '../_shared'; +import { IResubscriptionTaskRepository } from '../resubscription-task'; +import { ITokenRepository } from '../token'; +import { IChannelRepository } from './channel-repository'; + +export class VerifyYoutubeChannelSubscriptionInvalidTopicError extends AppError { + public readonly name = 'VerifyYoutubeChannelSubscriptionInvalidTopicError'; + + public constructor(public readonly topic: string) { + super(`Invalid topic ${topic}`); + } +} + +export class VerifyYoutubeChannelSubscriptionUnknownChannelError extends AppError { + public readonly name = 'VerifyYoutubeChannelSubscriptionUnknownChannelError'; + + public constructor(public readonly youtubeChannelId: YoutubeChannelId) { + super( + `You claimed that you verified ${youtubeChannelId} but it is not requested from our end`, + ); + } +} + +export type VerifyYoutubeChannelSubscriptionCommand = { + readonly topic: string; + readonly leaseSeconds: number; +}; + +@injectable() +export class VerifyYoutubeChannelSubscription { + public constructor( + @inject(ChannelService) + private readonly _channelService: ChannelService, + + @inject(TYPES.ChannelRepository) + private readonly _channelRepository: IChannelRepository, + + @inject(TYPES.TokenRepository) + private readonly _tokenRepository: ITokenRepository, + + @inject(TYPES.ResubscriptionTaskRepository) + private readonly _resubscriptionTaskRepository: IResubscriptionTaskRepository, + + @inject(TYPES.Logger) + private readonly _logger: ILogger, + ) {} + + public async invoke( + command: VerifyYoutubeChannelSubscriptionCommand, + ): Promise { + const topic = new URL(command.topic); + const youtubeChannelIdStr = topic.searchParams.get('channel_id'); + if (youtubeChannelIdStr == null) { + throw new VerifyYoutubeChannelSubscriptionInvalidTopicError( + command.topic, + ); + } + + const youtubeChannelId = new YoutubeChannelId(youtubeChannelIdStr); + const channel = await this._channelRepository.findByYoutubeChannelId( + youtubeChannelId, + ); + if (channel == null) { + throw new VerifyYoutubeChannelSubscriptionUnknownChannelError( + youtubeChannelId, + ); + } + + const { + channel: updatedChannel, + token, + resubscriptionTask, + } = this._channelService.verifySubscription( + channel, + dayjs().add(command.leaseSeconds, 'seconds'), + ); + + await this._channelRepository.update(updatedChannel); + await this._tokenRepository.create(token); + await this._resubscriptionTaskRepository.create(resubscriptionTask); + + this._logger.info( + `Scheduled resubscription for ${topic} in ${command.leaseSeconds} secs`, + ); + } +} diff --git a/packages/@neet/vschedule-api/src/app/dto.ts b/packages/@neet/vschedule-api/src/app/dto.ts index a80f7576..1d9819aa 100644 --- a/packages/@neet/vschedule-api/src/app/dto.ts +++ b/packages/@neet/vschedule-api/src/app/dto.ts @@ -14,6 +14,23 @@ export type MediaAttachmentDto = { readonly updatedAt: Dayjs; }; +export type BaseChannelDto = { + readonly type: T; + readonly id: string; + readonly name: string; + readonly description: string | null; +}; + +export type YoutubeChannelDto = BaseChannelDto<'youtube'> & { + readonly youtubeChannelId: string; +}; + +export type TwicastingChannelDto = BaseChannelDto<'twicasting'> & { + readonly twicastingScreenName: string; +}; + +export type ChannelDto = YoutubeChannelDto | TwicastingChannelDto; + export type OrganizationDto = { readonly id: string; readonly name: string; @@ -21,6 +38,7 @@ export type OrganizationDto = { readonly description: string | null; readonly avatar: MediaAttachmentDto | null; readonly url: URL | null; + readonly channels: ChannelDto[]; readonly twitterUsername: string | null; readonly youtubeChannelId: string | null; readonly createdAt: Dayjs; @@ -34,6 +52,7 @@ export type PerformerDto = { readonly description: string | null; readonly avatar: MediaAttachmentDto | null; readonly url: URL | null; + readonly channels: ChannelDto[]; readonly twitterUsername: string | null; readonly youtubeChannelId: string | null; readonly organization: OrganizationDto | null; @@ -48,7 +67,8 @@ export type StreamDto = { readonly url: URL; readonly thumbnail: MediaAttachmentDto | null; readonly owner: PerformerDto; - readonly casts: readonly PerformerDto[]; + readonly channelId: string; + readonly participants: readonly PerformerDto[]; readonly startedAt: Dayjs; readonly endedAt: Dayjs | null; readonly createdAt: Dayjs; diff --git a/packages/@neet/vschedule-api/src/app/factories.ts b/packages/@neet/vschedule-api/src/app/factories.ts index 32386b38..0f02955e 100644 --- a/packages/@neet/vschedule-api/src/app/factories.ts +++ b/packages/@neet/vschedule-api/src/app/factories.ts @@ -1,17 +1,11 @@ import { ContainerModule } from 'inversify'; -import { - IOrganizationFactory, - IPerformerFactory, - IStreamFactory, -} from '../domain'; +import { IOrganizationFactory, IPerformerFactory } from '../domain'; import { TYPES } from '../types'; import { OrganizationFactory } from './organization'; import { PerformerFactoryImpl } from './performer'; -import { StreamFactoryImpl } from './stream'; export const factories = new ContainerModule((bind) => { - bind(TYPES.StreamFactory).to(StreamFactoryImpl); bind(TYPES.PerformerFactory).to(PerformerFactoryImpl); bind(TYPES.OrganizationFactory).to(OrganizationFactory); }); diff --git a/packages/@neet/vschedule-api/src/app/index.ts b/packages/@neet/vschedule-api/src/app/index.ts index a25b0f87..bf2759b6 100644 --- a/packages/@neet/vschedule-api/src/app/index.ts +++ b/packages/@neet/vschedule-api/src/app/index.ts @@ -4,11 +4,13 @@ export * from './_external/index'; export * from './_shared/index'; +export * from './channel/index'; export * from './dto'; export * from './factories'; export * from './media-attachment/index'; export * from './organization/index'; export * from './performer/index'; +export * from './resubscription-task/index'; export * from './stream/index'; export * from './token/index'; export * from './user/index'; diff --git a/packages/@neet/vschedule-api/src/app/media-attachment/index.ts b/packages/@neet/vschedule-api/src/app/media-attachment/index.ts index fdfebaeb..5f9a77ee 100644 --- a/packages/@neet/vschedule-api/src/app/media-attachment/index.ts +++ b/packages/@neet/vschedule-api/src/app/media-attachment/index.ts @@ -2,4 +2,5 @@ * @file Automatically generated by barrelsby. */ +export * from './media-attachment-repository'; export * from './show-media-attachment'; diff --git a/packages/@neet/vschedule-api/src/domain/entities/media-attachment/media-attachment-repository.ts b/packages/@neet/vschedule-api/src/app/media-attachment/media-attachment-repository.ts similarity index 67% rename from packages/@neet/vschedule-api/src/domain/entities/media-attachment/media-attachment-repository.ts rename to packages/@neet/vschedule-api/src/app/media-attachment/media-attachment-repository.ts index 5caa50cf..20a36a9b 100644 --- a/packages/@neet/vschedule-api/src/domain/entities/media-attachment/media-attachment-repository.ts +++ b/packages/@neet/vschedule-api/src/app/media-attachment/media-attachment-repository.ts @@ -1,18 +1,20 @@ -import { MediaAttachment } from './media-attachment'; -import { MediaAttachmentFilename } from './media-attachment-filename'; -import { MediaAttachmentId } from './media-attachment-id'; +import { + MediaAttachment, + MediaAttachmentFilename, + MediaAttachmentId, +} from '../../domain'; export interface IMediaAttachmentRepository { findById(id: MediaAttachmentId): Promise; findByFilename( filename: MediaAttachmentFilename, ): Promise; + findByRemoteUrl(url: URL): Promise; save( filename: MediaAttachmentFilename, buffer: Buffer, remoteUrl?: URL, ): Promise; - delete(id: MediaAttachmentId): Promise; } diff --git a/packages/@neet/vschedule-api/src/app/media-attachment/show-media-attachment.ts b/packages/@neet/vschedule-api/src/app/media-attachment/show-media-attachment.ts index 708de478..7caf3344 100644 --- a/packages/@neet/vschedule-api/src/app/media-attachment/show-media-attachment.ts +++ b/packages/@neet/vschedule-api/src/app/media-attachment/show-media-attachment.ts @@ -1,12 +1,9 @@ import { inject, injectable } from 'inversify'; -import { - IMediaAttachmentRepository, - MediaAttachment, - MediaAttachmentFilename, -} from '../../domain'; +import { MediaAttachment, MediaAttachmentFilename } from '../../domain'; import { TYPES } from '../../types'; import { AppError } from '../_shared'; +import { IMediaAttachmentRepository } from './media-attachment-repository'; export class ShowMediaAttachmentNotFoundError extends AppError { public readonly name = 'ShowMediaAttachmentNotFoundError'; diff --git a/packages/@neet/vschedule-api/src/app/organization/index.ts b/packages/@neet/vschedule-api/src/app/organization/index.ts index fdd8c7d9..d81fe7b2 100644 --- a/packages/@neet/vschedule-api/src/app/organization/index.ts +++ b/packages/@neet/vschedule-api/src/app/organization/index.ts @@ -5,5 +5,6 @@ export * from './list-organizations'; export * from './organization-factory-impl'; export * from './organization-query-service'; +export * from './organization-repository'; export * from './show-organization'; export * from './upsert-organization'; diff --git a/packages/@neet/vschedule-api/src/app/organization/organization-factory-impl.ts b/packages/@neet/vschedule-api/src/app/organization/organization-factory-impl.ts index 1a79e450..aadcdcb8 100644 --- a/packages/@neet/vschedule-api/src/app/organization/organization-factory-impl.ts +++ b/packages/@neet/vschedule-api/src/app/organization/organization-factory-impl.ts @@ -5,7 +5,6 @@ import fetch from 'node-fetch'; import sharp from 'sharp'; import { - IMediaAttachmentRepository, IOrganizationFactory, MediaAttachmentFilename, Organization, @@ -15,6 +14,7 @@ import { import { TYPES } from '../../types'; import { IYoutubeApiService } from '../_external'; import { AppError, ILogger, UnexpectedError } from '../_shared'; +import { IMediaAttachmentRepository } from '../media-attachment'; export class OrganizationFactoryChannelNotFoundError extends AppError { public readonly name = 'OrganizationFactoryChannelNotFoundError'; diff --git a/packages/@neet/vschedule-api/src/domain/entities/organization/organization-repository.ts b/packages/@neet/vschedule-api/src/app/organization/organization-repository.ts similarity index 68% rename from packages/@neet/vschedule-api/src/domain/entities/organization/organization-repository.ts rename to packages/@neet/vschedule-api/src/app/organization/organization-repository.ts index 5e62de54..8b56729c 100644 --- a/packages/@neet/vschedule-api/src/domain/entities/organization/organization-repository.ts +++ b/packages/@neet/vschedule-api/src/app/organization/organization-repository.ts @@ -1,12 +1,14 @@ -import { YoutubeChannelId } from '../_shared'; -import { PerformerId } from '../performer'; -import { Organization } from './organization'; -import { OrganizationId } from './organization-id'; +import { + Organization, + OrganizationId, + PerformerId, + YoutubeChannelId, +} from '../../domain'; -export interface FindOrganizationParams { +export type FindOrganizationParams = { readonly limit?: number; readonly offset?: number; -} +}; export interface IOrganizationRepository { create(organization: Organization): Promise; diff --git a/packages/@neet/vschedule-api/src/app/organization/upsert-organization.ts b/packages/@neet/vschedule-api/src/app/organization/upsert-organization.ts index 7a12d2d1..ab09811c 100644 --- a/packages/@neet/vschedule-api/src/app/organization/upsert-organization.ts +++ b/packages/@neet/vschedule-api/src/app/organization/upsert-organization.ts @@ -3,7 +3,6 @@ import { inject, injectable } from 'inversify'; import { IOrganizationFactory, - IOrganizationRepository, Organization, OrganizationDescription, OrganizationName, @@ -12,6 +11,7 @@ import { } from '../../domain'; import { TYPES } from '../../types'; import { ILogger } from '../_shared'; +import { IOrganizationRepository } from './organization-repository'; export type UpsertOrganizationCommand = { readonly name: string; diff --git a/packages/@neet/vschedule-api/src/app/performer/index.ts b/packages/@neet/vschedule-api/src/app/performer/index.ts index b830c352..cdb5517a 100644 --- a/packages/@neet/vschedule-api/src/app/performer/index.ts +++ b/packages/@neet/vschedule-api/src/app/performer/index.ts @@ -5,6 +5,6 @@ export * from './list-performers'; export * from './performer-factory-impl'; export * from './performer-query-service'; +export * from './performer-repository'; export * from './show-performer'; -export * from './subscribe-to-performer'; export * from './upsert-performer'; diff --git a/packages/@neet/vschedule-api/src/app/performer/performer-factory-impl.ts b/packages/@neet/vschedule-api/src/app/performer/performer-factory-impl.ts index 6b020f90..aae3f07e 100644 --- a/packages/@neet/vschedule-api/src/app/performer/performer-factory-impl.ts +++ b/packages/@neet/vschedule-api/src/app/performer/performer-factory-impl.ts @@ -5,7 +5,6 @@ import fetch from 'node-fetch'; import sharp from 'sharp'; import { - IMediaAttachmentRepository, IPerformerFactory, MediaAttachmentFilename, Performer, @@ -15,6 +14,7 @@ import { import { TYPES } from '../../types'; import { IYoutubeApiService } from '../_external'; import { AppError, ILogger, UnexpectedError } from '../_shared'; +import { IMediaAttachmentRepository } from '../media-attachment'; export class PerformerFactoryChannelNotFoundError extends AppError { public readonly name = 'PerformerFactoryChannelNotFoundError'; diff --git a/packages/@neet/vschedule-api/src/domain/entities/performer/performer-repository.ts b/packages/@neet/vschedule-api/src/app/performer/performer-repository.ts similarity index 78% rename from packages/@neet/vschedule-api/src/domain/entities/performer/performer-repository.ts rename to packages/@neet/vschedule-api/src/app/performer/performer-repository.ts index bad6c3cf..cd143f37 100644 --- a/packages/@neet/vschedule-api/src/domain/entities/performer/performer-repository.ts +++ b/packages/@neet/vschedule-api/src/app/performer/performer-repository.ts @@ -1,6 +1,4 @@ -import { YoutubeChannelId } from '../_shared'; -import { Performer } from './performer'; -import { PerformerId } from './performer-id'; +import { Performer, PerformerId, YoutubeChannelId } from '../../domain'; export interface FindPerformerParams { readonly limit?: number; diff --git a/packages/@neet/vschedule-api/src/app/performer/subscribe-to-performer.ts b/packages/@neet/vschedule-api/src/app/performer/subscribe-to-performer.ts deleted file mode 100644 index ec61aba1..00000000 --- a/packages/@neet/vschedule-api/src/app/performer/subscribe-to-performer.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { inject, injectable } from 'inversify'; - -import { IPerformerRepository, PerformerId } from '../../domain'; -import { TYPES } from '../../types'; -import { IYoutubeWebsubService } from '../_external'; - -export interface ResubscribeToPerformerParams { - readonly performerId: string; -} - -@injectable() -export class SubscribeToPerformer { - constructor( - @inject(TYPES.PerformerRepository) - private readonly _performerRepository: IPerformerRepository, - - @inject(TYPES.YoutubeWebsubService) - private readonly _youtubeWebsubService: IYoutubeWebsubService, - ) {} - - async invoke(params: ResubscribeToPerformerParams): Promise { - const performer = await this._performerRepository.findById( - new PerformerId(params.performerId), - ); - - if (performer == null) { - throw new Error(`Actor with id ${params.performerId} not found`); - } - - if (performer.youtubeChannelId == null) { - throw new Error( - `Actor with id ${params.performerId} has no youtube channel`, - ); - } - - // TODO: ドメインモデルに書いてイベント的な感じで反映させたい - await this._youtubeWebsubService.subscribeToChannel( - performer.youtubeChannelId.value, - ); - } -} diff --git a/packages/@neet/vschedule-api/src/app/performer/upsert-performer.ts b/packages/@neet/vschedule-api/src/app/performer/upsert-performer.ts index 45f04e56..0156ef8b 100644 --- a/packages/@neet/vschedule-api/src/app/performer/upsert-performer.ts +++ b/packages/@neet/vschedule-api/src/app/performer/upsert-performer.ts @@ -3,7 +3,6 @@ import { inject, injectable } from 'inversify'; import { IPerformerFactory, - IPerformerRepository, OrganizationId, OrganizationService, Performer, @@ -13,7 +12,17 @@ import { YoutubeChannelId, } from '../../domain'; import { TYPES } from '../../types'; -import { ILogger } from '../_shared'; +import { AppError, ILogger } from '../_shared'; +import { IOrganizationRepository } from '../organization'; +import { IPerformerRepository } from './performer-repository'; + +export class UpsertPerformerOrganizationNotFoundError extends AppError { + readonly name = 'UpsertPerformerOrganizationNotFoundError'; + + constructor(id: OrganizationId) { + super(`Organization with ID ${id} was not found`); + } +} export type UpsertPerformerCommand = { readonly youtubeChannelId: string; @@ -37,6 +46,9 @@ export class UpsertPerformer { @inject(OrganizationService) private readonly _organizationService: OrganizationService, + @inject(TYPES.OrganizationRepository) + private readonly _organizationRepository: IOrganizationRepository, + @inject(TYPES.Logger) private readonly _logger: ILogger, ) {} @@ -53,10 +65,18 @@ export class UpsertPerformer { : await this.updatePerformer(existingPerformer, command); if (command.organizationId != null) { - await this._organizationService.addPerformer( + const organizationId = new OrganizationId(command.organizationId); + const existingOrganization = await this._organizationRepository.findById( + organizationId, + ); + if (existingOrganization == null) { + throw new UpsertPerformerOrganizationNotFoundError(organizationId); + } + const updatedPerformer = this._organizationService.addPerformer( performer, - new OrganizationId(command.organizationId), + existingOrganization, ); + await this._performerRepository.update(updatedPerformer); } } diff --git a/packages/@neet/vschedule-api/src/app/resubscription-task/index.ts b/packages/@neet/vschedule-api/src/app/resubscription-task/index.ts new file mode 100644 index 00000000..f8d54e78 --- /dev/null +++ b/packages/@neet/vschedule-api/src/app/resubscription-task/index.ts @@ -0,0 +1,5 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './resubscription-task-repository'; diff --git a/packages/@neet/vschedule-api/src/domain/entities/resubscription-task/resubscription-task-repository.ts b/packages/@neet/vschedule-api/src/app/resubscription-task/resubscription-task-repository.ts similarity index 63% rename from packages/@neet/vschedule-api/src/domain/entities/resubscription-task/resubscription-task-repository.ts rename to packages/@neet/vschedule-api/src/app/resubscription-task/resubscription-task-repository.ts index 53c1afd4..e47a4dcb 100644 --- a/packages/@neet/vschedule-api/src/domain/entities/resubscription-task/resubscription-task-repository.ts +++ b/packages/@neet/vschedule-api/src/app/resubscription-task/resubscription-task-repository.ts @@ -1,4 +1,4 @@ -import { ResubscriptionTask } from './resubscription-task'; +import { ResubscriptionTask } from '../../domain'; export interface IResubscriptionTaskRepository { create(task: ResubscriptionTask): Promise; diff --git a/packages/@neet/vschedule-api/src/app/stream/index.ts b/packages/@neet/vschedule-api/src/app/stream/index.ts index 84957d34..21878200 100644 --- a/packages/@neet/vschedule-api/src/app/stream/index.ts +++ b/packages/@neet/vschedule-api/src/app/stream/index.ts @@ -7,4 +7,5 @@ export * from './remove-stream'; export * from './show-stream'; export * from './stream-factory-impl'; export * from './stream-query-service'; +export * from './stream-repository'; export * from './upsert-stream'; diff --git a/packages/@neet/vschedule-api/src/app/stream/remove-stream.ts b/packages/@neet/vschedule-api/src/app/stream/remove-stream.ts index 39937ee8..c4c47ed0 100644 --- a/packages/@neet/vschedule-api/src/app/stream/remove-stream.ts +++ b/packages/@neet/vschedule-api/src/app/stream/remove-stream.ts @@ -1,9 +1,9 @@ import { inject, injectable } from 'inversify'; import { URL } from 'url'; -import { IStreamRepository } from '../../domain'; import { TYPES } from '../../types'; import { AppError, ILogger } from '../_shared'; +import { IStreamRepository } from './stream-repository'; export class RemoveStreamNotFoundError extends AppError { public readonly name = 'RemoveStreamNotFoundError'; diff --git a/packages/@neet/vschedule-api/src/app/stream/stream-factory-impl.ts b/packages/@neet/vschedule-api/src/app/stream/stream-factory-impl.ts index fbe413ef..28fc0b6d 100644 --- a/packages/@neet/vschedule-api/src/app/stream/stream-factory-impl.ts +++ b/packages/@neet/vschedule-api/src/app/stream/stream-factory-impl.ts @@ -5,36 +5,26 @@ import fetch from 'node-fetch'; import sharp from 'sharp'; import { - IMediaAttachmentRepository, - IPerformerRepository, - IStreamFactory, - IStreamRepository, MediaAttachment, MediaAttachmentFilename, Performer, + PerformerId, Stream, StreamDescription, StreamTitle, YoutubeChannelId, } from '../../domain'; import { TYPES } from '../../types'; -import { IYoutubeApiService, Video } from '../_external'; +import { Video } from '../_external'; import { AppError } from '../_shared'; +import { IChannelRepository } from '../channel'; +import { IMediaAttachmentRepository } from '../media-attachment'; +import { IPerformerRepository } from '../performer'; +import { IStreamRepository } from './stream-repository'; const YOUTUBE_CHANNEL_REGEXP = /https:\/\/www\.youtube\.com\/channel\/(.+?)(\/|\s|\n|\?)/g; -export class CreateStreamFailedToFetchVideoError extends AppError { - public readonly name = 'CreateStreamFailedToFetchVideoError'; - - public constructor( - public readonly videoId: string, - public readonly cause: unknown, - ) { - super(`No video found with ID ${videoId}`); - } -} - export class CreateStreamPerformerNotFoundWithChannelIdError extends AppError { public readonly name = 'CreateStreamPerformerNotFoundWithChannelIdError'; @@ -44,39 +34,43 @@ export class CreateStreamPerformerNotFoundWithChannelIdError extends AppError { } @injectable() -export class StreamFactoryImpl implements IStreamFactory { +export class StreamFactory { public constructor( - @inject(TYPES.YoutubeApiService) - private readonly _youtubeApiService: IYoutubeApiService, - @inject(TYPES.PerformerRepository) private readonly _performerRepository: IPerformerRepository, @inject(TYPES.MediaAttachmentRepository) private readonly _mediaAttachmentRepository: IMediaAttachmentRepository, + @inject(TYPES.ChannelRepository) + private readonly _channelRepository: IChannelRepository, + @inject(TYPES.StreamRepository) private readonly _streamRepository: IStreamRepository, ) {} - public async createFromVideoId(videoId: string): Promise { - const video = await this._fetchVideoById(videoId); - const performer = await this._performerRepository.findByYoutubeChannelId( - new YoutubeChannelId(video.channelId), + public async createFromVideo(video: Video): Promise { + const youtubeChannelId = new YoutubeChannelId(video.channelId); + const channel = await this._fetchChannelFromYoutubeChannel( + youtubeChannelId, ); + const thumbnail = + video.thumbnailUrl != null + ? await this._createThumbnail(video.thumbnailUrl) + : null; + if (!(channel.ownerId instanceof PerformerId)) { + throw new Error('Unexpected'); + } + + const performer = await this._performerRepository.findById(channel.ownerId); if (performer == null) { throw new CreateStreamPerformerNotFoundWithChannelIdError( video.channelId, ); } - const thumbnail = - video.thumbnailUrl != null - ? await this._createThumbnail(video.thumbnailUrl) - : null; - - const casts = await this._listCasts(video.description); + const participants = await this._listParticipants(video.description); // FIXME オブジェクトの作成以上の責務を負っている気がする let stream = await this._streamRepository.findByUrl(new URL(video.url)); @@ -88,15 +82,16 @@ export class StreamFactoryImpl implements IStreamFactory { startedAt: video.startedAt != null ? dayjs(video.startedAt) : dayjs(), endedAt: video.endedAt != null ? dayjs(video.endedAt) : null, ownerId: performer.id, - castIds: casts.map((cast) => cast.id), + participantIds: participants.map((participant) => participant.id), thumbnail, + channelId: channel.id, }); } stream = stream .setTitle(new StreamTitle(video.title)) .setDescription(new StreamDescription(video.description)) - .setCasts(casts.map((cast) => cast.id)); + .setParticipantIds(participants.map((participant) => participant.id)); if (thumbnail != null) { stream = stream.setThumbnail(thumbnail); @@ -111,11 +106,24 @@ export class StreamFactoryImpl implements IStreamFactory { return stream; } + private async _fetchChannelFromYoutubeChannel(id: YoutubeChannelId) { + const channel = await this._channelRepository.findByYoutubeChannelId(id); + if (channel == null) { + throw new CreateStreamPerformerNotFoundWithChannelIdError(id.value); + } + return channel; + } + private async _createThumbnail(urlStr: string): Promise { const url = new URL(urlStr); + + const existing = await this._mediaAttachmentRepository.findByRemoteUrl(url); + if (existing) { + return existing; + } + const image = await fetch(url); const imageBuffer = Buffer.from(await image.arrayBuffer()); - return await this._mediaAttachmentRepository.save( new MediaAttachmentFilename(`${nanoid()}_thumbnail.webp`), await sharp(imageBuffer).webp().toBuffer(), @@ -123,20 +131,10 @@ export class StreamFactoryImpl implements IStreamFactory { ); } - private async _fetchVideoById(videoId: string): Promise