import { readdir, stat } from 'node:fs/promises' import { basename, extname, join, relative, sep } from 'node:path' import { detectMediaKind, detectMediaRole, escapePostgresLikeLiteral, extractMediaReferences, normalizeMediaCatalogUrl } from '../../lib/media-catalog.js' import { getPageCoverThumbnailUrl, getPostBodyPreviewThumbnailUrl, getPostBodyThumbnailUrl, isPageCoverThumbnailUrl, isPostBody1600ThumbnailUrl, isPostBody720ThumbnailUrl, isPostCardThumbnailUrl } from '../../lib/post-card-thumbnail-url.js' import { getPostgresClient } from './postgres-client.js' import { getUploadRoot } from '../utils/upload-root.js' const uploadRoot = getUploadRoot() const catalogFilePattern = /\.(jpe?g|png|webp|gif|mp4|webm|mov|mp3|wav|ogg|m4a|pdf|zip|txt|csv|docx|xlsx|pptx)$/i /** * 게시물 원본 URL의 카드 썸네일 URL을 만든다. * @param {string} url - 게시물 원본 이미지 URL * @returns {string} 카드 썸네일 URL */ const getCatalogPostThumbnailUrl = (url) => String(url || '').replace( /\/([^/]+)\.(?:jpe?g|png|webp)$/i, '/thumbs/$1-card.webp' ) /** * 카탈로그 오류 상세를 제한된 문자열로 정리한다. * @param {unknown} error - 원본 오류 * @returns {string} 저장 가능한 오류 코드 */ const getCatalogErrorDetail = (error) => String(error?.code || error?.message || 'MEDIA_CATALOG_ERROR').slice(0, 300) /** * 참조 필드의 관리자 표시 이름을 반환한다. * @param {string} field - 참조 필드 * @returns {string} 표시 이름 */ const getReferenceFieldLabel = (field) => ({ content: '본문', featuredImage: '대표 이미지', ogImage: '공유 이미지', avatarUrl: '프로필 썸네일', headerImageUrl: '태그 헤더 이미지', logoUrl: '사이트 로고', faviconUrl: '파비콘', homeCoverImageUrl: '메인 화면 라이트 이미지', homeCoverDarkImageUrl: '메인 화면 다크 이미지', cachedFaviconUrl: '추천 사이트 아이콘', url: '네비게이션 URL' })[field] || field /** * 참조 행을 기존 미디어 사용처 계약으로 변환한다. * @param {Object} reference - DB 참조 행 * @returns {Object} 사용처 */ const mapMediaReferenceUsage = (reference) => { const type = reference.reference_type === 'site_setting' ? 'settings' : reference.reference_type const typeMetadata = { post: { typeLabel: '게시물', adminUrl: `/admin/posts/${reference.reference_id}`, publicUrl: reference.reference_slug ? `/post/${reference.reference_slug}` : '' }, page: { typeLabel: '페이지', adminUrl: `/admin/pages/${reference.reference_id}`, publicUrl: reference.reference_slug ? `/pages/${reference.reference_slug}` : '' }, tag: { typeLabel: '태그', adminUrl: `/admin/tags/${reference.reference_id}`, publicUrl: reference.reference_slug ? `/tag/${reference.reference_slug}` : '' }, user: { typeLabel: '회원', adminUrl: `/admin/members/${reference.reference_id}`, publicUrl: '' }, navigation: { typeLabel: '네비게이션', adminUrl: '/admin/navigation', publicUrl: reference.reference_slug || '' }, settings: { typeLabel: '사이트 설정', adminUrl: '/admin/settings', publicUrl: '/' } }[type] || { typeLabel: '참조', adminUrl: '', publicUrl: '' } return { type, typeLabel: typeMetadata.typeLabel, id: reference.reference_id, title: reference.reference_title, slug: reference.reference_slug, status: reference.reference_status, location: reference.reference_field, label: getReferenceFieldLabel(reference.reference_field), adminUrl: typeMetadata.adminUrl, publicUrl: typeMetadata.publicUrl } } /** * 사용자 행을 기존 avatarOwner 계약으로 변환한다. * @param {Object} row - 사용자 행 * @returns {Object} 아바타 소유자 */ const mapAvatarOwner = (row) => ({ id: row.id, username: row.username, email: row.email, roleCode: String(row.user_role || 'member'), lastSeenAt: row.last_seen_at ? row.last_seen_at.toISOString() : null, lastSeenIp: row.last_seen_ip || '' }) /** * 파생 파일의 원본 행 id를 찾는다. * @param {ReturnType} sql - PostgreSQL 클라이언트 * @param {string} storageKey - 파생 저장 키 * @returns {Promise} 원본 행 id */ const findParentMediaId = async (sql, storageKey) => { const { parentStorageKey } = detectMediaRole(storageKey) if (!parentStorageKey) { return null } const stem = parentStorageKey.replace(/\.[^.]+$/, '') const rows = await sql` SELECT id FROM media_metadata WHERE storage_key LIKE ${`${stem}.%`} AND role = 'original' ORDER BY id ASC LIMIT 1 ` return rows[0]?.id || null } /** * 앱이 성공적으로 반영한 파일 변경 뒤 상태 집계를 현재 active 행으로 맞춘다. * 상태 단일 행을 잠근 뒤 집계해 동시 업로드의 마지막 갱신이 오래된 값을 쓰지 않게 한다. * @param {ReturnType} [client] - PostgreSQL 클라이언트 * @returns {Promise} 갱신 후 준비 상태 */ export const refreshManagedMediaCatalogState = async (client = getPostgresClient()) => { if (!client) { return false } return client.begin(async (transaction) => { const stateRows = await transaction` SELECT ready, reconciled_at, error_detail FROM media_catalog_state WHERE singleton = true FOR UPDATE ` if (!stateRows[0]) { return false } const summaryRows = await transaction` SELECT count(*) FILTER (WHERE status = 'active')::bigint AS active_count, COALESCE(sum(size_bytes) FILTER (WHERE status = 'active'), 0)::bigint AS active_bytes, count(*) FILTER (WHERE status IN ('staging', 'error'))::int AS incomplete_count FROM media_metadata ` const summary = summaryRows[0] const ready = Boolean(stateRows[0].ready) && Boolean(stateRows[0].reconciled_at) && Number(summary.incomplete_count || 0) === 0 await transaction` UPDATE media_catalog_state SET ready = ${ready}, filesystem_count = ${summary.active_count}, filesystem_bytes = ${summary.active_bytes}, catalog_count = ${summary.active_count}, catalog_bytes = ${summary.active_bytes}, error_detail = ${ready ? null : Number(summary.incomplete_count || 0) > 0 ? 'INCOMPLETE_MEDIA_ROWS' : stateRows[0].error_detail}, updated_at = now() WHERE singleton = true ` return ready }) } /** * 앱에서 실제 삭제를 완료한 미디어 카탈로그 행을 제거하고 상태 집계를 맞춘다. * @param {Array} urls - 삭제된 공개 URL 목록 * @returns {Promise} 삭제한 카탈로그 행 수 */ export const deleteMediaCatalogEntries = async (urls) => { const sql = getPostgresClient() const normalizedUrls = [...new Set(urls.map((url) => { try { return normalizeMediaCatalogUrl(url).url } catch { return '' } }).filter(Boolean))] if (!sql || !normalizedUrls.length) { return 0 } const rows = await sql` DELETE FROM media_metadata WHERE url IN ${sql(normalizedUrls)} RETURNING id ` await refreshManagedMediaCatalogState(sql).catch(() => { console.warn('[media-catalog] 삭제 후 상태 집계 갱신 실패') }) return rows.length } /** * 실제 파일 정보를 미디어 카탈로그에 upsert한다. * @param {string} inputUrl - 공개 미디어 URL * @param {{ category?: string, origin?: string, role?: string, refreshState?: boolean }} [options] - 기록 옵션 * @returns {Promise} 카탈로그 행 */ export const upsertMediaCatalogFile = async (inputUrl, options = {}) => { const sql = getPostgresClient() if (!sql) { return null } const { url, storageKey } = normalizeMediaCatalogUrl(inputUrl) const filePath = join(uploadRoot, ...storageKey.split('/')) const fileStat = await stat(filePath) const detected = detectMediaRole(storageKey) const role = options.role || detected.role const origin = options.origin || (role === 'original' ? 'upload' : 'derived') const parentId = await findParentMediaId(sql, storageKey) const rows = await sql` INSERT INTO media_metadata ( url, category, file_name, storage_key, extension, kind, role, size_bytes, modified_at, parent_id, origin, status, last_seen_at, missing_since, error_detail ) VALUES ( ${url}, ${String(options.category || '미분류').trim() || '미분류'}, ${basename(storageKey)}, ${storageKey}, ${extname(storageKey).toLowerCase()}, ${detectMediaKind(storageKey)}, ${role}, ${fileStat.size}, ${fileStat.mtime}, ${parentId}, ${origin}, 'active', now(), NULL, NULL ) ON CONFLICT (url) DO UPDATE SET category = CASE WHEN ${Boolean(options.category)} THEN EXCLUDED.category ELSE media_metadata.category END, file_name = EXCLUDED.file_name, storage_key = EXCLUDED.storage_key, extension = EXCLUDED.extension, kind = EXCLUDED.kind, role = EXCLUDED.role, size_bytes = EXCLUDED.size_bytes, modified_at = EXCLUDED.modified_at, parent_id = COALESCE(EXCLUDED.parent_id, media_metadata.parent_id), origin = CASE WHEN media_metadata.origin = 'external' THEN EXCLUDED.origin ELSE media_metadata.origin END, status = 'active', last_seen_at = now(), missing_since = NULL, error_detail = NULL, updated_at = now() RETURNING * ` if (options.refreshState !== false) { await refreshManagedMediaCatalogState(sql).catch(() => { console.warn('[media-catalog] 관리 파일 상태 집계 갱신 실패') }) } return rows[0] || null } /** * 기존 기능을 막지 않는 최선 노력 방식으로 파일을 카탈로그에 기록한다. * @param {string} url - 공개 미디어 URL * @param {{ category?: string, origin?: string, role?: string, refreshState?: boolean }} [options] - 기록 옵션 * @returns {Promise} 기록 성공 여부 */ export const recordMediaCatalogFile = async (url, options = {}) => { try { await upsertMediaCatalogFile(url, options) return true } catch (error) { const sql = getPostgresClient() if (sql) { try { const normalized = normalizeMediaCatalogUrl(url) await sql` INSERT INTO media_metadata ( url, category, file_name, storage_key, extension, kind, role, origin, status, error_detail ) VALUES ( ${normalized.url}, ${String(options.category || '미분류').trim() || '미분류'}, ${basename(normalized.storageKey)}, ${normalized.storageKey}, ${extname(normalized.storageKey).toLowerCase()}, ${detectMediaKind(normalized.storageKey)}, ${options.role || detectMediaRole(normalized.storageKey).role}, ${options.origin || 'upload'}, 'error', ${getCatalogErrorDetail(error)} ) ON CONFLICT (url) DO UPDATE SET status = 'error', error_detail = ${getCatalogErrorDetail(error)}, updated_at = now() ` } catch { // 기존 파일 작업 성공을 카탈로그 보조 기록 실패로 되돌리지 않는다. } } console.warn('[media-catalog] 파일 인덱스 기록 실패') return false } } /** * 한 대상의 미디어 참조를 현재 필드 값으로 교체한다. * @param {string} referenceType - 참조 종류 * @param {string|number} referenceId - 참조 대상 id * @param {Record} fields - URL을 포함할 수 있는 필드 * @param {{ title?: string, slug?: string, status?: string }} [metadata] - 표시 메타 * @returns {Promise} 저장한 참조 수 */ export const replaceMediaReferences = async (referenceType, referenceId, fields, metadata = {}) => { const sql = getPostgresClient() if (!sql) { return 0 } const references = extractMediaReferences(fields) const targetId = String(referenceId) await sql.begin(async (tx) => { await tx` DELETE FROM media_references WHERE reference_type = ${referenceType} AND reference_id = ${targetId} ` for (const reference of references) { await tx` INSERT INTO media_references ( media_id, media_url, reference_type, reference_id, reference_field, reference_title, reference_slug, reference_status ) SELECT media_metadata.id, ${reference.mediaUrl}, ${referenceType}, ${targetId}, ${reference.referenceField}, ${String(metadata.title || '')}, ${String(metadata.slug || '')}, ${String(metadata.status || '')} FROM (SELECT 1) AS source LEFT JOIN media_metadata ON media_metadata.url = ${reference.mediaUrl} ON CONFLICT (media_url, reference_type, reference_id, reference_field) DO UPDATE SET media_id = EXCLUDED.media_id, reference_title = EXCLUDED.reference_title, reference_slug = EXCLUDED.reference_slug, reference_status = EXCLUDED.reference_status, updated_at = now() ` } }) return references.length } /** * DB 행 묶음을 기존 관리자 미디어 항목 계약으로 변환한다. * @param {Array} outputRows - 응답에 포함할 미디어 행 * @param {Array} contextRows - parent·child 판별용 행 * @param {Array} referenceRows - 관련 참조 행 * @param {Array} avatarRows - 관련 사용자 행 * @returns {Array} 미디어 항목 목록 */ const buildMediaCatalogItems = (outputRows, contextRows, referenceRows, avatarRows) => { const referencesByUrl = new Map() for (const reference of referenceRows) { const usages = referencesByUrl.get(reference.media_url) || [] usages.push(mapMediaReferenceUsage(reference)) referencesByUrl.set(reference.media_url, usages) } const avatarOwnerByUrl = new Map() for (const avatar of avatarRows) { if (avatar.avatar_url && !avatarOwnerByUrl.has(avatar.avatar_url)) { avatarOwnerByUrl.set(avatar.avatar_url, mapAvatarOwner(avatar)) } } const byId = new Map(contextRows.map((row) => [String(row.id), row])) const childrenByParentId = new Map() for (const row of contextRows) { if (!row.parent_id) { continue } const children = childrenByParentId.get(String(row.parent_id)) || [] children.push(row) childrenByParentId.set(String(row.parent_id), children) } return outputRows.map((row) => { const original = row.parent_id ? byId.get(String(row.parent_id)) : null const directUsage = referencesByUrl.get(row.url) || [] const inheritedUsage = original ? (referencesByUrl.get(original.url) || []).filter((usage) => { if (row.role === 'card') { return usage.location === 'featuredImage' } if (row.role === 'page_cover') { return usage.type === 'page' && usage.location === 'featuredImage' } return usage.location === 'content' }).map((usage) => ({ ...usage, location: row.role === 'card' ? 'featuredImageThumbnail' : row.role === 'page_cover' ? 'pageCoverThumbnail' : 'contentBodyImage', sourceUrl: original.url })) : [] const children = childrenByParentId.get(String(row.id)) || [] const cardChild = children.find((child) => child.role === 'card' && isPostCardThumbnailUrl(child.url)) const pageCoverChild = children.find((child) => child.role === 'page_cover' && isPageCoverThumbnailUrl(child.url)) const body1600Child = children.find((child) => child.role === 'body' && isPostBody1600ThumbnailUrl(child.url)) const body720Child = children.find((child) => child.role === 'body' && isPostBody720ThumbnailUrl(child.url)) const isPostUploadImage = row.role === 'original' && row.kind === 'image' && row.storage_key.startsWith('posts/') && !String(row.storage_key).includes('/thumbs/') const isFeaturedImage = directUsage.some((usage) => usage.type === 'post' && usage.location === 'featuredImage') const isPageCoverImage = directUsage.some((usage) => usage.type === 'page' && usage.location === 'featuredImage') const isContentImage = directUsage.some((usage) => usage.location === 'content') const avatarOwner = avatarOwnerByUrl.get(row.url) || null const usage = [...directUsage, ...inheritedUsage].filter((entry) => !( entry.type === 'user' && entry.location === 'avatarUrl' )) return { url: row.url, name: row.file_name, title: basename(row.file_name, row.extension || extname(row.file_name)), size: Number(row.size_bytes || 0), extension: row.extension || extname(row.file_name).toLowerCase(), kind: row.kind, updatedAt: (row.modified_at || row.updated_at).toISOString(), category: row.category, usage, thumbnailStatus: cardChild ? { role: 'original', variant: 'postCard', thumbnailUrl: cardChild.url, hasThumbnail: true, isFallbackActive: false } : pageCoverChild ? { role: 'original', variant: 'pageCover', thumbnailUrl: pageCoverChild.url, hasThumbnail: true, isFallbackActive: false } : isPostUploadImage && isFeaturedImage ? { role: 'original', variant: 'postCard', thumbnailUrl: getCatalogPostThumbnailUrl(row.url), hasThumbnail: false, isFallbackActive: true } : isPostUploadImage && isPageCoverImage ? { role: 'original', variant: 'pageCover', thumbnailUrl: getPageCoverThumbnailUrl(row.url), hasThumbnail: false, isFallbackActive: true } : isPostUploadImage ? { role: 'original', variant: 'postCard', thumbnailUrl: getCatalogPostThumbnailUrl(row.url), hasThumbnail: false, isFallbackActive: false } : ['card', 'page_cover'].includes(row.role) ? { role: row.role === 'card' ? 'card' : 'pageCover', originalUrl: original?.url || '' } : null, bodyThumbnailStatus: isPostUploadImage ? { role: 'original', thumbnailUrl: body1600Child?.url || getPostBodyThumbnailUrl(row.url), previewThumbnailUrl: body720Child?.url || getPostBodyPreviewThumbnailUrl(row.url), hasThumbnail: Boolean(body1600Child), hasPreviewThumbnail: Boolean(body720Child), isFallbackActive: isContentImage && !body1600Child } : row.role === 'body' ? { role: 'body', originalUrl: original?.url || '' } : null, avatarOwner } }) } /** * 선택된 미디어 행의 parent·child·참조·아바타 소유자를 함께 조회한다. * @param {ReturnType} sql - PostgreSQL 클라이언트 * @param {Array} rows - 응답 미디어 행 * @returns {Promise>} 기존 계약 미디어 항목 */ const hydrateMediaCatalogRows = async (sql, rows) => { if (!rows.length) { return [] } const rowIds = rows.map((row) => row.id) const parentIds = rows.map((row) => row.parent_id).filter(Boolean) const contextRows = await sql` SELECT * FROM media_metadata WHERE status = 'active' AND ( id IN ${sql([...new Set([...rowIds, ...parentIds])])} OR parent_id IN ${sql(rowIds)} ) ` const contextById = new Map(contextRows.map((row) => [String(row.id), row])) const referenceUrls = [...new Set(rows.flatMap((row) => { const parent = row.parent_id ? contextById.get(String(row.parent_id)) : null return [row.url, parent?.url].filter(Boolean) }))] const [referenceRows, avatarRows] = await Promise.all([ sql` SELECT * FROM media_references WHERE media_url IN ${sql(referenceUrls)} ORDER BY id ASC `, sql` SELECT id, username, email, user_role, last_seen_at, last_seen_ip, avatar_url FROM users WHERE avatar_url IN ${sql(rows.map((row) => row.url))} ` ]) return buildMediaCatalogItems(rows, contextRows, referenceRows, avatarRows) } /** * DB 카탈로그 준비 상태를 DB 조회만으로 판정한다. * @returns {Promise<{ ready: boolean, reason: string }>} 준비 상태 */ export const getMediaCatalogReadiness = async () => { const sql = getPostgresClient() if (!sql) { return { ready: false, reason: 'database-unavailable' } } const rows = await sql` SELECT state.ready, state.reconciled_at, state.filesystem_count, state.filesystem_bytes, state.catalog_count, state.catalog_bytes, ( SELECT count(*)::int FROM media_metadata WHERE status IN ('staging', 'error') ) AS incomplete_count, ( SELECT count(*)::int FROM media_metadata WHERE status = 'active' ) AS live_catalog_count, ( SELECT COALESCE(sum(size_bytes), 0)::bigint FROM media_metadata WHERE status = 'active' ) AS live_catalog_bytes FROM media_catalog_state AS state WHERE state.singleton = true ` const state = rows[0] if (!state?.reconciled_at) { return { ready: false, reason: 'reconcile-required' } } if (Number(state.incomplete_count || 0) > 0) { return { ready: false, reason: 'incomplete-rows' } } if (!state.ready || Number(state.filesystem_count) !== Number(state.catalog_count) || Number(state.filesystem_bytes) !== Number(state.catalog_bytes) || Number(state.live_catalog_count) !== Number(state.catalog_count) || Number(state.live_catalog_bytes) !== Number(state.catalog_bytes)) { return { ready: false, reason: 'catalog-difference' } } return { ready: true, reason: 'ready' } } /** * DB 카탈로그 전체를 기존 관리자 미디어 항목 계약으로 조회한다. * @returns {Promise>} 미디어 항목 목록 */ export const listMediaCatalogItems = async () => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } const rows = await sql` SELECT * FROM media_metadata WHERE status = 'active' ORDER BY modified_at DESC NULLS LAST, updated_at DESC ` return hydrateMediaCatalogRows(sql, rows) } /** * 관리자 미디어 DB 페이지 조회 입력을 정규화한다. * @param {Record} query - API query * @returns {Object} 정규화된 조회 입력 */ const normalizePagedMediaQuery = (query) => { const tabValue = String(query.tab || 'library') const kindValue = String(query.kind || 'all') const pageSizeValue = Number(query.pageSize) || 50 return { tab: ['library', 'postThumbnails', 'postBodyThumbnails', 'thumbnails'].includes(tabValue) ? tabValue : 'library', kind: ['all', 'image', 'video', 'audio', 'file'].includes(kindValue) ? kindValue : 'all', pageSize: [50, 100, 200].includes(pageSizeValue) ? pageSizeValue : 50, requestedPage: Math.max(1, Number(query.page) || 1), search: String(query.search || '').trim().toLowerCase(), folder: String(query.folder || '').trim(), unusedOnly: query.unused === '1' || query.unused === 'true' } } /** * DB 페이지 조회용 탭 조건을 만든다. * @param {ReturnType} sql - PostgreSQL 클라이언트 * @param {string} tab - 탭 * @returns {Object} SQL 조각 */ const getCatalogTabCondition = (sql, tab) => { if (tab === 'thumbnails') { return sql`metadata.storage_key LIKE 'members/avatars/%'` } if (tab === 'postThumbnails') { return sql`metadata.role IN ('card', 'page_cover')` } if (tab === 'postBodyThumbnails') { return sql`metadata.role = 'body'` } return sql`metadata.storage_key NOT LIKE 'members/avatars/%' AND metadata.role NOT IN ('card', 'page_cover', 'body')` } /** * DB 페이지 조회용 잠금 조건을 만든다. * @param {ReturnType} sql - PostgreSQL 클라이언트 * @returns {Object} SQL 조각 */ const getCatalogLockedCondition = (sql) => sql` EXISTS ( SELECT 1 FROM media_references AS direct_reference WHERE direct_reference.media_url = metadata.url ) OR EXISTS ( SELECT 1 FROM users AS avatar_user WHERE avatar_user.avatar_url = metadata.url ) OR ( metadata.parent_id IS NOT NULL AND EXISTS ( SELECT 1 FROM media_metadata AS parent_metadata JOIN media_references AS parent_reference ON parent_reference.media_url = parent_metadata.url WHERE parent_metadata.id = metadata.parent_id AND ( (metadata.role = 'card' AND parent_reference.reference_field = 'featuredImage') OR ( metadata.role = 'page_cover' AND parent_reference.reference_type = 'page' AND parent_reference.reference_field = 'featuredImage' ) OR (metadata.role = 'body' AND parent_reference.reference_field = 'content') ) ) ) ` /** * DB에서 필터·검색·페이지 slice를 수행해 기존 paged 응답 계약을 반환한다. * counts는 SQL 집계 후 작은 객체만 메모리에서 조립한다. * @param {Record} query - API query * @returns {Promise} paged 응답 */ export const listPagedMediaCatalogItems = async (query) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } const input = normalizePagedMediaQuery(query) const escapedSearch = escapePostgresLikeLiteral(input.search) const escapedFolder = escapePostgresLikeLiteral(input.folder) const tabCondition = getCatalogTabCondition(sql, input.tab) const libraryCondition = getCatalogTabCondition(sql, 'library') const folderCondition = input.tab === 'library' && input.folder ? sql` AND ( metadata.category = ${input.folder} OR metadata.category LIKE ${`${escapedFolder}/%`} ESCAPE '!' ) ` : sql`` const kindCondition = input.kind === 'all' ? sql`` : sql`AND metadata.kind = ${input.kind}` const lockedCondition = getCatalogLockedCondition(sql) const unusedCondition = input.unusedOnly ? sql`AND NOT (${lockedCondition})` : sql`` const searchCondition = input.search ? sql` AND ( lower(metadata.file_name) LIKE ${`%${escapedSearch}%`} ESCAPE '!' OR EXISTS ( SELECT 1 FROM media_references AS search_reference LEFT JOIN media_metadata AS search_parent ON search_parent.id = metadata.parent_id WHERE lower(search_reference.reference_title) LIKE ${`%${escapedSearch}%`} ESCAPE '!' AND ( search_reference.media_url = metadata.url OR ( search_reference.media_url = search_parent.url AND ( (metadata.role = 'card' AND search_reference.reference_field = 'featuredImage') OR ( metadata.role = 'page_cover' AND search_reference.reference_type = 'page' AND search_reference.reference_field = 'featuredImage' ) OR (metadata.role = 'body' AND search_reference.reference_field = 'content') ) ) ) ) ) ` : sql`` const totalRows = await sql` SELECT count(*)::int AS total FROM media_metadata AS metadata WHERE metadata.status = 'active' AND ${tabCondition} ${folderCondition} ${kindCondition} ${unusedCondition} ${searchCondition} ` const total = Number(totalRows[0]?.total || 0) const pageCount = Math.max(1, Math.ceil(total / input.pageSize)) const page = Math.min(input.requestedPage, pageCount) const offset = (page - 1) * input.pageSize const [rows, tabCountRows, folderCountRows, kindCountRows, usageCountRows] = await Promise.all([ sql` SELECT metadata.* FROM media_metadata AS metadata WHERE metadata.status = 'active' AND ${tabCondition} ${folderCondition} ${kindCondition} ${unusedCondition} ${searchCondition} ORDER BY metadata.modified_at DESC NULLS LAST, metadata.updated_at DESC LIMIT ${input.pageSize} OFFSET ${offset} `, sql` SELECT count(*) FILTER ( WHERE storage_key NOT LIKE 'members/avatars/%' AND role NOT IN ('card', 'page_cover', 'body') )::int AS library, count(*) FILTER (WHERE role IN ('card', 'page_cover'))::int AS post_thumbnails, count(*) FILTER (WHERE role = 'body')::int AS post_body_thumbnails, count(*) FILTER (WHERE storage_key LIKE 'members/avatars/%')::int AS thumbnails FROM media_metadata WHERE status = 'active' `, sql` SELECT array_to_string(category_parts[1:depth], '/') AS folder, count(*)::int AS count FROM media_metadata AS metadata CROSS JOIN LATERAL ( SELECT string_to_array(COALESCE(NULLIF(metadata.category, ''), '미분류'), '/') AS category_parts ) AS category_values CROSS JOIN LATERAL generate_series(1, array_length(category_parts, 1)) AS depth WHERE metadata.status = 'active' AND ${libraryCondition} GROUP BY folder `, sql` SELECT metadata.kind, count(*)::int AS count FROM media_metadata AS metadata WHERE metadata.status = 'active' AND ${tabCondition} ${folderCondition} GROUP BY metadata.kind `, sql` SELECT count(*)::int AS all_count, count(*) FILTER (WHERE NOT (${lockedCondition}))::int AS unused_count FROM media_metadata AS metadata WHERE metadata.status = 'active' AND ${tabCondition} ${folderCondition} ${kindCondition} ` ]) const items = await hydrateMediaCatalogRows(sql, rows) const folders = {} for (const row of folderCountRows) { folders[row.folder] = Number(row.count) } const kinds = { all: 0, image: 0, video: 0, audio: 0, file: 0 } for (const row of kindCountRows) { kinds[row.kind] = Number(row.count) kinds.all += Number(row.count) } return { items, pagination: { page, pageSize: input.pageSize, total, pageCount }, counts: { tabs: { library: Number(tabCountRows[0]?.library || 0), postThumbnails: Number(tabCountRows[0]?.post_thumbnails || 0), postBodyThumbnails: Number(tabCountRows[0]?.post_body_thumbnails || 0), thumbnails: Number(tabCountRows[0]?.thumbnails || 0) }, folders, kinds, usage: { all: Number(usageCountRows[0]?.all_count || 0), unused: Number(usageCountRows[0]?.unused_count || 0) } } } } /** * 업로드 루트의 지원 파일 경로를 재귀적으로 읽는다. * @param {string} directoryPath - 스캔 디렉터리 * @returns {Promise>} 절대 파일 경로 */ const scanCatalogFiles = async (directoryPath) => { const entries = await readdir(directoryPath, { withFileTypes: true }).catch(() => []) const nested = await Promise.all(entries.map((entry) => { const path = join(directoryPath, entry.name) if (entry.isDirectory()) { return scanCatalogFiles(path) } return catalogFilePattern.test(entry.name) ? [path] : [] })) return nested.flat() } /** * 콘텐츠 전반의 현재 미디어 참조를 초기 백필한다. * @returns {Promise} 저장된 참조 수 */ export const backfillMediaReferences = async () => { const sql = getPostgresClient() if (!sql) { return 0 } const [posts, pages, tags, users, settings, navigation] = await Promise.all([ sql`SELECT id, title, slug, status, content, featured_image FROM posts`, sql`SELECT id, title, slug, status, content, featured_image FROM pages`, sql`SELECT id, name, slug, tag_type, header_image_url FROM tags`, sql`SELECT id, username, avatar_url FROM users WHERE avatar_url IS NOT NULL`, sql`SELECT * FROM site_settings LIMIT 1`, sql`SELECT id, label, url, cached_favicon_url FROM navigation_items` ]) let count = 0 for (const post of posts) { count += await replaceMediaReferences('post', post.id, { content: post.content, featuredImage: post.featured_image }, post) } for (const page of pages) { count += await replaceMediaReferences('page', page.id, { content: page.content, featuredImage: page.featured_image }, page) } for (const tag of tags) { count += await replaceMediaReferences('tag', tag.id, { headerImageUrl: tag.header_image_url }, { title: tag.name, slug: tag.slug, status: tag.tag_type }) } for (const user of users) { count += await replaceMediaReferences('user', user.id, { avatarUrl: user.avatar_url }, { title: user.username }) } if (settings[0]) { count += await replaceMediaReferences('site_setting', 'site', { logoUrl: settings[0].logo_url, faviconUrl: settings[0].favicon_url, homeCoverImageUrl: settings[0].home_cover_image_url, homeCoverDarkImageUrl: settings[0].home_cover_dark_image_url }, { title: '사이트 설정' }) } for (const item of navigation) { count += await replaceMediaReferences('navigation', item.id, { url: item.url, cachedFaviconUrl: item.cached_favicon_url }, { title: item.label, slug: item.url, status: 'navigation' }) } return count } /** * 파일을 변경하지 않고 카탈로그를 실제 업로드 디렉터리와 재동기화한다. * @returns {Promise} 비교 보고 */ export const reconcileMediaCatalog = async () => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } await sql` UPDATE media_catalog_state SET ready = false, error_detail = NULL, updated_at = now() WHERE singleton = true ` const startedAt = new Date() const files = await scanCatalogFiles(uploadRoot) const derivativeUrls = [] let totalBytes = 0 const kinds = { image: 0, video: 0, audio: 0, file: 0 } for (const filePath of files) { const storageKey = relative(uploadRoot, filePath).split(sep).join('/') const url = `/uploads/${storageKey}` const fileStat = await stat(filePath) const kind = detectMediaKind(storageKey) totalBytes += fileStat.size kinds[kind] += 1 await upsertMediaCatalogFile(url, { origin: 'external', refreshState: false }) if (detectMediaRole(storageKey).role !== 'original') { derivativeUrls.push(url) } } for (const url of derivativeUrls) { await upsertMediaCatalogFile(url, { origin: 'external', refreshState: false }) } const missingRows = await sql` UPDATE media_metadata SET status = 'missing', missing_since = COALESCE(missing_since, now()), updated_at = now() WHERE status IN ('active', 'error', 'staging') AND (last_seen_at IS NULL OR last_seen_at < ${startedAt}) RETURNING id ` const referenceCount = await backfillMediaReferences() const catalogRows = await sql` SELECT count(*) FILTER (WHERE status = 'active')::int AS active_count, COALESCE(sum(size_bytes) FILTER (WHERE status = 'active'), 0)::bigint AS active_bytes, count(*) FILTER (WHERE status IN ('staging', 'error'))::int AS incomplete_count FROM media_metadata ` const activeCount = Number(catalogRows[0]?.active_count || 0) const activeBytes = Number(catalogRows[0]?.active_bytes || 0) const isReady = activeCount === files.length && activeBytes === totalBytes && Number(catalogRows[0]?.incomplete_count || 0) === 0 await sql` UPDATE media_catalog_state SET ready = ${isReady}, reconciled_at = now(), filesystem_count = ${files.length}, filesystem_bytes = ${totalBytes}, catalog_count = ${activeCount}, catalog_bytes = ${activeBytes}, error_detail = ${isReady ? null : 'CATALOG_DIFFERENCE'}, updated_at = now() WHERE singleton = true ` return { scanned: { count: files.length, bytes: totalBytes, kinds }, catalog: { activeCount, activeBytes, markedMissing: missingRows.length, references: referenceCount, ready: isReady }, difference: { count: activeCount - files.length, bytes: activeBytes - totalBytes } } }