diff --git a/bun.lock b/bun.lock index a3433da..fe4af03 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,7 @@ "mock-aws-s3": "^4.0.2", "nock": "^14.0.4", "pg": "^8.17.1", + "slugify": "^1.6.6", "zod": "^4.0.5", }, "devDependencies": { @@ -1150,6 +1151,8 @@ "slice-ansi": ["slice-ansi@1.0.0", "", { "dependencies": { "is-fullwidth-code-point": "^2.0.0" } }, "sha512-POqxBK6Lb3q6s047D/XsDVNPnF9Dl8JSaqe9h9lURl0OdNqy/ujDrOiIHtsqXMGbWWTIomRzAMaTyawAU//Reg=="], + "slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="], + "spdx-correct": ["spdx-correct@3.2.0", "", { "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" } }, "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA=="], "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="], diff --git a/package.json b/package.json index 282b2de..b13b4ba 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "bunx eslint", "commit": "bun x git-cz", "push": "bun scripts/git-multipush.ts", + "prisma:push": "bunx prisma db push", "prisma:generate": "bunx prisma generate", "prisma:dbml": "bunx prisma db pull && bunx prisma dbml --output ./prisma/dbml/schema.dbml", "prisma:seed": "bun run ./prisma/seed/index.ts", @@ -35,6 +36,7 @@ "mock-aws-s3": "^4.0.2", "nock": "^14.0.4", "pg": "^8.17.1", + "slugify": "^1.6.6", "zod": "^4.0.5" }, "devDependencies": { diff --git a/prisma/dbml/schema.dbml b/prisma/dbml/schema.dbml index 8072558..279775c 100644 --- a/prisma/dbml/schema.dbml +++ b/prisma/dbml/schema.dbml @@ -6,23 +6,22 @@ Table medias { id String [pk] title String [not null] titleAlternative Json [not null] - slug String [not null] + slug String [unique, not null] + malId Int [unique] pictureMedium String [not null] pictureLarge String [not null] genres genres [not null] - country countries [not null] - countryId String [not null] - isAiring Boolean [not null, default: false] - isTba Boolean [not null, default: false] - startAiring DateTime [not null] - endAiring DateTime [not null] + country Country [not null, default: 'JP'] + score Decimal [not null, default: 0] + status String [not null] + startAiring DateTime + endAiring DateTime synopsis String [not null] - nfsw Boolean [not null, default: false] - ageRating AgeRating [not null] + ageRating String [not null] mediaType MediaType [not null] - source Source [not null] + source String studios studios [not null] - pendingUpload Boolean [not null, default: true] + onDraft Boolean [not null, default: true] uploader users [not null] uploadedBy String [not null] deletedAt DateTime @@ -51,7 +50,7 @@ Table media_logs { Table genres { id String [pk] name String [not null] - slug String [not null] + slug String [unique, not null] malId Int [not null] malUrl String [not null] creator users [not null] @@ -66,9 +65,10 @@ Table genres { Table studios { id String [pk] name String [not null] - slug String [not null] - logoUrl String [not null] - colorHex String [not null] + slug String [unique, not null] + linkAbout String [not null] + malId Int [not null] + logoUrl String creator users [not null] createdBy String [not null] deletedAt DateTime @@ -77,20 +77,6 @@ Table studios { medias medias [not null] } -Table countries { - id String [pk] - name String [not null] - code String [not null] - flag String [not null] - creator users [not null] - createdBy String [not null] - deletedAt DateTime - createdAt DateTime [default: `now()`, not null] - updatedAt DateTime [default: `now()`, not null] - medias medias [not null] - user_show_countries user_preferences [not null] -} - Table episodes { id String [pk] media medias [not null] @@ -186,7 +172,6 @@ Table users { media_approveds media_logs [not null] genres genres [not null] studios studios [not null] - countries countries [not null] episodes episodes [not null] episode_likes episode_likes [not null] videos videos [not null] @@ -221,7 +206,7 @@ Table user_preferences { videoQuality VideoQuality [not null, default: 'Q1080'] serviceDefault video_services serviceDefaultId String - showContries countries [not null] + hideContries Country[] [not null] favoriteGenres genres [not null] createdAt DateTime [default: `now()`, not null] updatedAt DateTime [default: `now()`, not null] @@ -495,25 +480,11 @@ Table UserFavoriteGenres { favoritegenresId String [ref: > genres.id] } -Table UserShowContries { - user_show_countriesId String [ref: > user_preferences.id] - showcontriesId String [ref: > countries.id] -} - Table UserSelectedSharingCollention { allowed_collectionsId String [ref: > collections.id] usersallowedId String [ref: > users.id] } -Enum AgeRating { - G - PG - PG_13 - R - R_plus - Rx -} - Enum MediaType { TV ONA @@ -523,11 +494,11 @@ Enum MediaType { Music } -Enum Source { - original - manga - light_novel - game +Enum Country { + JP + EN + ID + KR } Enum MediaOperation { @@ -622,8 +593,6 @@ Enum TypeSystemNotification { toast } -Ref: medias.countryId > countries.id - Ref: medias.uploadedBy > users.id Ref: media_logs.proposedBy > users.id @@ -636,8 +605,6 @@ Ref: genres.createdBy > users.id Ref: studios.createdBy > users.id -Ref: countries.createdBy > users.id - Ref: episodes.mediaId > medias.id Ref: episodes.uploadedBy > users.id diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 38cb29b..63131fb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,16 +32,16 @@ model Media { pictureLarge String @db.Text genres Genre[] @relation("MediaGenres") country Country @default(JP) - status Status + score Decimal @db.Decimal(4, 2) @default(0.00) + status String startAiring DateTime? - finishAiring DateTime? + endAiring DateTime? synopsis String @db.Text - nfsw Boolean @default(false) - ageRating AgeRating + ageRating String mediaType MediaType - source Source + source String? studios Studio[] @relation("MediaStudios") - onDraft Boolean @default(false) + onDraft Boolean @default(true) uploader User @relation("UserUploadedMedias", fields: [uploadedBy], references: [id]) uploadedBy String deletedAt DateTime? @@ -52,6 +52,11 @@ model Media { episodes Episode[] @relation("MediaEpisodes") collections Collection[] @relation("MediaCollections") reviews MediaReview[] @relation("MediaReviews") + + @@index([status, onDraft, deletedAt]) + @@index([mediaType]) + @@index([uploadedBy]) + @@index([createdAt]) @@map("medias") } @@ -73,7 +78,7 @@ model MediaLog { model Genre { id String @id @default(uuid()) name String @db.VarChar(255) - slug String @db.VarChar(255) + slug String @db.VarChar(255) @unique malId Int malUrl String @db.VarChar(255) creator User @relation("UserCreatedGenres", fields: [createdBy], references: [id]) @@ -84,15 +89,17 @@ model Genre { medias Media[] @relation("MediaGenres") user_favourite_genres UserPreference[] @relation("UserFavoriteGenres") + @@map("genres") } model Studio { id String @id @default(uuid()) name String @db.VarChar(255) - slug String @db.VarChar(255) - logoUrl String @db.Text - colorHex String @db.VarChar(10) + slug String @db.VarChar(255) @unique + linkAbout String @db.Text + malId Int + logoUrl String? @db.Text creator User @relation("UserCreatedStudios", fields: [createdBy], references: [id]) createdBy String deletedAt DateTime? @@ -103,6 +110,40 @@ model Studio { @@map("studios") } +model Character { + id String @id @default(uuid()) @db.Uuid + name String + role CharacterRole + favorites Int @default(0) + imageUrl String? + smallImageUrl String? + createdBy User @relation("UserCreatedCharacters", fields: [creatorId], references: [id]) + creatorId String + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("characters") +} + +model VoiceActor { + id String @id @default(uuid()) @db.Uuid + malId Int + name String + birthday DateTime? + description String @db.Text + aboutUrl String + imageUrl String? + websiteUrl String? + createdBy User @relation("UserCreatedVoiceActors", fields: [creatorId], references: [id]) + creatorId String + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("voice_actors") +} + model Episode { id String @id @default(uuid()) media Media @relation("MediaEpisodes", fields: [mediaId], references: [id]) @@ -205,6 +246,8 @@ model User { media_approveds MediaLog[] @relation("UserApprovedMedias") genres Genre[] @relation("UserCreatedGenres") studios Studio[] @relation("UserCreatedStudios") + characters Character[] @relation("UserCreatedCharacters") + voice_actor VoiceActor[] @relation("UserCreatedVoiceActors") episodes Episode[] @relation("UserEpisodes") episode_likes EpisodeLike[] @relation("UserEpisodeLikes") videos Video[] @relation("UserVideos") @@ -523,14 +566,6 @@ model SystemLog { //// Prisma Enum Values //// // Media Enum -enum AgeRating { - G // All Ages - PG // Children - PG_13 // Teens 13 or older - R // 17+ (violance & profanity) - R_plus // Mild Nudity - Rx // Hentai -} enum MediaType { TV ONA @@ -539,17 +574,6 @@ enum MediaType { Special Music } -enum Source { - original - manga - light_novel - game -} -enum Status { - FINISHED_AIRING @map("Finished Airing") - CURRENTLY_AIRING @map("Currently Airing") - NOT_YET_AIRED @map("Not yet aired") -} enum Country { JP @map("Japanese") EN @map("English") @@ -557,6 +581,12 @@ enum Country { KR @map("Korea") } +// Character Enum +enum CharacterRole { + Main + Supporting +} + // MediaLog Enum enum MediaOperation { create diff --git a/snowflake.d.ts b/snowflake.d.ts new file mode 100644 index 0000000..07451a2 --- /dev/null +++ b/snowflake.d.ts @@ -0,0 +1,15 @@ +declare module "node-snowflake" { + interface SnowflakeOptions { + workerId?: number; + datacenterId?: number; + sequence?: number; + epoch?: number | bigint | Date; + } + + class Snowflake { + constructor(options?: SnowflakeOptions); + generate(): bigint; + } + + export { Snowflake }; +} diff --git a/src/config/apis/media.reference.ts b/src/config/apis/media.reference.ts new file mode 100644 index 0000000..b845a5e --- /dev/null +++ b/src/config/apis/media.reference.ts @@ -0,0 +1,6 @@ +export const getMediaReferenceAPI = (malId: number) => { + return { + baseURL: "https://api.jikan.moe/v4", + getMediaFullInfo: `/anime/${malId}/full`, + }; +}; diff --git a/src/helpers/characters/generateSlug.ts b/src/helpers/characters/generateSlug.ts new file mode 100644 index 0000000..17fdbb4 --- /dev/null +++ b/src/helpers/characters/generateSlug.ts @@ -0,0 +1,50 @@ +import slugify from "slugify"; +import { PrismaClient } from "@prisma/client"; +import { prisma } from "../../utils/databases/prisma/connection"; + +interface UniqueConfig { + model?: keyof PrismaClient; + target?: string; +} + +export async function generateSlug( + input: string, + config?: UniqueConfig, +): Promise { + const baseSlug = slugify(input, { lower: true, strict: true }); + let uniqueSlug = baseSlug; + + // CASE 1: Tidak ada config → langsung return slug + if (!config) return uniqueSlug; + + const { model, target } = config; + + // CASE 2: Validasi pasangan model-target + if (!model || !target) { + throw new Error(`Both "model" and "target" must be provided together.`); + } + + // CASE 3: Cek unique + const prismaModel = (prisma as any)[model]; + if (!prismaModel) { + throw new Error(`Model "${model as string}" not found in PrismaClient.`); + } + + let counter = 1; + + while (true) { + const exists = await prismaModel.findFirst({ + where: { + [target]: uniqueSlug, + }, + select: { [target]: true }, + }); + + if (!exists) break; + + uniqueSlug = `${baseSlug}-${counter}`; + counter++; + } + + return uniqueSlug; +} diff --git a/src/modules/internal/index.ts b/src/modules/internal/index.ts new file mode 100644 index 0000000..202f334 --- /dev/null +++ b/src/modules/internal/index.ts @@ -0,0 +1,25 @@ +import Elysia, { Context } from "elysia"; +import { MediaFullInfoResponse } from "./types/mediaFullInfo.type"; +import { InsertMediaRepository } from "./repositories/insertMedia.repository"; +import { mainErrorHandler } from "../../helpers/error/handler"; + +const masterSourceAPI = "https://api.jikan.moe/v4"; + +export const internalModule = new Elysia({ prefix: "/internal" }).post( + "/medias", + async (ctx: Context & { body: { mal_id: number } }) => { + try { + const fullMediaData = await fetch( + `${masterSourceAPI}/anime/${ctx.body.mal_id}/full`, + ) + .then((res) => res.json()) + .then((data) => data as MediaFullInfoResponse); + + // return fullMediaData; + const createMedia = await InsertMediaRepository(fullMediaData); + return createMedia; + } catch (error) { + return mainErrorHandler(ctx.set, error); + } + }, +); diff --git a/src/modules/internal/repositories/insertMedia.repository.ts b/src/modules/internal/repositories/insertMedia.repository.ts new file mode 100644 index 0000000..f3627b5 --- /dev/null +++ b/src/modules/internal/repositories/insertMedia.repository.ts @@ -0,0 +1,134 @@ +import { Prisma } from "@prisma/client"; +import { generateSlug } from "../../../helpers/characters/generateSlug"; +import { AppError } from "../../../helpers/error/instances/app"; +import { prisma } from "../../../utils/databases/prisma/connection"; +import { MediaFullInfoResponse } from "../types/mediaFullInfo.type"; + +export const InsertMediaRepository = async (data: MediaFullInfoResponse) => { + try { + /** + * Genres Insertion + * + * This section handles the insertion of genres associated with the media. + * It iterates over each genre in the media data, generates a slug for it, + * and performs an upsert operation to ensure that the genre is either created + * or updated in the database. The IDs of the inserted or updated genres are + * collected for later association with the media. + * + * @param data - The full media data containing genres information. + */ + const genreIds: string[] = []; + for (const genre of data.data.genres) { + const slug = (await generateSlug(genre.name)) as string; + const genrePayload = { + name: genre.name, + malId: genre.mal_id, + malUrl: genre.url, + createdBy: "b734b9bc-b4ea-408f-a80e-0a837ce884da", + slug, + }; + const insertedGenre = await prisma.genre.upsert({ + where: { slug }, + create: genrePayload, + update: genrePayload, + select: { id: true }, + }); + genreIds.push(insertedGenre.id); + } + + /** + * Studios Insertion + * + * This section manages the insertion of studios associated with the media. + * It processes each studio listed in the media data, generating a slug for + * each and performing an upsert operation to either create or update the + * studio record in the database. The IDs of the inserted or updated studios + * are collected for later association with the media. + * + * @param data - The full media data containing studios information. + */ + const studioIds: string[] = []; + for (const studio of data.data.studios) { + const slug = (await generateSlug(studio.name)) as string; + const studioPayload = { + name: studio.name, + malId: studio.mal_id, + linkAbout: studio.url, + createdBy: "b734b9bc-b4ea-408f-a80e-0a837ce884da", + slug, + }; + const insertedStudio = await prisma.studio.upsert({ + where: { slug }, + create: studioPayload, + update: studioPayload, + select: { id: true }, + }); + studioIds.push(insertedStudio.id); + } + for (const studio of data.data.producers) { + const slug = (await generateSlug(studio.name)) as string; + const studioPayload = { + name: studio.name, + malId: studio.mal_id, + linkAbout: studio.url, + createdBy: "b734b9bc-b4ea-408f-a80e-0a837ce884da", + slug, + }; + const insertedStudio = await prisma.studio.upsert({ + where: { slug }, + create: studioPayload, + update: studioPayload, + select: { id: true }, + }); + studioIds.push(insertedStudio.id); + } + + /** + * Media Payload Construction and Upsert + * + * This section constructs the payload for the media insertion or update. + * It gathers all necessary information from the media data, including + * title, alternative titles, slug, associated genres and studios, score, + * images, status, airing dates, synopsis, age rating, media type, source, + * and other relevant details. This payload is then used in an upsert + * operation to ensure that the media record is either created or updated + * in the database. + * + * @param data - The full media data for constructing the media payload. + */ + const construct = { + title: data.data.title, + titleAlternative: (data.data.titles as unknown) as Prisma.InputJsonValue, + slug: await generateSlug(data.data.title, { + model: "media", + target: "slug", + }), + malId: data.data.mal_id, + genres: { + connect: genreIds.map((id) => ({ id })), + }, + studios: { + connect: studioIds.map((id) => ({ id })), + }, + score: data.data.score, + pictureMedium: data.data.images.webp.image_url, + pictureLarge: data.data.images.webp.large_image_url, + status: data.data.status, + startAiring: data.data.aired.from, + endAiring: data.data.aired.to, + synopsis: data.data.synopsis, + ageRating: data.data.rating, + mediaType: data.data.type, + source: data.data.source, + onDraft: false, + uploadedBy: "b734b9bc-b4ea-408f-a80e-0a837ce884da", + }; + return await prisma.media.upsert({ + where: { malId: data.data.mal_id }, + update: construct, + create: construct, + }); + } catch (error) { + throw new AppError(500, "Failed to insert media", error); + } +}; diff --git a/src/modules/internal/types/mediaFullInfo.type.ts b/src/modules/internal/types/mediaFullInfo.type.ts new file mode 100644 index 0000000..acdb229 --- /dev/null +++ b/src/modules/internal/types/mediaFullInfo.type.ts @@ -0,0 +1,125 @@ +import { MediaType } from "@prisma/client"; +export interface MediaFullInfoResponse { + data: Data; +} + +interface Data { + mal_id: number; + url: string; + images: { [key: string]: Image }; + trailer: Trailer; + approved: boolean; + titles: Title[]; + title: string; + title_english: string; + title_japanese: string; + title_synonyms: string[]; + type: MediaType; + source: string; + episodes: number; + status: string; + airing: boolean; + aired: Aired; + duration: string; + rating: string; + score: number; + scored_by: number; + rank: number; + popularity: number; + members: number; + favorites: number; + synopsis: string; + background: string; + season: string; + year: number; + broadcast: Broadcast; + producers: Genre[]; + licensors: any[]; + studios: Genre[]; + genres: Genre[]; + explicit_genres: any[]; + themes: Genre[]; + demographics: any[]; + relations: Relation[]; + theme: Theme; + external: External[]; + streaming: External[]; +} + +interface Aired { + from: Date; + to: Date; + prop: Prop; + string: string; +} + +interface Prop { + from: From; + to: From; +} + +interface From { + day: number; + month: number; + year: number; +} + +interface Broadcast { + day: string; + time: string; + timezone: string; + string: string; +} + +interface External { + name: string; + url: string; +} + +interface Genre { + mal_id: number; + type: Type; + name: string; + url: string; +} + +enum Type { + Anime = "anime", + Manga = "manga", +} + +interface Image { + image_url: string; + small_image_url: string; + large_image_url: string; +} + +interface Relation { + relation: string; + entry: Genre[]; +} + +interface Theme { + openings: string[]; + endings: string[]; +} + +interface Title { + type: string; + title: string; +} + +interface Trailer { + youtube_id: null; + url: null; + embed_url: string; + images: Images; +} + +interface Images { + image_url: null; + small_image_url: null; + medium_image_url: null; + large_image_url: null; + maximum_image_url: null; +}