feat(folders): nested folder tree (max depth 2) + id-based mutations
- new storeDb.ts: DB+FS layer (folders w/ parent_id, videos w/ global id)
- depth cap: sub-folder cannot have children (enforced in createFolder)
- video id: editable anytime (changeVideoId), uniqueness via PK
- routes:
- public /folder/:topName[/:subName], /video/:topName[/:subName]/:videoId
- admin /op/folder/:topName[/:subName], id-based /op/folders/:id/*
and /op/videos/:id/* (rename/changeId/delete/save + idAvailable check)
- views: breadcrumb + subFolder grid; 플레이리스트 추가 button shows at 2단계
- client JS: id-based mutation endpoints, share URL data attribute,
in-site player pushes share URL to address bar
- store.ts slimmed to Account + readAccounts (FS layer moved to storeDb)
- pickIntId accepts numeric JSON bodies
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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,6 +61,7 @@
|
||||
|
||||
function openModal() { modal.hidden = false; input.value = ''; setTimeout(function () { input.focus() }, 0) }
|
||||
function closeModal() { modal.hidden = true }
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', openModal)
|
||||
cancelBtn.addEventListener('click', closeModal)
|
||||
modal.addEventListener('click', function (e) { if (e.target === modal) closeModal() })
|
||||
@@ -77,4 +78,5 @@
|
||||
else alert(j.message || '폴더 생성 실패')
|
||||
})
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -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 || '저장 실패')
|
||||
}
|
||||
|
||||
171
public/folder.js
171
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) {
|
||||
|
||||
@@ -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) {
|
||||
// <a> 태그면 클릭 시 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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
327
src/routes/op.ts
327
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 segments = collectFolderSegments(req.params)
|
||||
const folder = getFolderByPath(segments)
|
||||
if (!folder) {
|
||||
res.status(404).send('폴더를 찾을 수 없습니다.')
|
||||
return
|
||||
}
|
||||
const subFolders = folder.parentId === null ? listChildFolders(folder.id) : []
|
||||
const videos = listVideosInFolder(folder.id)
|
||||
res.render('op/folder', {
|
||||
userId: req.session.userId,
|
||||
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 safe = await createFolder(name)
|
||||
res.json({ ok: true, name: safe })
|
||||
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/folders/rename', requireAuth, async (req, res) => {
|
||||
opRouter.post('/op/folders/:id/rename', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const oldName = pickStr(req.body.oldName)
|
||||
const id = pickIntId(req.params.id)
|
||||
if (id === null) throw new Error('폴더 ID 가 올바르지 않습니다.')
|
||||
const newName = pickStr(req.body.newName)
|
||||
const result = await renameFolder(oldName, newName)
|
||||
res.json({ ok: true, name: result })
|
||||
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/delete', requireAuth, async (req, res) => {
|
||||
opRouter.post('/op/folders/:id/delete', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const name = pickStr(req.body.name)
|
||||
await deleteFolder(name)
|
||||
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.get('/op/folder/:name', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const safe = sanitizeFolderName(req.params.name)
|
||||
if (!safe) {
|
||||
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) => {
|
||||
opRouter.get(
|
||||
['/op/folder/:topName/video/editor', '/op/folder/:topName/:subName/video/editor'],
|
||||
requireAuth,
|
||||
(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
|
||||
}
|
||||
const videoId = typeof req.query.id === 'string' ? req.query.id : null
|
||||
let video: VideoMeta | null = null
|
||||
if (videoId) {
|
||||
video = await loadVideoMeta(safe, videoId)
|
||||
}
|
||||
const video = videoId ? getVideo(videoId) : null
|
||||
res.render('op/editor', {
|
||||
userId: req.session.userId,
|
||||
folder: safe,
|
||||
folder,
|
||||
breadcrumb: folderPathNames(folder.id),
|
||||
video
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/folder/:name/video/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)
|
||||
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 })
|
||||
}
|
||||
})
|
||||
// ─── 영상 업로드 / 유튜브 ───────────────────────────────────────────────
|
||||
|
||||
// multer 가 던지는 LIMIT_FILE_SIZE 같은 에러를 라우트 핸들러가 잡지 못해
|
||||
// 글로벌 에러 핸들러로 새서 stack trace 가 그대로 노출되던 문제를 막는다.
|
||||
function uploadSingle(fieldName: string) {
|
||||
const mw = upload.single(fieldName)
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -219,45 +231,37 @@ 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) => {
|
||||
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 을 입력해 주세요.')
|
||||
@@ -270,16 +274,20 @@ opRouter.post('/op/folder/:name/video/youtube/probe', requireAuth, async (req, r
|
||||
}
|
||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
opRouter.post('/op/folder/:name/video/youtube/start', requireAuth, async (req, res) => {
|
||||
opRouter.post(
|
||||
['/op/folder/:topName/video/youtube/start', '/op/folder/:topName/:subName/video/youtube/start'],
|
||||
requireAuth,
|
||||
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 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 })
|
||||
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) {
|
||||
@@ -288,11 +296,11 @@ opRouter.post('/op/folder/:name/video/youtube/start', requireAuth, async (req, r
|
||||
}
|
||||
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.<ext> 생성. 원본은 그대로 보존.
|
||||
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, string | undefined>): 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('/')
|
||||
}
|
||||
|
||||
@@ -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, string | undefined>): string[] {
|
||||
const out: string[] = []
|
||||
if (params.topName) out.push(params.topName)
|
||||
if (params.subName) out.push(params.subName)
|
||||
return out
|
||||
}
|
||||
|
||||
function collectVideoSegments(params: Record<string, string | undefined>): {
|
||||
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 }
|
||||
|
||||
203
src/store.ts
203
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 <folder>/<id>/
|
||||
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<Account[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(accountJsonPath, 'utf8')
|
||||
@@ -40,181 +21,3 @@ export async function readAccounts(): Promise<Account[]> {
|
||||
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<void> {
|
||||
await fs.mkdir(foldersDir, { recursive: true })
|
||||
}
|
||||
|
||||
export async function listFolders(): Promise<string[]> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<VideoMeta[]> {
|
||||
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<VideoMeta | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void>((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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
524
src/storeDb.ts
Normal file
524
src/storeDb.ts
Normal file
@@ -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<Folder> {
|
||||
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<Folder> {
|
||||
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<void> {
|
||||
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<Video> {
|
||||
const folder = getFolder(input.folderId)
|
||||
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
|
||||
let id = input.id ?? newRandomVideoId()
|
||||
if (!isSafeVideoId(id)) throw new Error('영상 ID 가 올바르지 않습니다.')
|
||||
if (videoIdExists(id)) throw new Error('이미 사용 중인 영상 ID 입니다.')
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const db = getDb()
|
||||
// position = 현재 폴더 안 영상 개수 (끝에 추가)
|
||||
const count = (db
|
||||
.prepare(`SELECT COUNT(*) as c FROM videos WHERE folder_id = ?`)
|
||||
.get(input.folderId) as { c: number }).c
|
||||
db.prepare(
|
||||
`INSERT INTO videos
|
||||
(id, folder_id, title, original_file, edited_file, duration_sec,
|
||||
source_type, source_url, trim_start_sec, trim_end_sec,
|
||||
position, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, NULL, NULL, ?, ?, NULL, NULL, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
input.folderId,
|
||||
input.title,
|
||||
input.originalFile,
|
||||
input.sourceType,
|
||||
input.sourceUrl ?? null,
|
||||
count,
|
||||
now,
|
||||
now
|
||||
)
|
||||
// 디스크 디렉토리 생성
|
||||
const diskDir = path.join(folderDiskPath(input.folderId), id)
|
||||
try {
|
||||
await fsp.mkdir(diskDir, { recursive: true })
|
||||
} catch (err) {
|
||||
db.prepare(`DELETE FROM videos WHERE id = ?`).run(id)
|
||||
throw err
|
||||
}
|
||||
return getVideo(id)!
|
||||
}
|
||||
|
||||
export async function changeVideoId(oldId: string, newId: string): Promise<Video> {
|
||||
if (!isSafeVideoId(newId)) throw new Error('새 영상 ID 가 올바르지 않습니다.')
|
||||
if (oldId === newId) {
|
||||
const v = getVideo(oldId)
|
||||
if (!v) throw new Error('영상을 찾을 수 없습니다.')
|
||||
return v
|
||||
}
|
||||
const current = getVideo(oldId)
|
||||
if (!current) throw new Error('영상을 찾을 수 없습니다.')
|
||||
if (videoIdExists(newId)) throw new Error('이미 사용 중인 영상 ID 입니다.')
|
||||
|
||||
const oldDir = path.join(folderDiskPath(current.folderId), current.id)
|
||||
const newDir = path.join(folderDiskPath(current.folderId), newId)
|
||||
const db = getDb()
|
||||
try {
|
||||
db.prepare(`UPDATE videos SET id = ?, updated_at = ? WHERE id = ?`).run(
|
||||
newId,
|
||||
new Date().toISOString(),
|
||||
oldId
|
||||
)
|
||||
} catch (err) {
|
||||
if ((err as Error).message.includes('UNIQUE')) {
|
||||
throw new Error('이미 사용 중인 영상 ID 입니다.')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
try {
|
||||
await fsp.rename(oldDir, newDir)
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
// 디스크에 디렉토리가 없으면 빈 디렉토리 생성 (메타만 있는 케이스).
|
||||
await fsp.mkdir(newDir, { recursive: true })
|
||||
} else {
|
||||
db.prepare(`UPDATE videos SET id = ? WHERE id = ?`).run(oldId, newId)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return getVideo(newId)!
|
||||
}
|
||||
|
||||
export interface UpdateVideoPatch {
|
||||
title?: string
|
||||
originalFile?: string
|
||||
editedFile?: string | null
|
||||
durationSec?: number | null
|
||||
sourceUrl?: string | null
|
||||
trim?: VideoTrim | null
|
||||
}
|
||||
|
||||
export function updateVideo(id: string, patch: UpdateVideoPatch): Video {
|
||||
const current = getVideo(id)
|
||||
if (!current) throw new Error('영상을 찾을 수 없습니다.')
|
||||
|
||||
const fields: string[] = []
|
||||
const params: unknown[] = []
|
||||
if (patch.title !== undefined) {
|
||||
fields.push('title = ?')
|
||||
params.push(patch.title)
|
||||
}
|
||||
if (patch.originalFile !== undefined) {
|
||||
fields.push('original_file = ?')
|
||||
params.push(patch.originalFile)
|
||||
}
|
||||
if (patch.editedFile !== undefined) {
|
||||
fields.push('edited_file = ?')
|
||||
params.push(patch.editedFile)
|
||||
}
|
||||
if (patch.durationSec !== undefined) {
|
||||
fields.push('duration_sec = ?')
|
||||
params.push(patch.durationSec)
|
||||
}
|
||||
if (patch.sourceUrl !== undefined) {
|
||||
fields.push('source_url = ?')
|
||||
params.push(patch.sourceUrl)
|
||||
}
|
||||
if (patch.trim !== undefined) {
|
||||
if (patch.trim === null) {
|
||||
fields.push('trim_start_sec = NULL', 'trim_end_sec = NULL')
|
||||
} else {
|
||||
fields.push('trim_start_sec = ?', 'trim_end_sec = ?')
|
||||
params.push(patch.trim.startSec, patch.trim.endSec)
|
||||
}
|
||||
}
|
||||
if (fields.length === 0) return current
|
||||
fields.push('updated_at = ?')
|
||||
params.push(new Date().toISOString())
|
||||
params.push(id)
|
||||
getDb().prepare(`UPDATE videos SET ${fields.join(', ')} WHERE id = ?`).run(...params)
|
||||
return getVideo(id)!
|
||||
}
|
||||
|
||||
export async function deleteVideo(id: string): Promise<void> {
|
||||
const v = getVideo(id)
|
||||
if (!v) return
|
||||
const diskDir = path.join(folderDiskPath(v.folderId), v.id)
|
||||
getDb().prepare(`DELETE FROM videos WHERE id = ?`).run(id)
|
||||
await fsp.rm(diskDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// ─── 업로드 임시 파일 → 영상 디렉토리 이동 ─────────────────────────────
|
||||
|
||||
export async function moveUploadIntoVideoDir(
|
||||
videoId: string,
|
||||
tmpFile: string,
|
||||
destName: string
|
||||
): Promise<void> {
|
||||
if (!destName || destName.includes('/') || destName.includes('\\') || destName.includes('..')) {
|
||||
throw new Error('잘못된 파일명입니다.')
|
||||
}
|
||||
const dir = videoDiskDir(videoId)
|
||||
await fsp.mkdir(dir, { recursive: true })
|
||||
const target = path.join(dir, destName)
|
||||
try {
|
||||
await fsp.rename(tmpFile, target)
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
|
||||
// 다른 디바이스면 copyFile + unlink
|
||||
await fsp.copyFile(tmpFile, target)
|
||||
await fsp.unlink(tmpFile).catch(() => undefined)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,12 @@ import path from 'node:path'
|
||||
import { jobsDir, projectRoot } from './paths.js'
|
||||
import { upscaleOriginalTo60Fps } from './editor.js'
|
||||
import {
|
||||
loadVideoMeta,
|
||||
newVideoId,
|
||||
saveVideoMeta,
|
||||
sanitizeFolderName,
|
||||
videoDir,
|
||||
type VideoMeta
|
||||
} from './store.js'
|
||||
createVideo,
|
||||
getFolder,
|
||||
getVideo,
|
||||
updateVideo,
|
||||
videoDiskDir
|
||||
} from './storeDb.js'
|
||||
|
||||
export class YtDlpUnavailableError extends Error {
|
||||
constructor(message?: string) {
|
||||
@@ -94,7 +93,7 @@ export type JobStatus = 'queued' | 'downloading' | 'done' | 'error'
|
||||
|
||||
export interface DownloadJob {
|
||||
id: string
|
||||
folder: string
|
||||
folderId: number
|
||||
videoId: string
|
||||
url: string
|
||||
status: JobStatus
|
||||
@@ -122,39 +121,33 @@ export function listActiveJobs(): DownloadJob[] {
|
||||
}
|
||||
|
||||
export interface StartDownloadOpts {
|
||||
folder: string
|
||||
folderId: number
|
||||
url: string
|
||||
title?: string
|
||||
/** 호출자가 영상 ID 를 지정 (플레이리스트 일괄 등록용). 미지정 시 랜덤 생성. */
|
||||
videoId?: string
|
||||
}
|
||||
|
||||
/** 백그라운드 yt-dlp 다운로드를 시작. videoId 도 함께 생성/저장한다. */
|
||||
export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<DownloadJob> {
|
||||
const safeFolder = sanitizeFolderName(opts.folder)
|
||||
if (!safeFolder) throw new Error('폴더 이름이 올바르지 않습니다.')
|
||||
const folder = getFolder(opts.folderId)
|
||||
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
|
||||
const bin = getYtDlpPath() // 없으면 즉시 던짐
|
||||
const videoId = newVideoId()
|
||||
const dir = videoDir(safeFolder, videoId)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const meta: VideoMeta = {
|
||||
id: videoId,
|
||||
const video = await createVideo({
|
||||
id: opts.videoId,
|
||||
folderId: opts.folderId,
|
||||
title: opts.title?.trim() || '제목 없음',
|
||||
originalFile: 'original.%(ext)s', // 다운로드 완료 후 실제 확장자로 갱신
|
||||
editedFile: null,
|
||||
durationSec: null,
|
||||
sourceType: 'youtube',
|
||||
sourceUrl: opts.url,
|
||||
trim: null,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
await saveVideoMeta(safeFolder, meta)
|
||||
sourceUrl: opts.url
|
||||
})
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const job: DownloadJob = {
|
||||
id: newVideoId(),
|
||||
folder: safeFolder,
|
||||
videoId,
|
||||
id: 'job-' + Math.random().toString(36).slice(2, 14),
|
||||
folderId: opts.folderId,
|
||||
videoId: video.id,
|
||||
url: opts.url,
|
||||
status: 'queued',
|
||||
progress: 0,
|
||||
@@ -180,7 +173,7 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
||||
job.message = '다운로드 시작'
|
||||
await persistJob(job)
|
||||
|
||||
const dir = videoDir(job.folder, job.videoId)
|
||||
const dir = videoDiskDir(job.videoId)
|
||||
// yt-dlp 가 다운로드 후 결정한 실제 파일명을 알려주도록 --print after_move:filepath
|
||||
// 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱.
|
||||
const args = [
|
||||
@@ -288,10 +281,9 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
||||
console.error('[youtube] 60fps 후처리 실패:', err)
|
||||
}
|
||||
|
||||
const meta = await loadVideoMeta(job.folder, job.videoId)
|
||||
const meta = getVideo(job.videoId)
|
||||
if (meta) {
|
||||
meta.originalFile = finalName
|
||||
await saveVideoMeta(job.folder, meta)
|
||||
updateVideo(job.videoId, { originalFile: finalName })
|
||||
}
|
||||
job.progress = 100
|
||||
job.status = 'done'
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= folder %> · 비디오 사이트</title>
|
||||
<title><%= folder.name %> · 비디오 사이트</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
@@ -15,41 +15,51 @@
|
||||
<a class="secondaryButton" href="/op">관리자</a>
|
||||
</header>
|
||||
|
||||
<%
|
||||
// 현재 폴더까지의 경로 prefix (영상/하위폴더 URL 조립용)
|
||||
var folderPathEnc = breadcrumb.map(function (s) { return encodeURIComponent(s) }).join('/')
|
||||
var parentHref = isSubFolder ? '/folder/' + encodeURIComponent(breadcrumb[0]) : '/'
|
||||
var parentLabel = isSubFolder ? '← ' + breadcrumb[0] : '← 폴더 목록'
|
||||
%>
|
||||
|
||||
<main class="pageWrap">
|
||||
<section class="hero">
|
||||
<a class="muted" href="/">← 폴더 목록</a>
|
||||
<h1>📁 <%= folder %></h1>
|
||||
<a class="muted" href="<%= parentHref %>"><%= parentLabel %></a>
|
||||
<h1>📁 <%= breadcrumb.join(' / ') %></h1>
|
||||
</section>
|
||||
|
||||
<% if (subFolders && subFolders.length > 0) { %>
|
||||
<section class="folderGrid">
|
||||
<% subFolders.forEach(function (sf) { %>
|
||||
<a class="folderCard" href="/folder/<%= folderPathEnc %>/<%= encodeURIComponent(sf.name) %>">
|
||||
<span class="folderIcon">📁</span>
|
||||
<span class="folderName"><%= sf.name %></span>
|
||||
</a>
|
||||
<% }) %>
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
<section class="videoGrid">
|
||||
<% if (videos.length === 0) { %>
|
||||
<% if (videos.length === 0 && (!subFolders || subFolders.length === 0)) { %>
|
||||
<p class="muted">이 폴더에 영상이 없습니다.</p>
|
||||
<% } %>
|
||||
<% videos.forEach(function (v) { %>
|
||||
<button type="button" class="videoCard" data-video-id="<%= v.id %>">
|
||||
<%
|
||||
var shareUrl = '/video/' + folderPathEnc + '/' + encodeURIComponent(v.id)
|
||||
%>
|
||||
<a class="videoCard" href="<%= shareUrl %>"
|
||||
data-video-id="<%= v.id %>" data-share-url="<%= shareUrl %>">
|
||||
<div class="videoThumb">▶</div>
|
||||
<div class="videoTitle"><%= v.title %></div>
|
||||
</button>
|
||||
</a>
|
||||
<% }) %>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="playerOverlay" id="playerOverlay" hidden>
|
||||
<div class="playerModal" role="dialog" aria-modal="true">
|
||||
<button type="button" class="playerClose" id="playerClose" aria-label="닫기">✕</button>
|
||||
<div class="playerTitle" id="playerTitle"></div>
|
||||
<video id="playerVideo" controls preload="metadata"></video>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ctxMenu" id="ctxMenu" hidden>
|
||||
<button type="button" data-action="copyUrl">영상 주소 복사</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__SITE__ = { folder: <%- jsonForScript(folder) %> }
|
||||
</script>
|
||||
<script src="/static/player.js"></script>
|
||||
<script src="/static/public-folder.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -25,10 +25,10 @@
|
||||
<% if (folders.length === 0) { %>
|
||||
<p class="muted">아직 폴더가 없습니다. 관리자 페이지에서 폴더를 추가해 주세요.</p>
|
||||
<% } %>
|
||||
<% folders.forEach(function (name) { %>
|
||||
<a class="folderCard" href="/folder/<%= encodeURIComponent(name) %>">
|
||||
<% folders.forEach(function (f) { %>
|
||||
<a class="folderCard" href="/folder/<%= encodeURIComponent(f.name) %>">
|
||||
<span class="folderIcon">📁</span>
|
||||
<span class="folderName"><%= name %></span>
|
||||
<span class="folderName"><%= f.name %></span>
|
||||
</a>
|
||||
<% }) %>
|
||||
</section>
|
||||
|
||||
@@ -21,11 +21,11 @@
|
||||
<% if (folders.length === 0) { %>
|
||||
<p class="muted">아직 폴더가 없습니다. 우측 상단에서 폴더를 추가해 주세요.</p>
|
||||
<% } %>
|
||||
<% folders.forEach(function (name) { %>
|
||||
<div class="folderCard adminFolder" data-name="<%= name %>">
|
||||
<a class="folderCardLink" href="/op/folder/<%= encodeURIComponent(name) %>">
|
||||
<% folders.forEach(function (f) { %>
|
||||
<div class="folderCard adminFolder" data-folder-id="<%= f.id %>" data-name="<%= f.name %>">
|
||||
<a class="folderCardLink" href="/op/folder/<%= encodeURIComponent(f.name) %>">
|
||||
<span class="folderIcon">📁</span>
|
||||
<span class="folderName"><%= name %></span>
|
||||
<span class="folderName"><%= f.name %></span>
|
||||
</a>
|
||||
</div>
|
||||
<% }) %>
|
||||
|
||||
@@ -3,13 +3,18 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>영상 편집 · <%= folder %></title>
|
||||
<title>영상 편집 · <%= breadcrumb.join(' / ') %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
<%
|
||||
var folderPathEnc = breadcrumb.map(function (s) { return encodeURIComponent(s) }).join('/')
|
||||
var folderHref = '/op/folder/' + folderPathEnc
|
||||
var folderLabel = breadcrumb.join(' / ')
|
||||
%>
|
||||
<header class="editorNav">
|
||||
<div class="editorNavLeft">
|
||||
<a class="muted" href="/op/folder/<%= encodeURIComponent(folder) %>">← <%= folder %></a>
|
||||
<a class="muted" href="<%= folderHref %>">← <%= folderLabel %></a>
|
||||
<input type="text" id="titleInput" class="titleInput" placeholder="영상 제목" value="<%= video ? video.title : '' %>" />
|
||||
</div>
|
||||
<div class="editorNavRight">
|
||||
@@ -35,7 +40,7 @@
|
||||
|
||||
<div id="videoPanel" class="videoPanel" <% if (!video) { %>hidden<% } %>>
|
||||
<video id="editVideo" controls preload="metadata"
|
||||
<% if (video) { %>src="/api/video/<%= video.id %>/file?edited=0"<% } %>></video>
|
||||
<% if (video) { %>src="/api/video/<%= encodeURIComponent(video.id) %>/file?edited=0"<% } %>></video>
|
||||
|
||||
<div class="trimTimeline">
|
||||
<div class="trimTimelineHeader">
|
||||
@@ -66,7 +71,8 @@
|
||||
|
||||
<script>
|
||||
window.__EDITOR__ = {
|
||||
folder: <%- jsonForScript(folder) %>,
|
||||
folderPath: <%- jsonForScript(breadcrumb) %>,
|
||||
folderId: <%- jsonForScript(folder.id) %>,
|
||||
video: <%- jsonForScript(video) %>
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,32 +3,65 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>관리자 · <%= folder %></title>
|
||||
<title>관리자 · <%= breadcrumb.join(' / ') %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
<%- include('../partials/navbar', { userId }) %>
|
||||
|
||||
<%
|
||||
var folderPathEnc = breadcrumb.map(function (s) { return encodeURIComponent(s) }).join('/')
|
||||
var parentHref = isSubFolder ? '/op/folder/' + encodeURIComponent(breadcrumb[0]) : '/op/dashboard'
|
||||
var parentLabel = isSubFolder ? '← ' + breadcrumb[0] : '← 폴더 목록'
|
||||
var editorHref = '/op/folder/' + folderPathEnc + '/video/editor'
|
||||
var playlistHref = '/op/folder/' + folderPathEnc + '/playlist/new'
|
||||
%>
|
||||
|
||||
<main class="pageWrap">
|
||||
<section class="dashboardHeader">
|
||||
<div>
|
||||
<a class="muted" href="/op/dashboard">← 폴더 목록</a>
|
||||
<h1>📁 <%= folder %></h1>
|
||||
<a class="muted" href="<%= parentHref %>"><%= parentLabel %></a>
|
||||
<h1>📁 <%= breadcrumb.join(' / ') %></h1>
|
||||
</div>
|
||||
<div class="dashboardActions">
|
||||
<a class="primaryButton" href="/op/folder/<%= encodeURIComponent(folder) %>/video/editor">영상 추가</a>
|
||||
<% if (!isSubFolder) { %>
|
||||
<button type="button" class="secondaryButton" id="addSubFolderBtn">하위 폴더 추가</button>
|
||||
<% } %>
|
||||
<a class="primaryButton" href="<%= editorHref %>">영상 추가</a>
|
||||
<% if (isSubFolder) { %>
|
||||
<a class="primaryButton" href="<%= playlistHref %>">플레이리스트 추가</a>
|
||||
<% } %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<% if (!isSubFolder && subFolders && subFolders.length > 0) { %>
|
||||
<section class="folderGrid" id="subFolderGrid">
|
||||
<% subFolders.forEach(function (sf) { %>
|
||||
<div class="folderCard adminFolder" data-folder-id="<%= sf.id %>" data-name="<%= sf.name %>">
|
||||
<a class="folderCardLink" href="/op/folder/<%= folderPathEnc %>/<%= encodeURIComponent(sf.name) %>">
|
||||
<span class="folderIcon">📁</span>
|
||||
<span class="folderName"><%= sf.name %></span>
|
||||
</a>
|
||||
</div>
|
||||
<% }) %>
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
<section class="videoGrid" id="videoGrid">
|
||||
<% if (videos.length === 0) { %>
|
||||
<% if (videos.length === 0 && (isSubFolder || !subFolders || subFolders.length === 0)) { %>
|
||||
<p class="muted">이 폴더에 영상이 없습니다. 우측 상단에서 영상을 추가하세요.</p>
|
||||
<% } %>
|
||||
<% videos.forEach(function (v) { %>
|
||||
<div class="videoCard adminVideo" data-id="<%= v.id %>" data-video-id="<%= v.id %>" data-title="<%= v.title %>">
|
||||
<% videos.forEach(function (v) {
|
||||
var shareUrl = '/video/' + folderPathEnc + '/' + encodeURIComponent(v.id)
|
||||
%>
|
||||
<div class="videoCard adminVideo"
|
||||
data-id="<%= v.id %>"
|
||||
data-video-id="<%= v.id %>"
|
||||
data-title="<%= v.title %>"
|
||||
data-share-url="<%= shareUrl %>">
|
||||
<div class="videoThumb">▶</div>
|
||||
<div class="videoTitle"><%= v.title %></div>
|
||||
<% if (v.sourceType === 'youtube' && !v.originalFile.includes('original.') === false) { %>
|
||||
<% if (v.sourceType === 'youtube') { %>
|
||||
<div class="muted">YouTube</div>
|
||||
<% } %>
|
||||
</div>
|
||||
@@ -39,10 +72,16 @@
|
||||
<div class="ctxMenu" id="ctxMenu" hidden>
|
||||
<button type="button" data-action="edit">수정</button>
|
||||
<button type="button" data-action="rename">이름 변경</button>
|
||||
<button type="button" data-action="changeId">ID 변경</button>
|
||||
<button type="button" data-action="copyUrl">영상 주소 복사</button>
|
||||
<button type="button" data-action="delete" class="dangerLink">삭제</button>
|
||||
</div>
|
||||
|
||||
<div class="ctxMenu" id="folderCtxMenu" hidden>
|
||||
<button type="button" data-action="rename">이름 변경</button>
|
||||
<button type="button" data-action="delete" class="dangerLink">삭제</button>
|
||||
</div>
|
||||
|
||||
<div class="playerOverlay" id="playerOverlay" hidden>
|
||||
<div class="playerModal" role="dialog" aria-modal="true">
|
||||
<button type="button" class="playerClose" id="playerClose" aria-label="닫기">✕</button>
|
||||
@@ -51,9 +90,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modalOverlay" id="addSubFolderModal" hidden>
|
||||
<div class="modalCard">
|
||||
<h2>하위 폴더 추가</h2>
|
||||
<label>
|
||||
<span>폴더 이름</span>
|
||||
<input type="text" id="addSubFolderInput" autofocus />
|
||||
</label>
|
||||
<div class="modalActions">
|
||||
<button type="button" class="secondaryButton" id="addSubFolderCancel">취소</button>
|
||||
<button type="button" class="primaryButton" id="addSubFolderConfirm">생성</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__OP__ = { folder: <%- jsonForScript(folder) %> }
|
||||
window.__SITE__ = { folder: <%- jsonForScript(folder) %> }
|
||||
window.__OP__ = {
|
||||
folderId: <%- jsonForScript(folder.id) %>,
|
||||
folderPath: <%- jsonForScript(breadcrumb) %>,
|
||||
isSubFolder: <%- jsonForScript(isSubFolder) %>
|
||||
}
|
||||
</script>
|
||||
<script src="/static/folder.js"></script>
|
||||
<script src="/static/player.js"></script>
|
||||
|
||||
@@ -7,22 +7,27 @@
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
<%
|
||||
var folderPathEnc = breadcrumb.map(function (s) { return encodeURIComponent(s) }).join('/')
|
||||
var folderHref = '/folder/' + folderPathEnc
|
||||
var folderLabel = breadcrumb.join(' / ')
|
||||
%>
|
||||
<header class="publicNav">
|
||||
<a class="navBrand" href="/">
|
||||
<span class="navLogo">🎬</span>
|
||||
<span class="navTitle">비디오 사이트</span>
|
||||
</a>
|
||||
<a class="secondaryButton" href="/folder/<%= encodeURIComponent(folder) %>">폴더로</a>
|
||||
<a class="secondaryButton" href="<%= folderHref %>">폴더로</a>
|
||||
</header>
|
||||
|
||||
<main class="pageWrap">
|
||||
<section class="hero">
|
||||
<a class="muted" href="/folder/<%= encodeURIComponent(folder) %>">← <%= folder %></a>
|
||||
<a class="muted" href="<%= folderHref %>">← <%= folderLabel %></a>
|
||||
<h1><%= video.title %></h1>
|
||||
</section>
|
||||
|
||||
<div class="standalonePlayer">
|
||||
<video controls autoplay preload="metadata" src="/api/video/<%= video.id %>/file"></video>
|
||||
<video controls autoplay preload="metadata" src="/api/video/<%= encodeURIComponent(video.id) %>/file"></video>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user