diff --git a/Dockerfile b/Dockerfile index 49301e37..54451b68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,10 @@ ENV NODE_ENV=production \ PORT=3000 \ HOSTNAME=0.0.0.0 +# yt-dlp +ADD https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux /usr/local/bin/yt-dlp +RUN chmod a+rx /usr/local/bin/yt-dlp + RUN groupadd -r nodejs && useradd -r -g nodejs nextjs # public assets + standalone server + static chunks diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index 729e06d3..488cb3ab 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -7,7 +7,7 @@ import { Header } from "@/widgets/header/ui/header"; import { AiChatButton } from "@/features/ai-chat/ui/AiChatButton"; import { AiChatModal } from "@/features/ai-chat/ui/AiChatModal"; import { useAiChatPanel } from "@/features/ai-chat/model/useAiChatPanel"; -import { WakeUpMusicRecommend } from "@/features/wake-up-music/ui/WakeUpMusicRecommend"; +import { WakeUpMusicRecommend } from "@/features/wake-up-music-recommend/ui/WakeUpMusicRecommend"; import { RealtimeSubscriptions } from "@/widgets/realtime-subscription/ui/RealtimeSubscriptions"; export default function MainLayout({ diff --git a/app/api/youtube/transcript/route.ts b/app/api/youtube/transcript/route.ts new file mode 100644 index 00000000..1dd8c5d6 --- /dev/null +++ b/app/api/youtube/transcript/route.ts @@ -0,0 +1,42 @@ +import { HttpStatusCode } from "axios"; +import { NextRequest, NextResponse } from "next/server"; +import { extractYoutubeVideoId } from "@/entities/music/lib/youtube"; +import { getYoutubeTranscript } from "@/entities/music/lib/transcript"; + +export const runtime = "nodejs"; + +export async function GET(request: NextRequest) { + const authorization = request.headers.get("authorization"); + const accessToken = authorization?.startsWith("Bearer ") + ? authorization.slice("Bearer ".length) + : null; + + if (!accessToken) { + return NextResponse.json( + { error: "access token 없음" }, + { status: HttpStatusCode.Unauthorized }, + ); + } + + const params = request.nextUrl.searchParams; + const urlParam = params.get("url"); + const idParam = params.get("id"); + const videoId = + idParam ?? (urlParam ? extractYoutubeVideoId(urlParam) : null); + + const youtubeIdRegex = /^[a-zA-Z0-9_-]{11}$/; + if (!videoId || !youtubeIdRegex.test(videoId)) { + return NextResponse.json( + { error: "올바른 형식의 videoId(id) 또는 url이 필요합니다." }, + { status: HttpStatusCode.BadRequest }, + ); + } + + const result = await getYoutubeTranscript(videoId); + + if ("error" in result) { + return NextResponse.json(result, { status: HttpStatusCode.NotFound }); + } + + return NextResponse.json(result); +} diff --git a/package-lock.json b/package-lock.json index a776db28..b646c181 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "flooding-v2", "version": "0.2.2", "dependencies": { + "@mlc-ai/web-llm": "^0.2.84", "@sentry/nextjs": "^10.53.1", "@sun-typeface/suit": "^2.0.5", "@tanstack/react-query": "^5.90.20", @@ -1134,6 +1135,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mlc-ai/web-llm": { + "version": "0.2.84", + "resolved": "https://registry.npmjs.org/@mlc-ai/web-llm/-/web-llm-0.2.84.tgz", + "integrity": "sha512-hrOWzK4/nGNmgoRKT8pgVmZZ2oEPpbblIWQOwpqNyvK2dysHw3KVB1gNJOuRcQfKOPhucEhX1NJzXzgMDnwSCQ==", + "license": "Apache-2.0", + "dependencies": { + "loglevel": "^1.9.1" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.41.3", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", @@ -6801,6 +6811,19 @@ "dev": true, "license": "MIT" }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "license": "MIT", diff --git a/package.json b/package.json index c75436e7..61b924cf 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "format:check": "prettier --check --ignore-unknown ." }, "dependencies": { + "@mlc-ai/web-llm": "^0.2.84", "@sentry/nextjs": "^10.53.1", "@sun-typeface/suit": "^2.0.5", "@tanstack/react-query": "^5.90.20", diff --git a/src/entities/ai/lib/webllm.worker.ts b/src/entities/ai/lib/webllm.worker.ts new file mode 100644 index 00000000..e4798202 --- /dev/null +++ b/src/entities/ai/lib/webllm.worker.ts @@ -0,0 +1,7 @@ +import { WebWorkerMLCEngineHandler } from "@mlc-ai/web-llm"; + +const handler = new WebWorkerMLCEngineHandler(); + +self.onmessage = (event: MessageEvent) => { + handler.onmessage(event); +}; diff --git a/src/entities/ai/lib/webllmEngine.ts b/src/entities/ai/lib/webllmEngine.ts new file mode 100644 index 00000000..8628f2b4 --- /dev/null +++ b/src/entities/ai/lib/webllmEngine.ts @@ -0,0 +1,94 @@ +import type { WebWorkerMLCEngine } from "@mlc-ai/web-llm"; + +export const MUSIC_LLM_MODEL_ID = "gemma-2-2b-it-q4f16_1-MLC"; + +export type ModelState = + | { status: "idle" } + | { status: "loading"; progress: number } + | { status: "ready" } + | { status: "error"; message: string }; + +const WEBGPU_UNSUPPORTED_MESSAGE = + "이 브라우저/기기에서 WebGPU를 사용할 수 없습니다."; + +let enginePromise: Promise | null = null; +let modelState: ModelState = { status: "idle" }; +const listeners = new Set<() => void>(); + +function setModelState(next: ModelState) { + modelState = next; + listeners.forEach((listener) => listener()); +} + +export function subscribeModelState(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getModelStateSnapshot(): ModelState { + return modelState; +} + +export async function ensureWebGpuSupport(): Promise { + if (modelState.status !== "idle") return; + + const gpu = ( + navigator as Navigator & { + gpu?: { requestAdapter: () => Promise }; + } + ).gpu; + + if (!gpu) { + setModelState({ status: "error", message: WEBGPU_UNSUPPORTED_MESSAGE }); + return; + } + + try { + const adapter = await gpu.requestAdapter(); + if (!adapter) { + setModelState({ status: "error", message: WEBGPU_UNSUPPORTED_MESSAGE }); + } + } catch { + setModelState({ status: "error", message: WEBGPU_UNSUPPORTED_MESSAGE }); + } +} + +export function getMusicLlmEngine(): Promise { + if (!enginePromise) { + setModelState({ status: "loading", progress: 0 }); + enginePromise = (async () => { + const { CreateWebWorkerMLCEngine } = await import("@mlc-ai/web-llm"); + const worker = new Worker( + new URL("./webllm.worker.ts", import.meta.url), + { + type: "module", + }, + ); + + const engine = await CreateWebWorkerMLCEngine( + worker, + MUSIC_LLM_MODEL_ID, + { + initProgressCallback: (report) => + setModelState({ status: "loading", progress: report.progress }), + }, + ); + setModelState({ status: "ready" }); + return engine; + })().catch((error) => { + enginePromise = null; + const reason = error instanceof Error ? error.message : String(error); + setModelState({ + status: "error", + message: reason.includes("WebGPU") + ? WEBGPU_UNSUPPORTED_MESSAGE + : "AI 모델을 불러오지 못했습니다.", + }); + throw error; + }); + } + + return enginePromise; +} diff --git a/src/entities/ai/model/useModelState.ts b/src/entities/ai/model/useModelState.ts new file mode 100644 index 00000000..bd51d527 --- /dev/null +++ b/src/entities/ai/model/useModelState.ts @@ -0,0 +1,16 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { + getModelStateSnapshot, + subscribeModelState, + type ModelState, +} from "@/entities/ai/lib/webllmEngine"; + +export function useModelState(): ModelState { + return useSyncExternalStore( + subscribeModelState, + getModelStateSnapshot, + getModelStateSnapshot, + ); +} diff --git a/src/entities/dormitory/lib/musicPermission.ts b/src/entities/dormitory/lib/musicPermission.ts index a6a6a16e..6bf0fe19 100644 --- a/src/entities/dormitory/lib/musicPermission.ts +++ b/src/entities/dormitory/lib/musicPermission.ts @@ -1,11 +1,23 @@ import type { UserRole } from "@/entities/user/model/user"; +// 기자위(DORMITORY_MANAGER) 중 AI 분석을 쓸 수 있는 사감 계정 이름 +const DORM_SUPERVISOR_NAME = "사감선생님"; + interface CreateMusicPermissionParams { role?: UserRole; + name?: string; } -export function createMusicPermission({ role }: CreateMusicPermissionParams) { +export function createMusicPermission({ + role, + name, +}: CreateMusicPermissionParams) { + const isDormManager = role === "DORMITORY_MANAGER"; + return { - canDeleteAnyMusic: role === "ADMIN" || role === "DORMITORY_MANAGER", + canDeleteAnyMusic: role === "ADMIN" || isDormManager, + // 어드민은 모두 가능, 기자위는 사감선생님만 가능 + canUseAiAnalysis: + role === "ADMIN" || (isDormManager && name === DORM_SUPERVISOR_NAME), }; } diff --git a/src/entities/music/api/youtubeQueries.ts b/src/entities/music/api/youtubeQueries.ts index b5cc797f..8c988d52 100644 --- a/src/entities/music/api/youtubeQueries.ts +++ b/src/entities/music/api/youtubeQueries.ts @@ -13,6 +13,10 @@ interface YoutubeVideosResponse { videos: YoutubeVideoMetadata[]; } +export interface YoutubeTranscript { + text: string; +} + export async function getYoutubeVideos( videoIds: string[], signal?: AbortSignal, @@ -35,6 +39,25 @@ export async function getYoutubeVideos( return Object.fromEntries(data.videos.map((video) => [video.id, video])); } +const TRANSCRIPT_TIMEOUT_MS = 65_000; + +export async function getYoutubeTranscript( + videoId: string, + signal?: AbortSignal, +): Promise { + const { data } = await instance.get( + "/api/youtube/transcript", + { + baseURL: undefined, + params: { id: videoId }, + timeout: TRANSCRIPT_TIMEOUT_MS, + signal, + }, + ); + + return data; +} + export const youtubeQueries = { videos: (videoIds: string[]) => { const ids = Array.from(new Set(videoIds)).sort(); @@ -45,4 +68,13 @@ export const youtubeQueries = { enabled: ids.length > 0, }); }, + + transcript: (videoId: string) => + queryOptions({ + queryKey: ["music", "youtube-transcript", videoId], + queryFn: ({ signal }) => getYoutubeTranscript(videoId, signal), + enabled: Boolean(videoId), + staleTime: Infinity, + gcTime: Infinity, + }), } as const; diff --git a/src/entities/music/lib/transcript.ts b/src/entities/music/lib/transcript.ts new file mode 100644 index 00000000..1ce96ac5 --- /dev/null +++ b/src/entities/music/lib/transcript.ts @@ -0,0 +1,164 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface YoutubeTranscriptSuccess { + text: string; +} + +export interface YoutubeTranscriptFailure { + error: string; +} + +export type YoutubeTranscriptResult = + | YoutubeTranscriptSuccess + | YoutubeTranscriptFailure; + +const PREFERRED_LANGS = ["ko", "en"]; +const YT_DLP_CONCURRENCY = Number(process.env.YT_DLP_CONCURRENCY ?? 20); +const YT_DLP_BIN = process.env.YT_DLP_PATH ?? "yt-dlp"; +const YT_DLP_TIMEOUT_MS = 60_000; + +const transcriptCache = new Map(); + +let activeYtDlp = 0; +const ytDlpWaiters: Array<() => void> = []; + +async function withYtDlpLimit(task: () => Promise): Promise { + if (activeYtDlp >= YT_DLP_CONCURRENCY) { + await new Promise((resolve) => ytDlpWaiters.push(resolve)); + } + activeYtDlp += 1; + try { + return await task(); + } finally { + activeYtDlp -= 1; + ytDlpWaiters.shift()?.(); + } +} + +function toTimestamp(h: number, m: number, s: number): string { + const total = h * 3600 + m * 60 + s; + const sec = String(total % 60).padStart(2, "0"); + const min = Math.floor(total / 60) % 60; + const hr = Math.floor(total / 3600); + return hr > 0 + ? `${hr}:${String(min).padStart(2, "0")}:${sec}` + : `${min}:${sec}`; +} + +function cleanVtt(vtt: string): string { + const entries: string[] = []; + let time = "0:00"; + let previous: string | null = null; + + for (const rawLine of vtt.split(/\r?\n/)) { + const line = rawLine.trim(); + if ( + !line || + line.startsWith("WEBVTT") || + line.startsWith("Kind:") || + line.startsWith("Language:") || + /^\d+$/.test(line) + ) { + continue; + } + + const cue = line.match(/^(\d{2}):(\d{2}):(\d{2})[.,]\d+\s*-->/); + if (cue) { + time = toTimestamp(Number(cue[1]), Number(cue[2]), Number(cue[3])); + continue; + } + + const text = line.replace(/<[^>]+>/g, "").trim(); + if (!text || text === previous) continue; + entries.push(`[${time}] ${text}`); + previous = text; + } + + return entries.join("\n"); +} + +function langOf(fileName: string): string { + return fileName.split(".").at(-2) ?? ""; +} + +async function extractViaYtDlp( + videoId: string, +): Promise { + let workDir: string | null = null; + try { + workDir = await mkdtemp(path.join(tmpdir(), "yt-transcript-")); + + const args = [ + "--skip-download", + "--write-sub", + "--write-auto-sub", + "--sub-lang", + PREFERRED_LANGS.join(","), + "--sub-format", + "vtt", + "--retries", + "3", + "--retry-sleep", + "5", + "--ignore-errors", + "--no-abort-on-error", + "--sleep-subtitles", + "1", + "--no-warnings", + "-o", + path.join(workDir, `${videoId}.%(ext)s`), + `https://www.youtube.com/watch?v=${videoId}`, + ]; + + let rawStderr = ""; + try { + const { stderr } = await execFileAsync(YT_DLP_BIN, args, { + timeout: YT_DLP_TIMEOUT_MS, + }); + rawStderr = stderr ?? ""; + } catch (execError) { + const err = execError as { stderr?: string; message?: string }; + rawStderr = err.stderr ?? err.message ?? String(execError); + } + + const files = (await readdir(workDir)).filter((f) => f.endsWith(".vtt")); + if (files.length === 0) { + return { error: rawStderr.trim() || "자막 파일 없음(원인 미상)" }; + } + + const preferredFile = + PREFERRED_LANGS.map((code) => files.find((f) => langOf(f) === code)).find( + Boolean, + ) ?? files[0]; + + const text = cleanVtt( + await readFile(path.join(workDir, preferredFile), "utf-8"), + ); + if (!text) return { error: "yt-dlp: 자막 텍스트가 비어 있습니다." }; + + return { text }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } finally { + if (workDir) { + await rm(workDir, { recursive: true, force: true }).catch(() => {}); + } + } +} + +export async function getYoutubeTranscript( + videoId: string, +): Promise { + const cached = transcriptCache.get(videoId); + if (cached) return { text: cached }; + + const result = await withYtDlpLimit(() => extractViaYtDlp(videoId)); + if (!("error" in result)) transcriptCache.set(videoId, result.text); + return result; +} diff --git a/src/entities/music/ui/MusicListItem.tsx b/src/entities/music/ui/MusicListItem.tsx index 85535508..e467a5ff 100644 --- a/src/entities/music/ui/MusicListItem.tsx +++ b/src/entities/music/ui/MusicListItem.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import type { Music } from "@/entities/music/model/music"; import { getYoutubeThumbnailUrl } from "@/entities/music/lib/youtube"; import Heart from "@/shared/asset/svg/Heart"; @@ -11,61 +12,91 @@ interface MusicListItemProps { isLikePending?: boolean; onDelete?: () => void; isDeletePending?: boolean; + thumbnailOverlay?: ReactNode; + aiStatus?: ReactNode; + onSelect?: () => void; + isSelected?: boolean; } function formatAppliedDate(appliedAt: string) { return formatTime(appliedAt); } +const THUMBNAIL_CLASS = + "bg-sub-4 relative h-14 w-24 shrink-0 overflow-hidden rounded-lg sm:h-17 sm:w-30 2xl:h-22 2xl:w-39"; + export function MusicListItem({ music, onToggleLike, isLikePending = false, onDelete, isDeletePending = false, + thumbnailOverlay, + aiStatus, + onSelect, + isSelected = false, }: MusicListItemProps) { const thumbnailUrl = music.thumbnailUrl ?? getYoutubeThumbnailUrl(music.musicUrl); const title = music.title ?? music.musicUrl; + const thumbnailInner = ( + <> + {thumbnailUrl && ( + {title} + )} + {music.durationText && ( + + {music.durationText} + + )} + {thumbnailOverlay} + + ); + + const info = ( +
+

+ {title} +

+
+ + {music.studentNumber} {music.userName} + + + {formatAppliedDate(music.appliedAt)} + +
+ {aiStatus} +
+ ); + return (
-
- - {thumbnailUrl && ( - {title} - )} - {music.durationText && ( - - {music.durationText} - - )} - - -
-

- {title} -

-
- - {music.studentNumber} {music.userName} - - - {formatAppliedDate(music.appliedAt)} - -
+ {thumbnailInner} + {info} + + ) : ( + -
+ )}
{onDelete && ( diff --git a/src/entities/music/ui/YoutubeEmbed.tsx b/src/entities/music/ui/YoutubeEmbed.tsx new file mode 100644 index 00000000..ea1ca703 --- /dev/null +++ b/src/entities/music/ui/YoutubeEmbed.tsx @@ -0,0 +1,25 @@ +interface YoutubeEmbedProps { + videoId: string; + start?: number; + autoPlay?: boolean; +} + +export function YoutubeEmbed({ + videoId, + start = 0, + autoPlay = false, +}: YoutubeEmbedProps) { + return ( +
+