import { requireAdminSession } from '../../../utils/admin-auth' import { listMediaItems } from '../../../utils/media-library' import { getMediaCatalogMode } from '../../../utils/media-catalog-mode' import { getMediaCatalogReadiness, listMediaCatalogItems, listPagedMediaCatalogItems } from '../../../repositories/media-catalog-repository' import { isPageCoverThumbnailUrl, isPostBodyThumbnailUrl, isPostCardThumbnailUrl } from '../../../../lib/post-card-thumbnail-url.js' /** 관리자 미디어 페이지 표시 개수 허용값 */ const mediaPageSizeOptions = [50, 100, 200] /** * 미디어 항목 종류를 반환한다. * @param {Object} item - 미디어 항목 * @returns {'image'|'video'|'audio'|'file'} 미디어 종류 */ const getMediaItemKind = (item) => item?.kind || 'image' /** * 미디어 항목 잠금 여부를 반환한다. * @param {Object} item - 미디어 항목 * @returns {boolean} 잠금 여부 */ const isMediaItemLocked = (item) => Boolean(item?.usage?.length) || Boolean(item?.avatarOwner) /** * 프로필 이미지 파일인지 확인한다. * @param {Object} item - 미디어 항목 * @returns {boolean} 프로필 이미지이면 true */ const isProfileThumbnailItem = (item) => Boolean(item?.url?.includes('/members/avatars/')) /** * 관리자 미디어 탭 id를 정규화한다. * @param {unknown} value - 요청 탭 * @returns {'library'|'postThumbnails'|'postBodyThumbnails'|'thumbnails'} 탭 id */ const normalizeMediaTab = (value) => { const tab = String(value || 'library') return ['library', 'postThumbnails', 'postBodyThumbnails', 'thumbnails'].includes(tab) ? tab : 'library' } /** * 관리자 미디어 종류 필터를 정규화한다. * @param {unknown} value - 요청 미디어 종류 * @returns {'all'|'image'|'video'|'audio'|'file'} 미디어 종류 */ const normalizeMediaKind = (value) => { const kind = String(value || 'all') return ['all', 'image', 'video', 'audio', 'file'].includes(kind) ? kind : 'all' } /** * 관리자 미디어 페이지 표시 개수를 정규화한다. * @param {unknown} value - 요청 표시 개수 * @returns {number} 표시 개수 */ const normalizeMediaPageSize = (value) => { const pageSize = Number(value) || 50 return mediaPageSizeOptions.includes(pageSize) ? pageSize : 50 } /** * 관리자 미디어 탭별 항목인지 확인한다. * @param {Object} item - 미디어 항목 * @param {'library'|'postThumbnails'|'postBodyThumbnails'|'thumbnails'} tab - 탭 id * @returns {boolean} 탭에 속하면 true */ const matchesMediaTab = (item, tab) => { const url = item?.url if (tab === 'thumbnails') { return isProfileThumbnailItem(item) } if (tab === 'postThumbnails') { return isPostCardThumbnailUrl(url) || isPageCoverThumbnailUrl(url) } if (tab === 'postBodyThumbnails') { return isPostBodyThumbnailUrl(url) } return !isProfileThumbnailItem(item) && !isPostCardThumbnailUrl(url) && !isPageCoverThumbnailUrl(url) && !isPostBodyThumbnailUrl(url) } /** * 폴더와 하위 폴더에 포함되는지 확인한다. * @param {Object} item - 미디어 항목 * @param {string} folder - 폴더 경로 * @returns {boolean} 포함 여부 */ const matchesMediaFolder = (item, folder) => !folder || item.category === folder || item.category?.startsWith(`${folder}/`) /** * 검색어와 미디어 항목이 일치하는지 확인한다. * @param {Object} item - 미디어 항목 * @param {string} query - 소문자 검색어 * @returns {boolean} 일치 여부 */ const matchesMediaQuery = (item, query) => { if (!query) { return true } const usageTitles = Array.isArray(item?.usage) ? item.usage.map((usage) => usage.title) : [] return [ item?.name, item?.title, ...usageTitles ].some((value) => String(value || '').toLowerCase().includes(query)) } /** * 미디어 폴더별 개수를 계산한다. * @param {Array} items - 미디어 항목 목록 * @returns {Record} 폴더별 개수 */ const countMediaFolders = (items) => items.reduce((counts, item) => { const segments = String(item.category || '미분류').split('/').filter(Boolean) if (!segments.length) { counts['미분류'] = (counts['미분류'] || 0) + 1 return counts } segments.reduce((parentPath, segment) => { const folder = parentPath ? `${parentPath}/${segment}` : segment counts[folder] = (counts[folder] || 0) + 1 return folder }, '') return counts }, {}) /** * 미디어 종류별 개수를 계산한다. * @param {Array} items - 미디어 항목 목록 * @returns {Record} 종류별 개수 */ const countMediaKinds = (items) => items.reduce((counts, item) => { const kind = getMediaItemKind(item) counts[kind] = (counts[kind] || 0) + 1 counts.all += 1 return counts }, { all: 0, image: 0, video: 0, audio: 0, file: 0 }) /** * 미디어 페이지 응답을 구성한다. * @param {Array} items - 전체 미디어 목록 * @param {Record} query - 요청 쿼리 * @returns {Object} 페이지 미디어 응답 */ export const createPagedMediaResponse = (items, query) => { const tab = normalizeMediaTab(query.tab) const pageSize = normalizeMediaPageSize(query.pageSize) const requestedPage = Math.max(1, Number(query.page) || 1) const mediaKind = normalizeMediaKind(query.kind) const searchQuery = String(query.search || '').trim().toLowerCase() const folder = tab === 'library' ? String(query.folder || '').trim() : '' const unusedOnly = query.unused === '1' || query.unused === 'true' const libraryItems = items.filter((item) => matchesMediaTab(item, 'library')) const tabItems = items.filter((item) => matchesMediaTab(item, tab)) const folderItems = tab === 'library' ? tabItems.filter((item) => matchesMediaFolder(item, folder)) : tabItems const kindItems = folderItems.filter((item) => mediaKind === 'all' || getMediaItemKind(item) === mediaKind) const usageFilteredItems = unusedOnly ? kindItems.filter((item) => !isMediaItemLocked(item)) : kindItems const filteredItems = usageFilteredItems.filter((item) => matchesMediaQuery(item, searchQuery)) const pageCount = Math.max(1, Math.ceil(filteredItems.length / pageSize)) const page = Math.min(requestedPage, pageCount) const startIndex = (page - 1) * pageSize return { items: filteredItems.slice(startIndex, startIndex + pageSize), pagination: { page, pageSize, total: filteredItems.length, pageCount }, counts: { tabs: { library: libraryItems.length, postThumbnails: items.filter((item) => matchesMediaTab(item, 'postThumbnails')).length, postBodyThumbnails: items.filter((item) => matchesMediaTab(item, 'postBodyThumbnails')).length, thumbnails: items.filter((item) => matchesMediaTab(item, 'thumbnails')).length }, folders: countMediaFolders(libraryItems), kinds: countMediaKinds(folderItems), usage: { all: kindItems.length, unused: kindItems.filter((item) => !isMediaItemLocked(item)).length } } } } /** * 관리자 미디어 목록 API * @param {import('h3').H3Event} event - 요청 이벤트 * @returns {Promise|Object>} 미디어 목록 또는 페이지 응답 */ export default defineEventHandler(async (event) => { requireAdminSession(event) const mode = getMediaCatalogMode() const query = getQuery(event) const paged = query.paged === '1' || query.paged === 'true' let items if (mode === 'database') { try { const readiness = await getMediaCatalogReadiness() if (!readiness.ready) { console.warn('[media-catalog] DB 카탈로그 준비 미완료, 파일시스템으로 대체', { reason: readiness.reason }) throw new Error('MEDIA_CATALOG_NOT_READY') } if (paged) { return await listPagedMediaCatalogItems(query) } items = await listMediaCatalogItems() } catch { console.warn('[media-catalog] DB 목록 조회 실패, 파일시스템으로 대체') items = await listMediaItems() } } else { items = await listMediaItems() } if (paged) { return createPagedMediaResponse(items, query) } return items })