diff --git a/public/dashboard.js b/public/dashboard.js
index 7e93449..8ed0d5d 100644
--- a/public/dashboard.js
+++ b/public/dashboard.js
@@ -1,5 +1,6 @@
(function () {
var ctxMenu = document.getElementById('ctxMenu')
+ var targetId = null
var targetName = null
function showCtx(x, y) {
@@ -7,11 +8,12 @@
ctxMenu.style.top = y + 'px'
ctxMenu.hidden = false
}
- function hideCtx() { ctxMenu.hidden = true; targetName = null }
+ function hideCtx() { ctxMenu.hidden = true; targetId = null; targetName = null }
document.querySelectorAll('.adminFolder').forEach(function (card) {
card.addEventListener('contextmenu', function (e) {
e.preventDefault()
+ targetId = card.getAttribute('data-folder-id')
targetName = card.getAttribute('data-name')
showCtx(e.clientX, e.clientY)
})
@@ -23,15 +25,15 @@
ctxMenu.addEventListener('click', function (e) {
var btn = e.target.closest('button')
- if (!btn) return
+ if (!btn || !targetId) return
var action = btn.getAttribute('data-action')
if (action === 'rename') {
var newName = window.prompt('새 폴더 이름', targetName)
if (newName && newName !== targetName) {
- fetch('/op/folders/rename', {
+ fetch('/op/folders/' + encodeURIComponent(targetId) + '/rename', {
method: 'POST',
headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ oldName: targetName, newName: newName })
+ body: JSON.stringify({ newName: newName })
}).then(function (r) { return r.json() }).then(function (j) {
if (j.ok) location.reload()
else alert(j.message || '이름 변경 실패')
@@ -39,10 +41,8 @@
}
} else if (action === 'delete') {
if (window.confirm('"' + targetName + '" 폴더를 정말 삭제할까요? 안의 영상이 모두 사라집니다.')) {
- fetch('/op/folders/delete', {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ name: targetName })
+ fetch('/op/folders/' + encodeURIComponent(targetId) + '/delete', {
+ method: 'POST'
}).then(function (r) { return r.json() }).then(function (j) {
if (j.ok) location.reload()
else alert(j.message || '삭제 실패')
@@ -61,20 +61,22 @@
function openModal() { modal.hidden = false; input.value = ''; setTimeout(function () { input.focus() }, 0) }
function closeModal() { modal.hidden = true }
- addBtn.addEventListener('click', openModal)
- cancelBtn.addEventListener('click', closeModal)
- modal.addEventListener('click', function (e) { if (e.target === modal) closeModal() })
- input.addEventListener('keydown', function (e) { if (e.key === 'Enter') confirmBtn.click() })
- confirmBtn.addEventListener('click', function () {
- var name = input.value.trim()
- if (!name) return
- fetch('/op/folders', {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ name: name })
- }).then(function (r) { return r.json() }).then(function (j) {
- if (j.ok) location.reload()
- else alert(j.message || '폴더 생성 실패')
+ if (addBtn) {
+ addBtn.addEventListener('click', openModal)
+ cancelBtn.addEventListener('click', closeModal)
+ modal.addEventListener('click', function (e) { if (e.target === modal) closeModal() })
+ input.addEventListener('keydown', function (e) { if (e.key === 'Enter') confirmBtn.click() })
+ confirmBtn.addEventListener('click', function () {
+ var name = input.value.trim()
+ if (!name) return
+ fetch('/op/folders', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ name: name })
+ }).then(function (r) { return r.json() }).then(function (j) {
+ if (j.ok) location.reload()
+ else alert(j.message || '폴더 생성 실패')
+ })
})
- })
+ }
})()
diff --git a/public/editor.js b/public/editor.js
index 7f66c4f..4913ac8 100644
--- a/public/editor.js
+++ b/public/editor.js
@@ -1,6 +1,7 @@
(function () {
- var ctx = window.__EDITOR__ || { folder: '', video: null }
- var folder = ctx.folder
+ var ctx = window.__EDITOR__ || { folderPath: [], folderId: null, video: null }
+ var folderPathEnc = (ctx.folderPath || []).map(encodeURIComponent).join('/')
+ var folderBaseUrl = '/op/folder/' + folderPathEnc
var video = ctx.video
var dropZone = document.getElementById('dropZone')
@@ -46,7 +47,7 @@
form.append('title', titleInput.value || file.name)
uploadStatus.textContent = '업로드 중...'
var xhr = new XMLHttpRequest()
- xhr.open('POST', '/op/folder/' + encodeURIComponent(folder) + '/video/upload')
+ xhr.open('POST', folderBaseUrl + '/video/upload')
xhr.upload.addEventListener('progress', function (e) {
if (e.lengthComputable) {
var pct = Math.round((e.loaded / e.total) * 100)
@@ -57,7 +58,7 @@
try {
var res = JSON.parse(xhr.responseText)
if (res.ok) {
- location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(res.videoId)
+ location.href = folderBaseUrl + '/video/editor?id=' + encodeURIComponent(res.videoId)
} else {
uploadStatus.textContent = '업로드 실패: ' + (res.message || '')
}
@@ -89,7 +90,7 @@
probeInfo.textContent = '확인 중...'
ytProbeBtn.disabled = true
ytStartBtn.disabled = true
- fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/probe', {
+ fetch(folderBaseUrl + '/video/youtube/probe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url: url })
@@ -144,7 +145,7 @@
}
// 중복 클릭 방지
ytStartBtn.disabled = true
- fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/start', {
+ fetch(folderBaseUrl + '/video/youtube/start', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url: url, title: titleInput.value })
@@ -180,7 +181,7 @@
dlProgress.value = job.progress
probeInfo.textContent = job.message
if (job.status === 'done') {
- location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(videoId)
+ location.href = folderBaseUrl + '/video/editor?id=' + encodeURIComponent(videoId)
} else if (job.status === 'error') {
probeInfo.textContent = '실패: ' + (job.error || '')
} else {
@@ -364,14 +365,13 @@
saveBtn.addEventListener('click', function () {
if (!video) { alert('먼저 영상을 추가하세요.'); return }
var payload = {
- id: video.id,
title: titleInput.value,
startSec: trimStart,
endSec: trimEnd
}
saveBtn.disabled = true
saveBtn.textContent = '저장 중...'
- fetch('/op/folder/' + encodeURIComponent(folder) + '/video/save', {
+ fetch('/op/videos/' + encodeURIComponent(video.id) + '/save', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
@@ -380,7 +380,7 @@
saveBtn.textContent = '저장'
if (j.ok) {
alert(j.note || '저장 완료')
- location.href = '/op/folder/' + encodeURIComponent(folder)
+ location.href = folderBaseUrl
} else {
alert(j.message || '저장 실패')
}
diff --git a/public/folder.js b/public/folder.js
index 2042d6b..f874ffb 100644
--- a/public/folder.js
+++ b/public/folder.js
@@ -1,74 +1,171 @@
(function () {
- var folder = (window.__OP__ || {}).folder
- var ctxMenu = document.getElementById('ctxMenu')
- var targetId = null
- var targetTitle = null
+ var op = window.__OP__ || { folderId: null, folderPath: [], isSubFolder: false }
+ var folderPathEnc = (op.folderPath || []).map(encodeURIComponent).join('/')
- function showCtx(x, y) {
- ctxMenu.style.left = x + 'px'
- ctxMenu.style.top = y + 'px'
- ctxMenu.hidden = false
+ // ── 영상 컨텍스트 메뉴 ───────────────────────────────────────────────
+ var videoCtxMenu = document.getElementById('ctxMenu')
+ var videoTargetId = null
+ var videoTargetTitle = null
+ var videoTargetShareUrl = null
+
+ function showVideoCtx(x, y) {
+ videoCtxMenu.style.left = x + 'px'
+ videoCtxMenu.style.top = y + 'px'
+ videoCtxMenu.hidden = false
}
- function hideCtx() { ctxMenu.hidden = true }
+ function hideVideoCtx() { videoCtxMenu.hidden = true }
document.querySelectorAll('.adminVideo').forEach(function (card) {
card.addEventListener('contextmenu', function (e) {
e.preventDefault()
- targetId = card.getAttribute('data-id')
- targetTitle = card.getAttribute('data-title')
- showCtx(e.clientX, e.clientY)
+ videoTargetId = card.getAttribute('data-id')
+ videoTargetTitle = card.getAttribute('data-title')
+ videoTargetShareUrl = card.getAttribute('data-share-url')
+ showVideoCtx(e.clientX, e.clientY)
})
})
- document.addEventListener('click', function (e) {
- if (!ctxMenu.contains(e.target)) hideCtx()
- })
-
- ctxMenu.addEventListener('click', function (e) {
+ videoCtxMenu.addEventListener('click', function (e) {
var btn = e.target.closest('button')
- if (!btn) return
+ if (!btn || !videoTargetId) return
var action = btn.getAttribute('data-action')
if (action === 'edit') {
- location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(targetId)
+ location.href = '/op/folder/' + folderPathEnc + '/video/editor?id=' + encodeURIComponent(videoTargetId)
} else if (action === 'copyUrl') {
- copyVideoUrl(targetId)
+ copyUrl(location.origin + videoTargetShareUrl)
} else if (action === 'rename') {
- var t = window.prompt('새 영상 제목', targetTitle)
- if (t && t !== targetTitle) {
- fetch('/op/folder/' + encodeURIComponent(folder) + '/video/rename', {
+ var t = window.prompt('새 영상 제목', videoTargetTitle)
+ if (t && t !== videoTargetTitle) {
+ fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/rename', {
method: 'POST',
headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ id: targetId, title: t })
+ body: JSON.stringify({ title: t })
}).then(function (r) { return r.json() }).then(function (j) {
if (j.ok) location.reload()
else alert(j.message || '이름 변경 실패')
})
}
- } else if (action === 'delete') {
- if (window.confirm('"' + targetTitle + '" 영상을 정말 삭제할까요?')) {
- fetch('/op/folder/' + encodeURIComponent(folder) + '/video/delete', {
+ } else if (action === 'changeId') {
+ var newId = window.prompt('새 영상 ID (영문/숫자/_/-, 1~64자)', videoTargetId)
+ if (newId && newId !== videoTargetId) {
+ fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/changeId', {
method: 'POST',
headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ id: targetId })
+ body: JSON.stringify({ newId: newId })
+ }).then(function (r) { return r.json() }).then(function (j) {
+ if (j.ok) location.reload()
+ else alert(j.message || 'ID 변경 실패')
+ })
+ }
+ } else if (action === 'delete') {
+ if (window.confirm('"' + videoTargetTitle + '" 영상을 정말 삭제할까요?')) {
+ fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/delete', {
+ method: 'POST'
}).then(function (r) { return r.json() }).then(function (j) {
if (j.ok) location.reload()
else alert(j.message || '삭제 실패')
})
}
}
- hideCtx()
+ hideVideoCtx()
})
- function copyVideoUrl(id) {
- var url = location.origin + '/file/video/' + encodeURIComponent(id)
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(url).then(function () {
- flashToast('주소가 복사되었습니다.')
- }, function () {
- fallbackCopy(url)
+ // ── 하위 폴더 컨텍스트 메뉴 (rename / delete) ────────────────────────
+ var folderCtxMenu = document.getElementById('folderCtxMenu')
+ var folderTargetId = null
+ var folderTargetName = null
+
+ function showFolderCtx(x, y) {
+ folderCtxMenu.style.left = x + 'px'
+ folderCtxMenu.style.top = y + 'px'
+ folderCtxMenu.hidden = false
+ }
+ function hideFolderCtx() { folderCtxMenu.hidden = true }
+
+ document.querySelectorAll('#subFolderGrid .adminFolder').forEach(function (card) {
+ card.addEventListener('contextmenu', function (e) {
+ e.preventDefault()
+ folderTargetId = card.getAttribute('data-folder-id')
+ folderTargetName = card.getAttribute('data-name')
+ showFolderCtx(e.clientX, e.clientY)
+ })
+ })
+
+ if (folderCtxMenu) {
+ folderCtxMenu.addEventListener('click', function (e) {
+ var btn = e.target.closest('button')
+ if (!btn || !folderTargetId) return
+ var action = btn.getAttribute('data-action')
+ if (action === 'rename') {
+ var newName = window.prompt('새 폴더 이름', folderTargetName)
+ if (newName && newName !== folderTargetName) {
+ fetch('/op/folders/' + encodeURIComponent(folderTargetId) + '/rename', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ newName: newName })
+ }).then(function (r) { return r.json() }).then(function (j) {
+ if (j.ok) location.reload()
+ else alert(j.message || '이름 변경 실패')
+ })
+ }
+ } else if (action === 'delete') {
+ if (window.confirm('"' + folderTargetName + '" 폴더를 정말 삭제할까요? 안의 영상이 모두 사라집니다.')) {
+ fetch('/op/folders/' + encodeURIComponent(folderTargetId) + '/delete', {
+ method: 'POST'
+ }).then(function (r) { return r.json() }).then(function (j) {
+ if (j.ok) location.reload()
+ else alert(j.message || '삭제 실패')
+ })
+ }
+ }
+ hideFolderCtx()
+ })
+ }
+
+ document.addEventListener('click', function (e) {
+ if (videoCtxMenu && !videoCtxMenu.contains(e.target)) hideVideoCtx()
+ if (folderCtxMenu && !folderCtxMenu.contains(e.target)) hideFolderCtx()
+ })
+
+ // ── 하위 폴더 추가 모달 ──────────────────────────────────────────────
+ var addSubBtn = document.getElementById('addSubFolderBtn')
+ if (addSubBtn) {
+ var subModal = document.getElementById('addSubFolderModal')
+ var subInput = document.getElementById('addSubFolderInput')
+ var subCancel = document.getElementById('addSubFolderCancel')
+ var subConfirm = document.getElementById('addSubFolderConfirm')
+
+ function openSubModal() {
+ subModal.hidden = false; subInput.value = ''
+ setTimeout(function () { subInput.focus() }, 0)
+ }
+ function closeSubModal() { subModal.hidden = true }
+ addSubBtn.addEventListener('click', openSubModal)
+ subCancel.addEventListener('click', closeSubModal)
+ subModal.addEventListener('click', function (e) { if (e.target === subModal) closeSubModal() })
+ subInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') subConfirm.click() })
+ subConfirm.addEventListener('click', function () {
+ var name = subInput.value.trim()
+ if (!name) return
+ fetch('/op/folders', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ name: name, parentId: op.folderId })
+ }).then(function (r) { return r.json() }).then(function (j) {
+ if (j.ok) location.reload()
+ else alert(j.message || '폴더 생성 실패')
})
+ })
+ }
+
+ // ── 유틸 ──────────────────────────────────────────────────────────
+ function copyUrl(text) {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(text).then(function () {
+ flashToast('주소가 복사되었습니다.')
+ }, function () { fallbackCopy(text) })
} else {
- fallbackCopy(url)
+ fallbackCopy(text)
}
}
function fallbackCopy(text) {
diff --git a/public/player.js b/public/player.js
index a26ef6c..cd63561 100644
--- a/public/player.js
+++ b/public/player.js
@@ -1,13 +1,15 @@
(function () {
var overlay = document.getElementById('playerOverlay')
+ if (!overlay) return
var closeBtn = document.getElementById('playerClose')
var video = document.getElementById('playerVideo')
var titleEl = document.getElementById('playerTitle')
- function openPlayer(videoId, title) {
- // 스펙: /player/:videoId 로 이동한 것처럼 동작하면서 내부 팝업으로 띄운다.
- // pushState 로 URL 만 바꿔, 새로고침/직접접근 시 player.ejs 가 응답한다.
- history.pushState({ player: true, videoId: videoId }, '', '/player/' + encodeURIComponent(videoId))
+ function openPlayer(videoId, title, shareUrl) {
+ // 사이트 내 재생도 share URL 을 주소창에 보이도록 pushState.
+ // share URL 이 없으면 단순한 /file/video/:id 로 폴백.
+ var url = shareUrl || ('/file/video/' + encodeURIComponent(videoId))
+ history.pushState({ player: true, videoId: videoId }, '', url)
titleEl.textContent = title || ''
video.src = '/api/video/' + encodeURIComponent(videoId) + '/file'
overlay.hidden = false
@@ -19,17 +21,19 @@
video.pause()
video.removeAttribute('src')
video.load()
- // 폴더 페이지로 되돌리기
if (history.state && history.state.player) {
history.back()
}
}
document.querySelectorAll('.videoCard').forEach(function (card) {
+ // 태그면 클릭 시 share URL 로 풀 페이지 이동하도록 두고, 모달은 띄우지 않는다.
+ if (card.tagName && card.tagName.toLowerCase() === 'a') return
card.addEventListener('click', function () {
var id = card.getAttribute('data-video-id')
- var title = card.querySelector('.videoTitle')
- openPlayer(id, title ? title.textContent : '')
+ var titleNode = card.querySelector('.videoTitle')
+ var shareUrl = card.getAttribute('data-share-url')
+ openPlayer(id, titleNode ? titleNode.textContent : '', shareUrl)
})
})
diff --git a/public/public-folder.js b/public/public-folder.js
index 0c3def1..a6b3b42 100644
--- a/public/public-folder.js
+++ b/public/public-folder.js
@@ -1,7 +1,7 @@
(function () {
var ctxMenu = document.getElementById('ctxMenu')
if (!ctxMenu) return
- var targetId = null
+ var targetShareUrl = null
function showCtx(x, y) {
ctxMenu.style.left = x + 'px'
@@ -13,7 +13,7 @@
document.querySelectorAll('.videoCard').forEach(function (card) {
card.addEventListener('contextmenu', function (e) {
e.preventDefault()
- targetId = card.getAttribute('data-video-id') || card.getAttribute('data-id')
+ targetShareUrl = card.getAttribute('data-share-url')
showCtx(e.clientX, e.clientY)
})
})
@@ -27,22 +27,19 @@
var btn = e.target.closest('button')
if (!btn) return
var action = btn.getAttribute('data-action')
- if (action === 'copyUrl' && targetId) {
- copyVideoUrl(targetId)
+ if (action === 'copyUrl' && targetShareUrl) {
+ copyUrl(location.origin + targetShareUrl)
}
hideCtx()
})
- function copyVideoUrl(id) {
- var url = location.origin + '/file/video/' + encodeURIComponent(id)
+ function copyUrl(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(url).then(function () {
+ navigator.clipboard.writeText(text).then(function () {
flashToast('주소가 복사되었습니다.')
- }, function () {
- fallbackCopy(url)
- })
+ }, function () { fallbackCopy(text) })
} else {
- fallbackCopy(url)
+ fallbackCopy(text)
}
}
function fallbackCopy(text) {
diff --git a/src/editor.ts b/src/editor.ts
index ce60f83..f03803a 100644
--- a/src/editor.ts
+++ b/src/editor.ts
@@ -1,7 +1,7 @@
import { spawn, spawnSync } from 'node:child_process'
import { promises as fs } from 'node:fs'
import path from 'node:path'
-import { videoDir, loadVideoMeta, saveVideoMeta, type VideoTrim } from './store.js'
+import { getVideo, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js'
export class FfmpegUnavailableError extends Error {
constructor() {
@@ -184,14 +184,13 @@ export async function upscaleOriginalTo60Fps(
* stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩.
*/
export async function applyTrimToVideo(
- folder: string,
videoId: string,
trim: VideoTrim
): Promise {
const bin = getFfmpegPath()
- const meta = await loadVideoMeta(folder, videoId)
+ const meta = getVideo(videoId)
if (!meta) throw new Error('비디오를 찾을 수 없습니다.')
- const dir = videoDir(folder, videoId)
+ const dir = videoDiskDir(videoId)
const inputPath = path.join(dir, meta.originalFile)
await fs.access(inputPath)
@@ -236,9 +235,10 @@ export async function applyTrimToVideo(
}
await fs.rename(tmpPath, outPath)
- meta.editedFile = outName
- meta.trim = { startSec, endSec }
- await saveVideoMeta(folder, meta)
+ updateVideo(videoId, {
+ editedFile: outName,
+ trim: { startSec, endSec }
+ })
return outName
}
diff --git a/src/routes/op.ts b/src/routes/op.ts
index a32fcd5..43af09d 100644
--- a/src/routes/op.ts
+++ b/src/routes/op.ts
@@ -2,24 +2,26 @@ import { Router } from 'express'
import path from 'node:path'
import multer, { MulterError } from 'multer'
import type { Request, Response, NextFunction } from 'express'
-import { promises as fs } from 'node:fs'
import { requireAuth } from '../auth.js'
+import { readAccounts } from '../store.js'
import {
+ changeVideoId,
createFolder,
+ createVideo,
deleteFolder,
deleteVideo,
- folderPath,
- listFolders,
- listVideos,
- loadVideoMeta,
- moveUploadIntoVideo,
- newVideoId,
- readAccounts,
+ folderPathNames,
+ getFolder,
+ getFolderByPath,
+ getVideo,
+ listChildFolders,
+ listTopFolders,
+ listVideosInFolder,
+ moveUploadIntoVideoDir,
renameFolder,
- sanitizeFolderName,
- saveVideoMeta,
- type VideoMeta
-} from '../store.js'
+ updateVideo,
+ videoIdExists
+} from '../storeDb.js'
import { tmpDir } from '../paths.js'
import {
YtDlpUnavailableError,
@@ -32,9 +34,6 @@ import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js'
export const opRouter = Router()
// 업로드 용량 상한. 기본 1 GiB. UPLOAD_MAX_BYTES 환경변수로 변경 가능.
-// - 비어있거나 미설정이면 기본 1 GiB
-// - "0" 또는 "Infinity" 만 명시적 무제한으로 인정
-// - 잘못된 값 (오타 등) 은 기본 1 GiB 로 fallback (경고 출력)
const DEFAULT_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024
const uploadMaxBytes = (() => {
const raw = process.env.UPLOAD_MAX_BYTES
@@ -43,7 +42,9 @@ const uploadMaxBytes = (() => {
if (trimmed === '0' || trimmed.toLowerCase() === 'infinity') return Infinity
const n = Number(trimmed)
if (!Number.isFinite(n) || n <= 0) {
- console.warn(`[upload] invalid UPLOAD_MAX_BYTES=${JSON.stringify(raw)}; falling back to default ${DEFAULT_UPLOAD_MAX_BYTES} bytes`)
+ console.warn(
+ `[upload] invalid UPLOAD_MAX_BYTES=${JSON.stringify(raw)}; falling back to default ${DEFAULT_UPLOAD_MAX_BYTES} bytes`
+ )
return DEFAULT_UPLOAD_MAX_BYTES
}
return Math.max(1, Math.floor(n))
@@ -58,6 +59,22 @@ function pickStr(v: unknown): string {
return typeof v === 'string' ? v : ''
}
+function pickIntId(v: unknown): number | null {
+ // JSON body 로 들어오면 number, form 으로 들어오면 string. 둘 다 지원.
+ let n: number
+ if (typeof v === 'number') {
+ n = v
+ } else {
+ const s = pickStr(v).trim()
+ if (!s) return null
+ n = Number(s)
+ }
+ if (!Number.isFinite(n) || n < 1 || Math.floor(n) !== n) return null
+ return n
+}
+
+// ─── 인증 ─────────────────────────────────────────────────────────────
+
opRouter.get('/op', (req, res) => {
if (req.session?.userId) {
res.redirect('/op/dashboard')
@@ -88,119 +105,114 @@ opRouter.post('/op/logout', (req, res) => {
})
})
-opRouter.get('/op/dashboard', requireAuth, async (req, res, next) => {
+// ─── 폴더 뷰 ──────────────────────────────────────────────────────────
+
+opRouter.get('/op/dashboard', requireAuth, (req, res, next) => {
try {
- const folders = await listFolders()
+ const folders = listTopFolders()
res.render('op/dashboard', { userId: req.session.userId, folders })
} catch (err) {
next(err)
}
})
-opRouter.post('/op/folders', requireAuth, async (req, res, next) => {
+function folderViewHandler(req: Request, res: Response, next: NextFunction): void {
try {
- const name = pickStr(req.body.name)
- const safe = await createFolder(name)
- res.json({ ok: true, name: safe })
- } catch (err) {
- res.status(400).json({ ok: false, message: (err as Error).message })
- }
-})
-
-opRouter.post('/op/folders/rename', requireAuth, async (req, res) => {
- try {
- const oldName = pickStr(req.body.oldName)
- const newName = pickStr(req.body.newName)
- const result = await renameFolder(oldName, newName)
- res.json({ ok: true, name: result })
- } catch (err) {
- res.status(400).json({ ok: false, message: (err as Error).message })
- }
-})
-
-opRouter.post('/op/folders/delete', requireAuth, async (req, res) => {
- try {
- const name = pickStr(req.body.name)
- await deleteFolder(name)
- res.json({ ok: true })
- } catch (err) {
- res.status(400).json({ ok: false, message: (err as Error).message })
- }
-})
-
-opRouter.get('/op/folder/:name', requireAuth, async (req, res, next) => {
- try {
- const safe = sanitizeFolderName(req.params.name)
- if (!safe) {
+ const segments = collectFolderSegments(req.params)
+ const folder = getFolderByPath(segments)
+ if (!folder) {
res.status(404).send('폴더를 찾을 수 없습니다.')
return
}
- try {
- await fs.access(folderPath(safe))
- } catch {
- res.status(404).send('폴더를 찾을 수 없습니다.')
- return
- }
- const videos = await listVideos(safe)
- res.render('op/folder', { userId: req.session.userId, folder: safe, videos })
- } catch (err) {
- next(err)
- }
-})
-
-opRouter.get('/op/folder/:name/video/editor', requireAuth, async (req, res, next) => {
- try {
- const safe = sanitizeFolderName(req.params.name)
- if (!safe) {
- res.status(404).send('폴더를 찾을 수 없습니다.')
- return
- }
- const videoId = typeof req.query.id === 'string' ? req.query.id : null
- let video: VideoMeta | null = null
- if (videoId) {
- video = await loadVideoMeta(safe, videoId)
- }
- res.render('op/editor', {
+ const subFolders = folder.parentId === null ? listChildFolders(folder.id) : []
+ const videos = listVideosInFolder(folder.id)
+ res.render('op/folder', {
userId: req.session.userId,
- folder: safe,
- video
+ folder,
+ breadcrumb: folderPathNames(folder.id),
+ subFolders,
+ videos,
+ isSubFolder: folder.parentId !== null
})
} catch (err) {
next(err)
}
+}
+opRouter.get('/op/folder/:topName', requireAuth, folderViewHandler)
+opRouter.get('/op/folder/:topName/:subName', requireAuth, folderViewHandler)
+
+// ─── 폴더 mutation (id 기반) ───────────────────────────────────────────
+
+opRouter.post('/op/folders', requireAuth, async (req, res) => {
+ try {
+ const name = pickStr(req.body.name)
+ const parentIdRaw = req.body.parentId
+ const parentId =
+ parentIdRaw === undefined || parentIdRaw === null || parentIdRaw === ''
+ ? null
+ : pickIntId(parentIdRaw)
+ if (parentIdRaw !== undefined && parentIdRaw !== null && parentIdRaw !== '' && parentId === null) {
+ throw new Error('상위 폴더 ID 가 올바르지 않습니다.')
+ }
+ const folder = await createFolder({ name, parentId })
+ res.json({ ok: true, folder })
+ } catch (err) {
+ res.status(400).json({ ok: false, message: (err as Error).message })
+ }
})
-opRouter.post('/op/folder/:name/video/rename', requireAuth, async (req, res) => {
+opRouter.post('/op/folders/:id/rename', requireAuth, async (req, res) => {
try {
- const safe = sanitizeFolderName(req.params.name)
- if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
- const id = pickStr(req.body.id)
- const title = pickStr(req.body.title).trim()
- if (!title) throw new Error('제목을 입력해 주세요.')
- const meta = await loadVideoMeta(safe, id)
- if (!meta) throw new Error('영상을 찾을 수 없습니다.')
- meta.title = title
- await saveVideoMeta(safe, meta)
+ const id = pickIntId(req.params.id)
+ if (id === null) throw new Error('폴더 ID 가 올바르지 않습니다.')
+ const newName = pickStr(req.body.newName)
+ const folder = await renameFolder(id, newName)
+ res.json({ ok: true, folder })
+ } catch (err) {
+ res.status(400).json({ ok: false, message: (err as Error).message })
+ }
+})
+
+opRouter.post('/op/folders/:id/delete', requireAuth, async (req, res) => {
+ try {
+ const id = pickIntId(req.params.id)
+ if (id === null) throw new Error('폴더 ID 가 올바르지 않습니다.')
+ await deleteFolder(id)
res.json({ ok: true })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
-opRouter.post('/op/folder/:name/video/delete', requireAuth, async (req, res) => {
- try {
- const safe = sanitizeFolderName(req.params.name)
- if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
- const id = pickStr(req.body.id)
- await deleteVideo(safe, id)
- res.json({ ok: true })
- } catch (err) {
- res.status(400).json({ ok: false, message: (err as Error).message })
- }
-})
+// ─── 영상 에디터 페이지 ────────────────────────────────────────────────
+
+opRouter.get(
+ ['/op/folder/:topName/video/editor', '/op/folder/:topName/:subName/video/editor'],
+ requireAuth,
+ (req, res, next) => {
+ try {
+ const segments = collectFolderSegments(req.params)
+ const folder = getFolderByPath(segments)
+ if (!folder) {
+ res.status(404).send('폴더를 찾을 수 없습니다.')
+ return
+ }
+ const videoId = typeof req.query.id === 'string' ? req.query.id : null
+ const video = videoId ? getVideo(videoId) : null
+ res.render('op/editor', {
+ userId: req.session.userId,
+ folder,
+ breadcrumb: folderPathNames(folder.id),
+ video
+ })
+ } catch (err) {
+ next(err)
+ }
+ }
+)
+
+// ─── 영상 업로드 / 유튜브 ───────────────────────────────────────────────
-// multer 가 던지는 LIMIT_FILE_SIZE 같은 에러를 라우트 핸들러가 잡지 못해
-// 글로벌 에러 핸들러로 새서 stack trace 가 그대로 노출되던 문제를 막는다.
function uploadSingle(fieldName: string) {
const mw = upload.single(fieldName)
return (req: Request, res: Response, next: NextFunction) => {
@@ -219,80 +231,76 @@ function uploadSingle(fieldName: string) {
}
}
-// 업로드: 단일 파일. multipart/form-data, fields: title, file
opRouter.post(
- '/op/folder/:name/video/upload',
+ ['/op/folder/:topName/video/upload', '/op/folder/:topName/:subName/video/upload'],
requireAuth,
uploadSingle('file'),
async (req, res) => {
try {
- const safe = sanitizeFolderName(req.params.name)
- if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
+ const folder = getFolderByPath(collectFolderSegments(req.params))
+ if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
const file = req.file
if (!file) throw new Error('파일이 없습니다.')
const title = pickStr(req.body?.title).trim() || file.originalname
const ext = (path.extname(file.originalname) || '.mp4').toLowerCase()
- const videoId = newVideoId()
const destName = `original${ext}`
- await moveUploadIntoVideo(safe, videoId, file.path, destName)
- const now = new Date().toISOString()
- const meta = {
- id: videoId,
+ const video = await createVideo({
+ folderId: folder.id,
title,
originalFile: destName,
- editedFile: null,
- durationSec: null,
- sourceType: 'upload' as const,
- sourceUrl: null,
- trim: null,
- createdAt: now,
- updatedAt: now
- }
- await saveVideoMeta(safe, meta)
- res.json({ ok: true, videoId, folder: safe })
+ sourceType: 'upload'
+ })
+ await moveUploadIntoVideoDir(video.id, file.path, destName)
+ res.json({ ok: true, videoId: video.id })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
}
)
-// 유튜브 프로브: 다운받기 전에 길이/사이즈/예상시간/5분초과경고
-opRouter.post('/op/folder/:name/video/youtube/probe', requireAuth, async (req, res) => {
- try {
- const url = pickStr(req.body.url).trim()
- if (!url) throw new Error('URL 을 입력해 주세요.')
- const probe = await probeYoutube(url)
- res.json({ ok: true, probe })
- } catch (err) {
- if (err instanceof YtDlpUnavailableError) {
- res.status(503).json({ ok: false, message: err.message, code: 'NO_YTDLP' })
- return
+opRouter.post(
+ ['/op/folder/:topName/video/youtube/probe', '/op/folder/:topName/:subName/video/youtube/probe'],
+ requireAuth,
+ async (req, res) => {
+ try {
+ const url = pickStr(req.body.url).trim()
+ if (!url) throw new Error('URL 을 입력해 주세요.')
+ const probe = await probeYoutube(url)
+ res.json({ ok: true, probe })
+ } catch (err) {
+ if (err instanceof YtDlpUnavailableError) {
+ res.status(503).json({ ok: false, message: err.message, code: 'NO_YTDLP' })
+ return
+ }
+ res.status(400).json({ ok: false, message: (err as Error).message })
}
- res.status(400).json({ ok: false, message: (err as Error).message })
}
-})
+)
-opRouter.post('/op/folder/:name/video/youtube/start', requireAuth, async (req, res) => {
- try {
- const safe = sanitizeFolderName(req.params.name)
- if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
- const url = pickStr(req.body.url).trim()
- const title = pickStr(req.body.title).trim() || undefined
- if (!url) throw new Error('URL 을 입력해 주세요.')
- const job = await startYoutubeDownload({ folder: safe, url, title })
- res.json({ ok: true, jobId: job.id, videoId: job.videoId })
- } catch (err) {
- if (err instanceof YtDlpUnavailableError) {
- res.status(503).json({ ok: false, message: err.message, code: 'NO_YTDLP' })
- return
+opRouter.post(
+ ['/op/folder/:topName/video/youtube/start', '/op/folder/:topName/:subName/video/youtube/start'],
+ requireAuth,
+ async (req, res) => {
+ try {
+ const folder = getFolderByPath(collectFolderSegments(req.params))
+ if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
+ const url = pickStr(req.body.url).trim()
+ const title = pickStr(req.body.title).trim() || undefined
+ if (!url) throw new Error('URL 을 입력해 주세요.')
+ const job = await startYoutubeDownload({ folderId: folder.id, url, title })
+ res.json({ ok: true, jobId: job.id, videoId: job.videoId })
+ } catch (err) {
+ if (err instanceof YtDlpUnavailableError) {
+ res.status(503).json({ ok: false, message: err.message, code: 'NO_YTDLP' })
+ return
+ }
+ res.status(400).json({ ok: false, message: (err as Error).message })
}
- res.status(400).json({ ok: false, message: (err as Error).message })
}
-})
+)
opRouter.get('/op/job/:id', requireAuth, (req, res) => {
- // 폴링 응답이 304 로 캐싱되면 브라우저가 body 없는 응답을 돌려줘서
- // 클라이언트의 r.json() 이 reject → 폴링이 중단된다. 항상 신선한 응답.
+ // 폴링 응답이 304 로 캐싱되면 클라이언트의 r.json() 이 reject → 폴링 중단된다.
res.set('Cache-Control', 'no-store, no-cache, must-revalidate')
res.set('Pragma', 'no-cache')
const job = getJob(req.params.id)
@@ -303,12 +311,58 @@ opRouter.get('/op/job/:id', requireAuth, (req, res) => {
res.json({ ok: true, job })
})
-// 편집 저장: trim 정보를 받아 ffmpeg 로 edited. 생성. 원본은 그대로 보존.
-opRouter.post('/op/folder/:name/video/save', requireAuth, async (req, res) => {
+// ─── 영상 mutation (id 기반 — 전역 유일 ID) ─────────────────────────────
+
+opRouter.post('/op/videos/:id/rename', requireAuth, (req, res) => {
try {
- const safe = sanitizeFolderName(req.params.name)
- if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
- const id = pickStr(req.body.id)
+ const id = req.params.id
+ const title = pickStr(req.body.title).trim()
+ if (!title) throw new Error('제목을 입력해 주세요.')
+ if (!getVideo(id)) throw new Error('영상을 찾을 수 없습니다.')
+ updateVideo(id, { title })
+ res.json({ ok: true })
+ } catch (err) {
+ res.status(400).json({ ok: false, message: (err as Error).message })
+ }
+})
+
+opRouter.post('/op/videos/:id/changeId', requireAuth, async (req, res) => {
+ try {
+ const oldId = req.params.id
+ const newId = pickStr(req.body.newId).trim()
+ if (!newId) throw new Error('새 ID 를 입력해 주세요.')
+ const video = await changeVideoId(oldId, newId)
+ res.json({ ok: true, video })
+ } catch (err) {
+ res.status(400).json({ ok: false, message: (err as Error).message })
+ }
+})
+
+/** 새 ID 가 사용 가능한지 미리 확인 (인풋 옆 실시간 표시용). */
+opRouter.get('/op/videos/idAvailable', requireAuth, (req, res) => {
+ const id = typeof req.query.id === 'string' ? req.query.id.trim() : ''
+ if (!id) {
+ res.json({ ok: true, available: false, reason: '비어 있음' })
+ return
+ }
+ const available = !videoIdExists(id)
+ res.json({ ok: true, available })
+})
+
+opRouter.post('/op/videos/:id/delete', requireAuth, async (req, res) => {
+ try {
+ await deleteVideo(req.params.id)
+ res.json({ ok: true })
+ } catch (err) {
+ res.status(400).json({ ok: false, message: (err as Error).message })
+ }
+})
+
+opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => {
+ try {
+ const id = req.params.id
+ const video = getVideo(id)
+ if (!video) throw new Error('영상을 찾을 수 없습니다.')
const title = pickStr(req.body.title).trim()
const startSec = Number(req.body.startSec ?? 0) || 0
const endRaw = req.body.endSec
@@ -316,14 +370,14 @@ opRouter.post('/op/folder/:name/video/save', requireAuth, async (req, res) => {
endRaw === null || endRaw === undefined || endRaw === ''
? null
: Number(endRaw)
- const meta = await loadVideoMeta(safe, id)
- if (!meta) throw new Error('영상을 찾을 수 없습니다.')
- if (title) meta.title = title
- meta.trim = { startSec, endSec: endSec == null || Number.isNaN(endSec) ? null : endSec }
- await saveVideoMeta(safe, meta)
+ const trim = {
+ startSec,
+ endSec: endSec == null || Number.isNaN(endSec) ? null : endSec
+ }
+ updateVideo(id, { trim, ...(title ? { title } : {}) })
// ffmpeg 가 없으면 trim 정보만 저장하고 안내.
try {
- const outName = await applyTrimToVideo(safe, id, meta.trim)
+ const outName = await applyTrimToVideo(id, trim)
res.json({ ok: true, editedFile: outName, note: '편집본 저장 완료' })
} catch (err) {
if (err instanceof FfmpegUnavailableError) {
@@ -340,3 +394,26 @@ opRouter.post('/op/folder/:name/video/save', requireAuth, async (req, res) => {
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
+
+// ─── 헬퍼 ─────────────────────────────────────────────────────────────
+
+function collectFolderSegments(params: Record): string[] {
+ const out: string[] = []
+ if (params.topName) out.push(params.topName)
+ if (params.subName) out.push(params.subName)
+ return out
+}
+
+/** 폴더 ID → admin URL. 클라이언트가 form action 만들 때 미사용이면 제거 가능. */
+export function adminFolderUrl(folderId: number): string {
+ const names = folderPathNames(folderId)
+ return '/op/folder/' + names.map((s) => encodeURIComponent(s)).join('/')
+}
+
+/** 비디오 ID → 공개 공유 URL (/video/topName/[subName/]videoId). */
+export function videoShareUrl(videoId: string): string | null {
+ const v = getVideo(videoId)
+ if (!v) return null
+ const names = folderPathNames(v.folderId)
+ return '/video/' + [...names, videoId].map((s) => encodeURIComponent(s)).join('/')
+}
diff --git a/src/routes/public.ts b/src/routes/public.ts
index b3b913c..8233fa8 100644
--- a/src/routes/public.ts
+++ b/src/routes/public.ts
@@ -1,84 +1,108 @@
import { Router, type RequestHandler } from 'express'
-import path from 'node:path'
-import { promises as fs } from 'node:fs'
import {
- findVideoAnywhere,
- folderPath,
- listFolders,
- listVideos,
- loadVideoMeta,
- sanitizeFolderName,
- videoDir,
- videoFileFsPath
-} from '../store.js'
+ getFolderByPath,
+ getVideo,
+ listChildFolders,
+ listTopFolders,
+ listVideosInFolder,
+ folderPathNames,
+ videoFileFsPath,
+ type Folder,
+ type Video
+} from '../storeDb.js'
export const publicRouter = Router()
-publicRouter.get('/', async (_req, res, next) => {
+publicRouter.get('/', (_req, res, next) => {
try {
- const folders = await listFolders()
+ const folders = listTopFolders()
res.render('index', { folders })
} catch (err) {
next(err)
}
})
-publicRouter.get('/folder/:name', async (req, res, next) => {
+/**
+ * 폴더 보기 (공개).
+ * - /folder/:topName — 최상위 폴더 (자식 폴더 + 영상 혼합 가능)
+ * - /folder/:topName/:subName — 서브폴더 (영상만)
+ */
+const folderViewHandler: RequestHandler = (req, res, next) => {
try {
- const safe = sanitizeFolderName(req.params.name)
- if (!safe) {
+ const segments = collectFolderSegments(req.params)
+ const folder = getFolderByPath(segments)
+ if (!folder) {
res.status(404).send('폴더를 찾을 수 없습니다.')
return
}
- // 존재 확인
- try {
- await fs.access(folderPath(safe))
- } catch {
- res.status(404).send('폴더를 찾을 수 없습니다.')
- return
- }
- const videos = await listVideos(safe)
- res.render('folder', { folder: safe, videos, isAdmin: false })
+ const subFolders = folder.parentId === null ? listChildFolders(folder.id) : []
+ const videos = listVideosInFolder(folder.id)
+ res.render('folder', {
+ folder,
+ breadcrumb: folderPathNames(folder.id),
+ subFolders,
+ videos,
+ isAdmin: false,
+ isSubFolder: folder.parentId !== null
+ })
} catch (err) {
next(err)
}
-})
+}
+publicRouter.get('/folder/:topName', folderViewHandler)
+publicRouter.get('/folder/:topName/:subName', folderViewHandler)
-publicRouter.get('/player/:videoId', async (req, res, next) => {
+/**
+ * 영상 보기 (공개).
+ * - /video/:topName/:videoId
+ * - /video/:topName/:subName/:videoId
+ *
+ * 경로의 마지막 세그먼트는 영상 ID. 앞쪽은 그 영상이 속한 폴더 경로와 일치해야 한다.
+ * (DB lookup 한 번이므로 path 가 ID 의 정합성 검증 역할도 함)
+ */
+const videoViewHandler: RequestHandler = (req, res, next) => {
try {
- const found = await findVideoAnywhere(req.params.videoId)
- if (!found) {
+ const { folderSegments, videoId } = collectVideoSegments(req.params)
+ const video = getVideo(videoId)
+ if (!video) {
res.status(404).send('영상을 찾을 수 없습니다.')
return
}
- res.render('player', { folder: found.folder, video: found.meta })
+ const folderPath = folderPathNames(video.folderId)
+ if (!segmentsEqual(folderPath, folderSegments)) {
+ res.status(404).send('영상 경로가 일치하지 않습니다.')
+ return
+ }
+ res.render('player', { video, breadcrumb: folderPath })
} catch (err) {
next(err)
}
-})
+}
+publicRouter.get('/video/:topName/:videoId', videoViewHandler)
+publicRouter.get('/video/:topName/:subName/:videoId', videoViewHandler)
/**
- * 영상 파일 스트리밍.
- * - 짧은 외부 공유용 경로: /file/video/:videoId
- * - 기존 경로: /api/video/:videoId/file (호환용으로 유지)
- * - ?edited=0 이면 원본을 강제하고, 기본은 편집본 있으면 편집본.
+ * 영상 파일 스트리밍 (짧은 ID-only URL — 외부 공유 호환용).
+ * - GET /file/video/:videoId
+ * - GET /api/video/:videoId/file (legacy alias)
+ * ?edited=0 이면 원본을 강제, 기본은 편집본 있으면 편집본.
*/
-const streamVideoHandler: RequestHandler = async (req, res, next) => {
+const streamVideoHandler: RequestHandler = (req, res, next) => {
try {
- const found = await findVideoAnywhere(req.params.videoId)
- if (!found) {
+ const video = getVideo(req.params.videoId)
+ if (!video) {
res.status(404).end()
return
}
const editedParam = typeof req.query.edited === 'string' ? req.query.edited : ''
const wantOriginal = editedParam === '0' || editedParam === 'false'
const fileName =
- !wantOriginal && found.meta.editedFile ? found.meta.editedFile : found.meta.originalFile
+ !wantOriginal && video.editedFile ? video.editedFile : video.originalFile
if (!fileName || fileName.includes('%(ext)s')) {
res.status(404).end()
return
}
- const fsPath = videoFileFsPath(found.folder, found.meta.id, fileName)
+ const fsPath = videoFileFsPath(video.id, fileName)
res.sendFile(fsPath)
} catch (err) {
next(err)
@@ -87,16 +111,49 @@ const streamVideoHandler: RequestHandler = async (req, res, next) => {
publicRouter.get('/file/video/:videoId', streamVideoHandler)
publicRouter.get('/api/video/:videoId/file', streamVideoHandler)
-/** 비디오 메타 조회 (플레이어/관리자 양쪽에서 사용) */
-publicRouter.get('/api/video/:videoId', async (req, res, next) => {
+/** 비디오 메타 조회 (플레이어/관리자 양쪽에서 사용). */
+publicRouter.get('/api/video/:videoId', (req, res, next) => {
try {
- const found = await findVideoAnywhere(req.params.videoId)
- if (!found) {
+ const video = getVideo(req.params.videoId)
+ if (!video) {
res.status(404).json({ ok: false, message: '영상을 찾을 수 없습니다.' })
return
}
- res.json({ ok: true, folder: found.folder, video: found.meta })
+ res.json({
+ ok: true,
+ video,
+ breadcrumb: folderPathNames(video.folderId)
+ })
} catch (err) {
next(err)
}
})
+
+// ─── 헬퍼 ─────────────────────────────────────────────────────────────
+
+function collectFolderSegments(params: Record): string[] {
+ const out: string[] = []
+ if (params.topName) out.push(params.topName)
+ if (params.subName) out.push(params.subName)
+ return out
+}
+
+function collectVideoSegments(params: Record): {
+ folderSegments: string[]
+ videoId: string
+} {
+ const folderSegments: string[] = []
+ if (params.topName) folderSegments.push(params.topName)
+ if (params.subName) folderSegments.push(params.subName)
+ return { folderSegments, videoId: params.videoId ?? '' }
+}
+
+function segmentsEqual(a: string[], b: string[]): boolean {
+ if (a.length !== b.length) return false
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] !== b[i]) return false
+ }
+ return true
+}
+
+export type { Folder, Video }
diff --git a/src/store.ts b/src/store.ts
index d55b4c1..5a364a9 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -1,31 +1,12 @@
-import { promises as fs, createReadStream, createWriteStream } from 'node:fs'
-import path from 'node:path'
-import { randomUUID } from 'node:crypto'
-import { foldersDir, accountJsonPath } from './paths.js'
+import { promises as fs } from 'node:fs'
+import { accountJsonPath } from './paths.js'
export interface Account {
id: string
password: string
}
-export interface VideoTrim {
- startSec: number
- endSec: number | null // null = until end
-}
-
-export interface VideoMeta {
- id: string
- title: string
- originalFile: string // relative to //
- editedFile: string | null
- durationSec: number | null
- sourceType: 'upload' | 'youtube'
- sourceUrl: string | null
- trim: VideoTrim | null
- createdAt: string
- updatedAt: string
-}
-
+/** account.json 에서 로그인 계정 목록을 읽는다. (나머지 파일 기반 저장소는 storeDb.ts 로 이전됨.) */
export async function readAccounts(): Promise {
try {
const raw = await fs.readFile(accountJsonPath, 'utf8')
@@ -40,181 +21,3 @@ export async function readAccounts(): Promise {
throw err
}
}
-
-// 폴더/영상 이름에 사용 가능한 문자만 허용. 경로 탈출 방지.
-const SAFE_NAME = /^[\p{L}\p{N}_\- ]+$/u
-
-export function sanitizeFolderName(raw: string): string {
- const trimmed = (raw || '').trim()
- if (trimmed.length === 0 || trimmed.length > 80) return ''
- if (!SAFE_NAME.test(trimmed)) return ''
- if (trimmed === '.' || trimmed === '..') return ''
- return trimmed
-}
-
-export function isSafeVideoId(id: string): boolean {
- return /^[a-zA-Z0-9_-]{8,64}$/.test(id)
-}
-
-export async function ensureFoldersDir(): Promise {
- await fs.mkdir(foldersDir, { recursive: true })
-}
-
-export async function listFolders(): Promise {
- await ensureFoldersDir()
- const entries = await fs.readdir(foldersDir, { withFileTypes: true })
- return entries
- .filter((e) => e.isDirectory())
- .map((e) => e.name)
- .filter((name) => sanitizeFolderName(name).length > 0)
- .sort((a, b) => a.localeCompare(b, 'ko'))
-}
-
-export async function createFolder(name: string): Promise {
- const safe = sanitizeFolderName(name)
- if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
- const target = path.join(foldersDir, safe)
- await fs.mkdir(target, { recursive: false }).catch((err) => {
- if (err.code === 'EEXIST') throw new Error('이미 존재하는 폴더입니다.')
- throw err
- })
- return safe
-}
-
-export async function renameFolder(oldName: string, newName: string): Promise {
- const oldSafe = sanitizeFolderName(oldName)
- const newSafe = sanitizeFolderName(newName)
- if (!oldSafe || !newSafe) throw new Error('폴더 이름이 올바르지 않습니다.')
- if (oldSafe === newSafe) return oldSafe
- const from = path.join(foldersDir, oldSafe)
- const to = path.join(foldersDir, newSafe)
- try {
- await fs.access(to)
- throw new Error('이미 존재하는 폴더입니다.')
- } catch (err) {
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
- }
- await fs.rename(from, to)
- return newSafe
-}
-
-export async function deleteFolder(name: string): Promise {
- const safe = sanitizeFolderName(name)
- if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
- const target = path.join(foldersDir, safe)
- await fs.rm(target, { recursive: true, force: true })
-}
-
-export function folderPath(name: string): string {
- const safe = sanitizeFolderName(name)
- if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
- return path.join(foldersDir, safe)
-}
-
-export function videoDir(folder: string, videoId: string): string {
- if (!isSafeVideoId(videoId)) throw new Error('비디오 ID가 올바르지 않습니다.')
- return path.join(folderPath(folder), videoId)
-}
-
-export function videoMetaPath(folder: string, videoId: string): string {
- return path.join(videoDir(folder, videoId), 'meta.json')
-}
-
-export async function listVideos(folder: string): Promise {
- const dir = folderPath(folder)
- try {
- const entries = await fs.readdir(dir, { withFileTypes: true })
- const metas: VideoMeta[] = []
- for (const entry of entries) {
- if (!entry.isDirectory()) continue
- if (!isSafeVideoId(entry.name)) continue
- const meta = await loadVideoMeta(folder, entry.name).catch(() => null)
- if (meta) metas.push(meta)
- }
- metas.sort((a, b) => (b.createdAt < a.createdAt ? -1 : 1))
- return metas
- } catch (err) {
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
- throw err
- }
-}
-
-export async function loadVideoMeta(folder: string, videoId: string): Promise {
- try {
- const raw = await fs.readFile(videoMetaPath(folder, videoId), 'utf8')
- return JSON.parse(raw) as VideoMeta
- } catch (err) {
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
- throw err
- }
-}
-
-export async function findVideoAnywhere(
- videoId: string
-): Promise<{ folder: string; meta: VideoMeta } | null> {
- if (!isSafeVideoId(videoId)) return null
- const folders = await listFolders()
- for (const folder of folders) {
- const meta = await loadVideoMeta(folder, videoId).catch(() => null)
- if (meta) return { folder, meta }
- }
- return null
-}
-
-export async function saveVideoMeta(folder: string, meta: VideoMeta): Promise {
- const dir = videoDir(folder, meta.id)
- await fs.mkdir(dir, { recursive: true })
- meta.updatedAt = new Date().toISOString()
- await fs.writeFile(videoMetaPath(folder, meta.id), JSON.stringify(meta, null, 2))
-}
-
-export async function deleteVideo(folder: string, videoId: string): Promise {
- const dir = videoDir(folder, videoId)
- await fs.rm(dir, { recursive: true, force: true })
-}
-
-export function newVideoId(): string {
- // URL-safe 22-char id (uuid 압축).
- return randomUUID().replace(/-/g, '').slice(0, 24)
-}
-
-export function videoFileFsPath(folder: string, videoId: string, rel: string): string {
- // rel 은 meta 에 저장된 파일명. 경로 탈출 방지.
- if (rel.includes('/') || rel.includes('\\') || rel.includes('..')) {
- throw new Error('잘못된 파일 경로입니다.')
- }
- return path.join(videoDir(folder, videoId), rel)
-}
-
-/** 업로드 임시 파일을 비디오 디렉토리로 이동. */
-export async function moveUploadIntoVideo(
- folder: string,
- videoId: string,
- tmpFile: string,
- destName: string
-): Promise {
- if (destName.includes('/') || destName.includes('\\') || destName.includes('..')) {
- throw new Error('잘못된 파일명입니다.')
- }
- const dir = videoDir(folder, videoId)
- await fs.mkdir(dir, { recursive: true })
- const target = path.join(dir, destName)
- try {
- await fs.rename(tmpFile, target)
- } catch (err) {
- if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
- // 다른 디바이스에 있으면 copy + unlink
- await new Promise((resolve, reject) => {
- const r = createReadStream(tmpFile)
- const w = createWriteStream(target)
- r.on('error', reject)
- w.on('error', reject)
- w.on('close', () => resolve())
- r.pipe(w)
- })
- await fs.unlink(tmpFile).catch(() => undefined)
- } else {
- throw err
- }
- }
-}
diff --git a/src/storeDb.ts b/src/storeDb.ts
new file mode 100644
index 0000000..311e8fe
--- /dev/null
+++ b/src/storeDb.ts
@@ -0,0 +1,524 @@
+/**
+ * DB(SQLite) 기반 폴더/영상 CRUD + 디스크 동기화 레이어.
+ *
+ * 디스크 레이아웃 (변경 없음):
+ * data/folders/{topName}/[{subName}/]{videoId}/...
+ *
+ * DB 가 source of truth. 변경 흐름:
+ * 1) 검증 (이름/ID/깊이/존재여부)
+ * 2) DB UPDATE/INSERT/DELETE
+ * 3) 디스크 동기화 (mkdir / rename / rm)
+ * 4) 실패 시 DB 롤백 (사용 가능한 경우)
+ *
+ * DB → 디스크 순서로 가는 이유: DB 가 UNIQUE 등 제약을 검사해 주므로 충돌을 일찍 잡고
+ * 디스크 작업으로 안 넘어간다. 디스크 작업이 실패하면 DB 변경을 되돌린다.
+ */
+import { promises as fsp } from 'node:fs'
+import path from 'node:path'
+import { randomUUID } from 'node:crypto'
+import { getDb } from './db.js'
+import { foldersDir } from './paths.js'
+
+// ─── 검증 ──────────────────────────────────────────────────────────────
+
+const SAFE_NAME = /^[\p{L}\p{N}_\- ]+$/u
+const SAFE_VIDEO_ID = /^[a-zA-Z0-9_-]{1,64}$/
+
+export function sanitizeFolderName(raw: string): string {
+ const trimmed = (raw || '').trim()
+ if (!trimmed || trimmed.length > 80) return ''
+ if (trimmed === '.' || trimmed === '..') return ''
+ if (!SAFE_NAME.test(trimmed)) return ''
+ return trimmed
+}
+
+export function isSafeVideoId(id: string): boolean {
+ return typeof id === 'string' && SAFE_VIDEO_ID.test(id)
+}
+
+export function newRandomVideoId(): string {
+ // URL-safe 24-char id (uuid 압축).
+ return randomUUID().replace(/-/g, '').slice(0, 24)
+}
+
+// ─── 타입 ──────────────────────────────────────────────────────────────
+
+export interface Folder {
+ id: number
+ parentId: number | null
+ name: string
+ position: number
+ createdAt: string
+ updatedAt: string
+}
+
+export interface VideoTrim {
+ startSec: number
+ endSec: number | null
+}
+
+export interface Video {
+ id: string
+ folderId: number
+ title: string
+ originalFile: string
+ editedFile: string | null
+ durationSec: number | null
+ sourceType: 'upload' | 'youtube'
+ sourceUrl: string | null
+ trim: VideoTrim | null
+ position: number
+ createdAt: string
+ updatedAt: string
+}
+
+interface FolderRow {
+ id: number
+ parent_id: number | null
+ name: string
+ position: number
+ created_at: string
+ updated_at: string
+}
+
+interface VideoRow {
+ id: string
+ folder_id: number
+ title: string
+ original_file: string
+ edited_file: string | null
+ duration_sec: number | null
+ source_type: 'upload' | 'youtube'
+ source_url: string | null
+ trim_start_sec: number | null
+ trim_end_sec: number | null
+ position: number
+ created_at: string
+ updated_at: string
+}
+
+function rowToFolder(r: FolderRow): Folder {
+ return {
+ id: r.id,
+ parentId: r.parent_id,
+ name: r.name,
+ position: r.position,
+ createdAt: r.created_at,
+ updatedAt: r.updated_at
+ }
+}
+
+function rowToVideo(r: VideoRow): Video {
+ const hasTrim = r.trim_start_sec !== null || r.trim_end_sec !== null
+ return {
+ id: r.id,
+ folderId: r.folder_id,
+ title: r.title,
+ originalFile: r.original_file,
+ editedFile: r.edited_file,
+ durationSec: r.duration_sec,
+ sourceType: r.source_type,
+ sourceUrl: r.source_url,
+ trim: hasTrim
+ ? { startSec: r.trim_start_sec ?? 0, endSec: r.trim_end_sec }
+ : null,
+ position: r.position,
+ createdAt: r.created_at,
+ updatedAt: r.updated_at
+ }
+}
+
+// ─── 폴더 조회 ─────────────────────────────────────────────────────────
+
+export function listTopFolders(): Folder[] {
+ const rows = getDb()
+ .prepare(
+ `SELECT * FROM folders WHERE parent_id IS NULL ORDER BY position, name`
+ )
+ .all() as FolderRow[]
+ return rows.map(rowToFolder)
+}
+
+export function listChildFolders(parentId: number): Folder[] {
+ const rows = getDb()
+ .prepare(`SELECT * FROM folders WHERE parent_id = ? ORDER BY position, name`)
+ .all(parentId) as FolderRow[]
+ return rows.map(rowToFolder)
+}
+
+export function getFolder(id: number): Folder | null {
+ const r = getDb()
+ .prepare(`SELECT * FROM folders WHERE id = ?`)
+ .get(id) as FolderRow | undefined
+ return r ? rowToFolder(r) : null
+}
+
+export function getTopFolderByName(name: string): Folder | null {
+ const safe = sanitizeFolderName(name)
+ if (!safe) return null
+ const r = getDb()
+ .prepare(`SELECT * FROM folders WHERE parent_id IS NULL AND name = ?`)
+ .get(safe) as FolderRow | undefined
+ return r ? rowToFolder(r) : null
+}
+
+export function getChildFolderByName(parentId: number, name: string): Folder | null {
+ const safe = sanitizeFolderName(name)
+ if (!safe) return null
+ const r = getDb()
+ .prepare(`SELECT * FROM folders WHERE parent_id = ? AND name = ?`)
+ .get(parentId, safe) as FolderRow | undefined
+ return r ? rowToFolder(r) : null
+}
+
+/** URL 경로 [topName] 또는 [topName, subName] → 폴더 1건. 못 찾으면 null. */
+export function getFolderByPath(names: string[]): Folder | null {
+ if (names.length === 0 || names.length > 2) return null
+ const top = getTopFolderByName(names[0])
+ if (!top) return null
+ if (names.length === 1) return top
+ return getChildFolderByName(top.id, names[1])
+}
+
+/** 폴더 → 디스크 경로 (parent 까지 거슬러 올라가 이름 join). */
+export function folderDiskPath(folderId: number): string {
+ const names = folderPathNames(folderId)
+ return path.join(foldersDir, ...names)
+}
+
+/** 폴더 → URL 경로 세그먼트 배열 (인코딩 전). */
+export function folderPathNames(folderId: number): string[] {
+ const names: string[] = []
+ let cur: Folder | null = getFolder(folderId)
+ while (cur) {
+ names.unshift(cur.name)
+ cur = cur.parentId !== null ? getFolder(cur.parentId) : null
+ }
+ return names
+}
+
+// ─── 폴더 변경 ─────────────────────────────────────────────────────────
+
+export async function createFolder(input: {
+ name: string
+ parentId: number | null
+}): Promise {
+ const safe = sanitizeFolderName(input.name)
+ if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
+ // 깊이 검사: 부모가 이미 서브폴더면 손자 폴더는 만들 수 없다 (최대 2단계).
+ if (input.parentId !== null) {
+ const parent = getFolder(input.parentId)
+ if (!parent) throw new Error('상위 폴더를 찾을 수 없습니다.')
+ if (parent.parentId !== null) {
+ throw new Error('서브폴더 안에는 폴더를 만들 수 없습니다.')
+ }
+ }
+ const now = new Date().toISOString()
+ const db = getDb()
+ // position = 현재 형제 개수 (끝에 추가)
+ const siblings = (db
+ .prepare(
+ input.parentId === null
+ ? `SELECT COUNT(*) as c FROM folders WHERE parent_id IS NULL`
+ : `SELECT COUNT(*) as c FROM folders WHERE parent_id = ?`
+ )
+ .get(...(input.parentId === null ? [] : [input.parentId])) as { c: number }).c
+
+ let info
+ try {
+ info = db
+ .prepare(
+ `INSERT INTO folders (parent_id, name, position, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?)`
+ )
+ .run(input.parentId, safe, siblings, now, now)
+ } catch (err) {
+ if ((err as Error).message.includes('UNIQUE')) {
+ throw new Error('이미 존재하는 폴더입니다.')
+ }
+ throw err
+ }
+ const folderId = Number(info.lastInsertRowid)
+ // 디스크 디렉토리 생성 (이미 있을 수 있음 — 마이그레이션 케이스).
+ const diskPath = folderDiskPath(folderId)
+ try {
+ await fsp.mkdir(diskPath, { recursive: true })
+ } catch (err) {
+ // 디스크 실패 시 DB 롤백.
+ db.prepare(`DELETE FROM folders WHERE id = ?`).run(folderId)
+ throw err
+ }
+ return getFolder(folderId)!
+}
+
+export async function renameFolder(folderId: number, newName: string): Promise {
+ const folder = getFolder(folderId)
+ if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
+ const safe = sanitizeFolderName(newName)
+ if (!safe) throw new Error('새 폴더 이름이 올바르지 않습니다.')
+ if (safe === folder.name) return folder
+
+ const oldDisk = folderDiskPath(folderId)
+ const db = getDb()
+ try {
+ db.prepare(`UPDATE folders SET name = ?, updated_at = ? WHERE id = ?`).run(
+ safe,
+ new Date().toISOString(),
+ folderId
+ )
+ } catch (err) {
+ if ((err as Error).message.includes('UNIQUE')) {
+ throw new Error('이미 존재하는 폴더 이름입니다.')
+ }
+ throw err
+ }
+ const newDisk = folderDiskPath(folderId)
+ try {
+ await fsp.rename(oldDisk, newDisk)
+ } catch (err) {
+ // ENOENT (디스크 디렉토리가 없는 경우) 는 무시하고 비어있는 새 디렉토리 생성.
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
+ await fsp.mkdir(newDisk, { recursive: true })
+ } else {
+ // 디스크 rename 실패 → DB 롤백
+ db.prepare(`UPDATE folders SET name = ? WHERE id = ?`).run(folder.name, folderId)
+ throw err
+ }
+ }
+ return getFolder(folderId)!
+}
+
+export async function deleteFolder(folderId: number): Promise {
+ const folder = getFolder(folderId)
+ if (!folder) return // idempotent
+ const diskPath = folderDiskPath(folderId)
+ // DB CASCADE 가 자식 폴더/영상까지 모두 삭제.
+ getDb().prepare(`DELETE FROM folders WHERE id = ?`).run(folderId)
+ await fsp.rm(diskPath, { recursive: true, force: true })
+}
+
+// ─── 영상 조회 ─────────────────────────────────────────────────────────
+
+export function getVideo(id: string): Video | null {
+ if (!isSafeVideoId(id)) return null
+ const r = getDb()
+ .prepare(`SELECT * FROM videos WHERE id = ?`)
+ .get(id) as VideoRow | undefined
+ return r ? rowToVideo(r) : null
+}
+
+export function listVideosInFolder(folderId: number): Video[] {
+ const rows = getDb()
+ .prepare(
+ `SELECT * FROM videos WHERE folder_id = ? ORDER BY position, created_at DESC`
+ )
+ .all(folderId) as VideoRow[]
+ return rows.map(rowToVideo)
+}
+
+export function videoIdExists(id: string): boolean {
+ if (!isSafeVideoId(id)) return false
+ const r = getDb()
+ .prepare(`SELECT 1 FROM videos WHERE id = ?`)
+ .get(id)
+ return !!r
+}
+
+/** 영상 → 디스크 디렉토리 (폴더 경로 + videoId). */
+export function videoDiskDir(videoId: string): string {
+ const v = getVideo(videoId)
+ if (!v) throw new Error('영상을 찾을 수 없습니다.')
+ return path.join(folderDiskPath(v.folderId), v.id)
+}
+
+/** 영상 → 공유 URL 의 경로 부분 (인코딩 전 세그먼트). */
+export function videoPathNames(videoId: string): string[] | null {
+ const v = getVideo(videoId)
+ if (!v) return null
+ return [...folderPathNames(v.folderId), v.id]
+}
+
+/** 영상 디렉토리 내 파일의 절대 경로. 경로 탈출 방지. */
+export function videoFileFsPath(videoId: string, rel: string): string {
+ if (!rel || rel.includes('/') || rel.includes('\\') || rel.includes('..')) {
+ throw new Error('잘못된 파일 경로입니다.')
+ }
+ return path.join(videoDiskDir(videoId), rel)
+}
+
+// ─── 영상 변경 ─────────────────────────────────────────────────────────
+
+export interface CreateVideoInput {
+ id?: string // 미지정 시 랜덤
+ folderId: number
+ title: string
+ originalFile: string // 디렉토리 안 상대 경로 (또는 'original.%(ext)s' 같은 yt-dlp 임시 패턴)
+ sourceType: 'upload' | 'youtube'
+ sourceUrl?: string | null
+}
+
+export async function createVideo(input: CreateVideoInput): Promise
<% }) %>
-
-
-
-