add stream

This commit is contained in:
Sergey Krylov 2025-07-09 06:17:03 +03:00
parent 5d347acf88
commit d0a409abf0
12 changed files with 350 additions and 4 deletions

View File

@ -8,6 +8,22 @@ datasource db {
url = env("POSTGRES_URI")
}
model Stream {
id String @id @default(uuid())
title String
thumbnailUrl String? @map("thumbnail_url")
ingressId String? @unique @map("ingress_id")
serverUrl String? @map("server_url")
key String? @map("key")
isLive Boolean @default(false) @map("is_live")
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String? @unique @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("stream")
}
model SocialLink {
id String @id @default(uuid())
title String
@ -37,6 +53,7 @@ model User {
deactivatedAt DateTime? @map("deactivated_at")
socialLink SocialLink[]
totpSecret String? @map("totp_secret")
stream Stream?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

View File

@ -9,6 +9,7 @@ import { VerificationModule } from '@/src/module/auth/verification/verification.
import { CronModule } from '@/src/module/cron/cron.module'
import { MailModule } from '@/src/module/libs/mail/mail.module'
import { StorageModule } from '@/src/module/libs/storage/storage.module'
import { StreamModule } from '@/src/module/stream/stream.module'
import { IS_DEV } from '@/src/shared/util/is-dev.util'
import { ApolloDriver } from '@nestjs/apollo'
import { Module } from '@nestjs/common'
@ -41,6 +42,7 @@ import { RedisModule } from './redis/redis.module'
TotpModule,
DeactivateModule,
ProfileModule,
StreamModule,
],
})
export class CoreModule {}

View File

@ -22,6 +22,11 @@ input ChangeProfileInfoInput {
name: String!
}
input ChangeStreamInfoInput {
categoryId: String!
title: String!
}
input CreateUserInput {
email: String!
name: String!
@ -50,6 +55,12 @@ input EnableTotpInput {
secret: String!
}
input FilterInput {
searchTerm: String
skip: Float
take: Float
}
type LocationModel {
city: String!
country: String!
@ -68,6 +79,8 @@ type Mutation {
changePassword(data: ChangePasswordInput!): UserModel!
changeProfileAvatar(avatar: Upload!): Boolean!
changeProfileInfo(data: ChangeProfileInfoInput!): UserModel!
changeStreamInfo(data: ChangeStreamInfoInput!): StreamModel!
changeStreamThumbnail(thumbnail: Upload!): Boolean!
clearSessionCookie: Boolean!
createSocialLink(data: SocialLinkInput!): SocialLinkModel!
createUser(data: CreateUserInput!): UserModel!
@ -79,11 +92,12 @@ type Mutation {
removeProfileAvatar: Boolean!
removeSession(id: String!): Boolean!
removeSocialLink(id: String!): Boolean!
removeStreamThumbnail: Boolean!
reorderSocialLink(list: [SocialLinkOrderInput!]!): Boolean!
resetPassword(data: ResetPasswordInput!): Boolean!
setNewPassword(data: NewPasswordInput!): Boolean!
updateSocialLink(data: SocialLinkInput!, id: String!): SocialLinkModel!
verifyAccount(data: VerificationInput!): UserModel!
verifyAccount(data: VerificationInput!): AuthModel!
}
input NewPasswordInput {
@ -93,8 +107,10 @@ input NewPasswordInput {
}
type Query {
findAllStreams(filters: FilterInput!): [StreamModel!]!
findCurrentSession: SessionModel!
findProfile: UserModel!
findRandomStreams: [StreamModel!]!
findSessionsByUser: [SessionModel!]!
findSocialLinks: [SocialLinkModel!]!
generateTotpSecret: TotpModel!
@ -137,6 +153,20 @@ input SocialLinkOrderInput {
position: Float!
}
type StreamModel {
createdAt: DateTime!
id: ID!
ingressId: String
isLive: Boolean!
key: String
serverUrl: String
thumbnailUrl: String
title: String!
updatedAt: DateTime!
user: UserModel!
userId: ID!
}
type TotpModel {
qrcodeUrl: String!
secret: String!
@ -160,6 +190,7 @@ type UserModel {
name: String!
password: String!
socialLink: [SocialLinkModel!]!
stream: StreamModel!
totpSecret: String
updatedAt: DateTime!
}

View File

@ -51,6 +51,11 @@ export class AccountService {
email,
password: await hash(password),
displayName: name,
stream: {
create: {
title: `Stream by ${name}`,
},
},
},
})

View File

@ -1,4 +1,5 @@
import { SocialLinkModel } from '@/src/module/auth/profile/inputs/models/social-link.model';
import { SocialLinkModel } from '@/src/module/auth/profile/inputs/models/social-link.model'
import { StreamModel } from '@/src/module/stream/models/stream.model'
import { Field, ID, ObjectType } from '@nestjs/graphql'
import { User } from '@prisma/generated'
@ -46,6 +47,9 @@ export class UserModel implements User {
@Field(() => [SocialLinkModel])
socialLink: SocialLinkModel[]
@Field(() => StreamModel)
stream: StreamModel
@Field(() => Date)
createdAt: Date

View File

@ -1,4 +1,4 @@
import { UserModel } from '@/src/module/auth/account/models/user.model'
import { AuthModel } from '@/src/module/auth/account/models/auth.model'
import { VerificationInput } from '@/src/module/auth/verification/inputs/verification.input'
import { UserAgent } from '@/src/shared/decorators/user-agent.decorator'
import { GqlContext } from '@/src/shared/types/gql-context.types'
@ -9,7 +9,7 @@ import { VerificationService } from './verification.service'
export class VerificationResolver {
constructor(private readonly verificationService: VerificationService) {}
@Mutation(() => UserModel, { name: 'verifyAccount' })
@Mutation(() => AuthModel, { name: 'verifyAccount' })
public async verify(
@Context() { req }: GqlContext,
@Args('data') input: VerificationInput,

View File

@ -0,0 +1,15 @@
import { Field, InputType } from '@nestjs/graphql'
import { IsNotEmpty, IsString } from 'class-validator'
@InputType()
export class ChangeStreamInfoInput {
@Field(() => String)
@IsString()
@IsNotEmpty()
title: string
@Field(() => String)
@IsString()
@IsNotEmpty()
categoryId: string
}

View File

@ -0,0 +1,20 @@
import { Field, InputType } from '@nestjs/graphql'
import { IsNumber, IsOptional, IsString } from 'class-validator'
@InputType()
export class FilterInput {
@Field(() => Number, { nullable: true })
@IsNumber()
@IsOptional()
public take?: number
@Field(() => Number, { nullable: true })
@IsNumber()
@IsOptional()
public skip?: number
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
public searchTerm?: string
}

View File

@ -0,0 +1,39 @@
import { UserModel } from '@/src/module/auth/account/models/user.model'
import { Field, ID, ObjectType } from '@nestjs/graphql'
import { Stream } from '@prisma/generated'
@ObjectType()
export class StreamModel implements Stream {
@Field(() => ID)
id: string
@Field(() => ID)
userId: UserModel['id']
@Field(() => UserModel)
user: UserModel
@Field(() => String)
title: string
@Field(() => String, { nullable: true })
key: string
@Field(() => String, { nullable: true })
thumbnailUrl: string
@Field(() => String, { nullable: true })
serverUrl: string
@Field(() => Boolean)
isLive: boolean
@Field(() => String, { nullable: true })
ingressId: string
@Field(() => Date)
updatedAt: Date
@Field(() => Date)
createdAt: Date
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common'
import { StreamService } from './stream.service'
import { StreamResolver } from './stream.resolver'
@Module({
providers: [StreamResolver, StreamService],
})
export class StreamModule {}

View File

@ -0,0 +1,52 @@
import { ChangeStreamInfoInput } from '@/src/module/stream/inputs/change-stream-info.input'
import { FilterInput } from '@/src/module/stream/inputs/filter.input'
import { StreamModel } from '@/src/module/stream/models/stream.model'
import { Authorization } from '@/src/shared/decorators/auth.decorator'
import { Authorized } from '@/src/shared/decorators/authorized.decorator'
import { FileValidationPipe } from '@/src/shared/pipes/file-validation.pipe'
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'
import { User } from '@prisma/generated'
import * as Upload from 'graphql-upload/Upload.js'
import * as GraphQLUpload from 'graphql-upload/GraphQLUpload.js'
import { StreamService } from './stream.service'
@Resolver('Stream')
export class StreamResolver {
constructor(private readonly streamService: StreamService) {}
@Query(() => [StreamModel], { name: 'findAllStreams' })
public async findAll(
@Args('filters') input: FilterInput,
) {
return this.streamService.findAll(input)
}
@Query(() => [StreamModel], { name: 'findRandomStreams' })
public async findRandomStreams() {
return this.streamService.findRandom()
}
@Authorization()
@Mutation(() => StreamModel, { name: 'changeStreamInfo' })
public async changeInfo(
@Authorized() user: User,
@Args('data') input: ChangeStreamInfoInput,
) {
return this.streamService.changeInfo(user, input)
}
@Authorization()
@Mutation(() => Boolean, { name: 'changeStreamThumbnail' })
public async changeThumbnail(
@Authorized() user: User,
@Args('thumbnail', { type: () => GraphQLUpload }, FileValidationPipe) file: Upload,
) {
return this.streamService.changeThumbnail(user, file)
}
@Authorization()
@Mutation(() => Boolean, { name: 'removeStreamThumbnail' })
public async removeAvatar(@Authorized() user: User) {
return this.streamService.removeThumbnail(user)
}
}

View File

@ -0,0 +1,153 @@
import { Prisma, User } from '@/prisma/generated'
import { PrismaService } from '@/src/core/prisma/prisma.service'
import { StorageService } from '@/src/module/libs/storage/storage.service'
import { ChangeStreamInfoInput } from '@/src/module/stream/inputs/change-stream-info.input'
import { FilterInput } from '@/src/module/stream/inputs/filter.input'
import { Injectable, NotFoundException } from '@nestjs/common'
import * as Upload from 'graphql-upload/Upload'
import sharp from 'sharp'
@Injectable()
export class StreamService {
constructor(
private readonly prismaService: PrismaService,
private readonly storageService: StorageService,
) {
}
public async findAll(input: FilterInput = {}) {
const { take, skip, searchTerm } = input
const whereClause = searchTerm
? this.findBySearchTermFilter(searchTerm)
: undefined
return this.prismaService.stream.findMany({
take: take ?? 12,
skip: skip ?? 0,
where: {
user: { isDeactivated: false },
...whereClause,
},
include: { user: true },
orderBy: {
createdAt: 'desc',
},
})
}
public async findRandom() {
const total = await this.prismaService.stream.count({
where: {
user: {
isDeactivated: false,
},
},
})
const randomIndexes = new Set<number>()
while (randomIndexes.size < 2) {
const randomIndex = Math.floor(Math.random() * total)
randomIndexes.add(randomIndex)
}
const streams = await this.prismaService.stream.findMany({
where: {
user: {
isDeactivated: false,
},
},
take: total,
skip: 0,
include: { user: true },
})
return Array.from(randomIndexes).map(index => streams[index])
}
public findBySearchTermFilter(searchTerm: NonNullable<FilterInput['searchTerm']>): Prisma.StreamWhereInput {
return {
OR: [
{
title: {
contains: searchTerm,
mode: 'insensitive',
},
},
{
user: {
name: {
contains: searchTerm,
mode: 'insensitive',
},
},
},
],
}
}
public async changeInfo(user: User, input: ChangeStreamInfoInput) {
const { categoryId, title } = input
return this.prismaService.stream.update({
where: { userId: user.id },
data: { title },
})
}
public async changeThumbnail(user: User, file: Upload) {
const stream = await this.findStreamByUser(user)
if (stream.thumbnailUrl) {
await this.storageService.remove(stream.thumbnailUrl)
}
const chunks: Buffer[] = []
for await (const chunk of file.createReadStream()) {
chunks.push(chunk)
}
const buffer = Buffer.concat(chunks)
const fileName = `/streams/${user.name}.webp`
const processedBuffer = await sharp(buffer, { animated: file.filename.endsWith('.gif') })
.resize(1280, 720)
.webp()
.toBuffer()
await this.storageService.upload(processedBuffer, fileName, 'image/webp')
return this.prismaService.stream.update({
where: { id: stream.id },
data: { thumbnailUrl: fileName },
})
}
public async removeThumbnail(user: User) {
const stream = await this.findStreamByUser(user)
if (!stream.thumbnailUrl) {
return true
}
await this.storageService.remove(stream.thumbnailUrl)
return this.prismaService.stream.update({
where: { id: stream.id },
data: { thumbnailUrl: null },
})
}
private async findStreamByUser(user: User) {
const stream = await this.prismaService.stream.findUnique({
where: {
userId: user.id,
},
})
if (!stream) {
throw new NotFoundException('Не найден такой стрим')
}
return stream
}
}