feat(ai-character): 오디오 콘텐츠 관리 기능 구현

This commit is contained in:
Yu Sung
2026-08-01 01:30:36 +09:00
parent 286c536ba7
commit f9ed4d0094
26 changed files with 3600 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
import type { ReleaseScheduleValue } from "@/features/audio-contents/components/ReleaseScheduleField";
import type { AudioContentDetail } from "@/features/audio-contents/model/types";
import type { AudioContentCreateRequest, AudioContentUpdateRequest } from "@/features/audio-contents/schemas/audio-content-schema";
import { validateAudioCoverFile } from "@/features/audio-contents/validation/audio-cover-policy";
import { validateAudioFile } from "@/shared/validation/audio-file-policy";
import { formatCanPriceInput, parseCanPriceInput } from "@/shared/validation/can-price";
export function createInitialPrice(price: number | undefined): string {
return price === undefined ? "" : `${price.toLocaleString("ko-KR")}`;
}
export function parsePrice(value: string): number | null {
return parseCanPriceInput(value);
}
export function formatPrice(value: string): string {
return formatCanPriceInput(value);
}
const localDateTimePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
function toUtcMilliseconds(value: string, offsetHours: number): number {
if (!localDateTimePattern.test(value)) {
return Number.NaN;
}
return Date.UTC(Number(value.slice(0, 4)), Number(value.slice(5, 7)) - 1, Number(value.slice(8, 10)), Number(value.slice(11, 13)) - offsetHours, Number(value.slice(14, 16)));
}
export function toUtcReleaseDate(value: string, timezone: "Asia/Seoul"): string {
const offsetHours = timezone === "Asia/Seoul" ? 9 : 0;
return new Date(toUtcMilliseconds(value, offsetHours)).toISOString().replace(".000Z", "Z");
}
export function isFutureLocalDateTime(value: string): boolean {
const selected = toUtcMilliseconds(value, 9);
return Number.isFinite(selected) && selected > Date.now();
}
export type AudioContentCreateSettings = Pick<AudioContentCreateRequest, "purchaseOption" | "limited" | "isAdult" | "isGeneratePreview" | "isOnlyRental" | "isPointAvailable" | "isCommentAvailable" | "isFullDetailVisible" | "previewStartTime" | "previewEndTime" | "languageCode">;
export const defaultAudioContentCreateSettings = {
purchaseOption: "BOTH",
limited: null,
isAdult: false,
isGeneratePreview: false,
isOnlyRental: false,
isPointAvailable: false,
isCommentAvailable: false,
isFullDetailVisible: true,
previewStartTime: null,
previewEndTime: null,
languageCode: null,
} satisfies AudioContentCreateSettings;
export function audioErrorMessage(file: File | null, mode: "create" | "edit"): string | undefined {
if (mode === "edit") {
return undefined;
}
if (file === null) {
return "오디오 파일을 선택하세요.";
}
const result = validateAudioFile(file);
if (result.ok) {
return undefined;
}
if (result.reason === "size") {
return "오디오 파일은 1,024,000,000 bytes 이하만 업로드할 수 있습니다.";
}
if (result.reason === "mimeExtensionCombination") {
return "확장자와 MIME 조합이 올바른 MP3, AAC, M4A 파일만 업로드하세요.";
}
return "MP3, AAC, M4A 파일만 업로드하세요.";
}
export function coverErrorMessage(file: File | null, mode: "create" | "edit"): string | undefined {
if (file === null) {
return mode === "create" ? "커버 이미지를 선택하세요." : undefined;
}
const result = validateAudioCoverFile(file);
if (result.ok) {
return undefined;
}
if (result.reason === "size") {
return "커버 이미지는 10MB 이하만 업로드할 수 있습니다.";
}
return "JPEG 또는 PNG 파일만 업로드하세요.";
}
export function toCreateRequest(params: { readonly detail: string; readonly price: number; readonly releaseSchedule: ReleaseScheduleValue; readonly settings: AudioContentCreateSettings; readonly tags: string; readonly themeId: number; readonly title: string }): AudioContentCreateRequest {
return {
title: params.title,
detail: params.detail,
tags: params.tags,
price: params.price,
purchaseOption: params.settings.purchaseOption,
limited: params.settings.limited,
releaseDate: params.releaseSchedule.publishMode === "immediate" ? null : toUtcReleaseDate(params.releaseSchedule.releaseDateTime, "Asia/Seoul"),
themeId: params.themeId,
isAdult: params.settings.isAdult,
isGeneratePreview: params.settings.isGeneratePreview,
isOnlyRental: params.settings.isOnlyRental,
isPointAvailable: params.settings.isPointAvailable,
isCommentAvailable: params.settings.isCommentAvailable,
isFullDetailVisible: params.settings.isFullDetailVisible,
previewStartTime: params.settings.previewStartTime,
previewEndTime: params.settings.previewEndTime,
languageCode: params.settings.languageCode,
};
}
export function toUpdateRequest(params: { readonly detail: string; readonly price: number; readonly tags: string; readonly title: string; readonly audio: AudioContentDetail }): AudioContentUpdateRequest {
return {
title: params.title,
detail: params.detail,
tags: params.tags,
price: params.price,
isAdult: params.audio.isAdult,
isPointAvailable: params.audio.isAvailableUsePoint,
isCommentAvailable: params.audio.isCommentAvailable,
};
}