feat: implement video site per README spec
- Express + EJS + express-session stack (auth/navbar ported from minecraft_launcher)
- Public: main folder list, folder video grid, internal popup player (/player/:videoId)
- Admin (/op): login, folder CRUD with right-click context menu + add-folder modal
- Admin folder: video grid with right-click edit/rename/delete, "영상 추가" -> editor
- Video editor: drag-drop upload, file picker, YouTube URL probe (ETA + 5분 경고),
background yt-dlp download with progress polling, navbar title edit, trim controls,
save runs ffmpeg trim (original preserved)
- Filesystem storage under data/folders/<name>/<videoId>/{meta.json, original.<ext>, edited.<ext>}
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/folders/*
|
||||||
|
!data/folders/.gitkeep
|
||||||
|
data/jobs/*
|
||||||
|
!data/jobs/.gitkeep
|
||||||
|
data/tmp/*
|
||||||
|
!data/tmp/.gitkeep
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
30
README.md
30
README.md
@@ -1,6 +1,36 @@
|
|||||||
# 비디오 사이트
|
# 비디오 사이트
|
||||||
## 영상 업로드 및 저장을 위한 사이트
|
## 영상 업로드 및 저장을 위한 사이트
|
||||||
|
|
||||||
|
## 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
npm start # 기본 http://127.0.0.1:3000 (PORT=3000, HOST=127.0.0.1)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 외부 노출이 필요하면 `HOST=0.0.0.0 npm start`
|
||||||
|
- 관리자 비밀번호는 `account.json` 의 `password` 값 (초기값 `admin`, 운영 시 반드시 변경)
|
||||||
|
- 세션 비밀은 `SESSION_SECRET` 환경변수로 덮어쓰기 권장
|
||||||
|
|
||||||
|
## 외부 의존
|
||||||
|
|
||||||
|
- `yt-dlp` — YouTube 영상 가져오기 (`PATH` 또는 `./bin/yt-dlp` 에 설치)
|
||||||
|
- `ffmpeg` — 영상 트림 저장 (`PATH` 에 설치). 없으면 trim 설정만 저장됩니다.
|
||||||
|
|
||||||
|
## 데이터 위치
|
||||||
|
|
||||||
|
```
|
||||||
|
data/
|
||||||
|
folders/<폴더이름>/<videoId>/
|
||||||
|
meta.json # 영상 메타 (제목, trim, 원본/편집본 파일명)
|
||||||
|
original.<ext> # 원본 (항상 보존)
|
||||||
|
edited.<ext> # 편집본 (저장 시 생성)
|
||||||
|
jobs/<jobId>.json # YouTube 다운로드 작업 상태
|
||||||
|
```
|
||||||
|
|
||||||
|
## 스펙
|
||||||
|
|
||||||
### 메인 페이지 (/)
|
### 메인 페이지 (/)
|
||||||
- 동영상이 저장되어있는 폴더를 나열합니다.
|
- 동영상이 저장되어있는 폴더를 나열합니다.
|
||||||
- 폴더를 선택해 안에 영상을 확인할수있습니다.
|
- 폴더를 선택해 안에 영상을 확인할수있습니다.
|
||||||
|
|||||||
6
account.json
Normal file
6
account.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "admin",
|
||||||
|
"password": "admin"
|
||||||
|
}
|
||||||
|
]
|
||||||
0
data/folders/.gitkeep
Normal file
0
data/folders/.gitkeep
Normal file
0
data/jobs/.gitkeep
Normal file
0
data/jobs/.gitkeep
Normal file
0
data/tmp/.gitkeep
Normal file
0
data/tmp/.gitkeep
Normal file
1193
package-lock.json
generated
Normal file
1193
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "make-video-site",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "영상 업로드 및 저장을 위한 사이트",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/app.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/app.js",
|
||||||
|
"dev": "tsc -p tsconfig.json && node dist/app.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ejs": "^3.1.10",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"express-session": "^1.18.0",
|
||||||
|
"multer": "^1.4.5-lts.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/ejs": "^3.1.5",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/express-session": "^1.18.0",
|
||||||
|
"@types/multer": "^1.4.11",
|
||||||
|
"@types/node": "^22.5.0",
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
80
public/dashboard.js
Normal file
80
public/dashboard.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
(function () {
|
||||||
|
var ctxMenu = document.getElementById('ctxMenu')
|
||||||
|
var targetName = null
|
||||||
|
|
||||||
|
function showCtx(x, y) {
|
||||||
|
ctxMenu.style.left = x + 'px'
|
||||||
|
ctxMenu.style.top = y + 'px'
|
||||||
|
ctxMenu.hidden = false
|
||||||
|
}
|
||||||
|
function hideCtx() { ctxMenu.hidden = true; targetName = null }
|
||||||
|
|
||||||
|
document.querySelectorAll('.adminFolder').forEach(function (card) {
|
||||||
|
card.addEventListener('contextmenu', function (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
targetName = card.getAttribute('data-name')
|
||||||
|
showCtx(e.clientX, e.clientY)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (!ctxMenu.contains(e.target)) hideCtx()
|
||||||
|
})
|
||||||
|
|
||||||
|
ctxMenu.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('button')
|
||||||
|
if (!btn) return
|
||||||
|
var action = btn.getAttribute('data-action')
|
||||||
|
if (action === 'rename') {
|
||||||
|
var newName = window.prompt('새 폴더 이름', targetName)
|
||||||
|
if (newName && newName !== targetName) {
|
||||||
|
fetch('/op/folders/rename', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ oldName: targetName, 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('"' + targetName + '" 폴더를 정말 삭제할까요? 안의 영상이 모두 사라집니다.')) {
|
||||||
|
fetch('/op/folders/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: targetName })
|
||||||
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
|
if (j.ok) location.reload()
|
||||||
|
else alert(j.message || '삭제 실패')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hideCtx()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 폴더 추가 모달
|
||||||
|
var addBtn = document.getElementById('addFolderBtn')
|
||||||
|
var modal = document.getElementById('addFolderModal')
|
||||||
|
var input = document.getElementById('addFolderInput')
|
||||||
|
var cancelBtn = document.getElementById('addFolderCancel')
|
||||||
|
var confirmBtn = document.getElementById('addFolderConfirm')
|
||||||
|
|
||||||
|
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 || '폴더 생성 실패')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})()
|
||||||
197
public/editor.js
Normal file
197
public/editor.js
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
(function () {
|
||||||
|
var ctx = window.__EDITOR__ || { folder: '', video: null }
|
||||||
|
var folder = ctx.folder
|
||||||
|
var video = ctx.video
|
||||||
|
|
||||||
|
var dropZone = document.getElementById('dropZone')
|
||||||
|
var fileInput = document.getElementById('fileInput')
|
||||||
|
var ytUrl = document.getElementById('ytUrl')
|
||||||
|
var ytProbeBtn = document.getElementById('ytProbeBtn')
|
||||||
|
var ytStartBtn = document.getElementById('ytStartBtn')
|
||||||
|
var probeInfo = document.getElementById('probeInfo')
|
||||||
|
var dlProgress = document.getElementById('downloadProgress')
|
||||||
|
var uploadStatus = document.getElementById('uploadStatus')
|
||||||
|
|
||||||
|
var videoPanel = document.getElementById('videoPanel')
|
||||||
|
var editVideo = document.getElementById('editVideo')
|
||||||
|
var titleInput = document.getElementById('titleInput')
|
||||||
|
var startSec = document.getElementById('startSec')
|
||||||
|
var endSec = document.getElementById('endSec')
|
||||||
|
var saveBtn = document.getElementById('saveBtn')
|
||||||
|
|
||||||
|
// 드래그&드롭
|
||||||
|
;['dragenter', 'dragover'].forEach(function (evt) {
|
||||||
|
dropZone.addEventListener(evt, function (e) {
|
||||||
|
e.preventDefault(); e.stopPropagation()
|
||||||
|
dropZone.classList.add('dragOver')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
;['dragleave', 'drop'].forEach(function (evt) {
|
||||||
|
dropZone.addEventListener(evt, function (e) {
|
||||||
|
e.preventDefault(); e.stopPropagation()
|
||||||
|
dropZone.classList.remove('dragOver')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
dropZone.addEventListener('drop', function (e) {
|
||||||
|
var files = e.dataTransfer && e.dataTransfer.files
|
||||||
|
if (files && files.length > 0) uploadFile(files[0])
|
||||||
|
})
|
||||||
|
fileInput.addEventListener('change', function () {
|
||||||
|
if (fileInput.files && fileInput.files[0]) uploadFile(fileInput.files[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
function uploadFile(file) {
|
||||||
|
var form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
form.append('title', titleInput.value || file.name)
|
||||||
|
uploadStatus.textContent = '업로드 중...'
|
||||||
|
var xhr = new XMLHttpRequest()
|
||||||
|
xhr.open('POST', '/op/folder/' + encodeURIComponent(folder) + '/video/upload')
|
||||||
|
xhr.upload.addEventListener('progress', function (e) {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
var pct = Math.round((e.loaded / e.total) * 100)
|
||||||
|
uploadStatus.textContent = '업로드 ' + pct + '%'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
xhr.onload = function () {
|
||||||
|
try {
|
||||||
|
var res = JSON.parse(xhr.responseText)
|
||||||
|
if (res.ok) {
|
||||||
|
location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(res.videoId)
|
||||||
|
} else {
|
||||||
|
uploadStatus.textContent = '업로드 실패: ' + (res.message || '')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
uploadStatus.textContent = '업로드 실패'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xhr.onerror = function () { uploadStatus.textContent = '업로드 실패 (네트워크)' }
|
||||||
|
xhr.send(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
// YouTube probe
|
||||||
|
ytProbeBtn.addEventListener('click', function () {
|
||||||
|
var url = ytUrl.value.trim()
|
||||||
|
if (!url) return
|
||||||
|
probeInfo.textContent = '확인 중...'
|
||||||
|
ytStartBtn.disabled = true
|
||||||
|
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/probe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: url })
|
||||||
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
|
if (!j.ok) {
|
||||||
|
probeInfo.textContent = j.message || '확인 실패'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p = j.probe
|
||||||
|
var parts = [
|
||||||
|
'제목: ' + p.title,
|
||||||
|
'길이: ' + formatDuration(p.durationSec)
|
||||||
|
]
|
||||||
|
if (p.filesizeApprox) parts.push('대략 ' + formatSize(p.filesizeApprox))
|
||||||
|
if (p.etaSec) parts.push('예상 다운로드: ' + formatDuration(p.etaSec))
|
||||||
|
probeInfo.textContent = parts.join(' · ')
|
||||||
|
if (p.warnOver5min) {
|
||||||
|
if (!window.confirm('가져오는 데 5분 이상 걸릴 수 있습니다. 진행할까요?\n(다른 페이지에서 작업해도 백그라운드로 계속 진행됩니다.)')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!titleInput.value) titleInput.value = p.title
|
||||||
|
ytStartBtn.disabled = false
|
||||||
|
}).catch(function (e) {
|
||||||
|
probeInfo.textContent = '확인 실패: ' + e.message
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ytStartBtn.addEventListener('click', function () {
|
||||||
|
var url = ytUrl.value.trim()
|
||||||
|
if (!url) return
|
||||||
|
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: url, title: titleInput.value })
|
||||||
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
|
if (!j.ok) {
|
||||||
|
probeInfo.textContent = j.message || '시작 실패'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dlProgress.hidden = false
|
||||||
|
probeInfo.textContent = '백그라운드 다운로드 시작...'
|
||||||
|
pollJob(j.jobId, j.videoId)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function pollJob(jobId, videoId) {
|
||||||
|
fetch('/op/job/' + encodeURIComponent(jobId)).then(function (r) { return r.json() }).then(function (j) {
|
||||||
|
if (!j.ok) {
|
||||||
|
probeInfo.textContent = j.message || '작업을 찾을 수 없음'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var job = j.job
|
||||||
|
dlProgress.value = job.progress
|
||||||
|
probeInfo.textContent = job.message
|
||||||
|
if (job.status === 'done') {
|
||||||
|
location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(videoId)
|
||||||
|
} else if (job.status === 'error') {
|
||||||
|
probeInfo.textContent = '실패: ' + (job.error || '')
|
||||||
|
} else {
|
||||||
|
setTimeout(function () { pollJob(jobId, videoId) }, 1500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 트림 컨트롤 - "현재 시점" 버튼
|
||||||
|
document.querySelectorAll('[data-set-current]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
if (!editVideo) return
|
||||||
|
var which = btn.getAttribute('data-set-current')
|
||||||
|
var t = editVideo.currentTime.toFixed(2)
|
||||||
|
if (which === 'start') startSec.value = t
|
||||||
|
else endSec.value = t
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
saveBtn.addEventListener('click', function () {
|
||||||
|
if (!video) { alert('먼저 영상을 추가하세요.'); return }
|
||||||
|
var payload = {
|
||||||
|
id: video.id,
|
||||||
|
title: titleInput.value,
|
||||||
|
startSec: Number(startSec.value || 0),
|
||||||
|
endSec: endSec.value === '' ? null : Number(endSec.value)
|
||||||
|
}
|
||||||
|
saveBtn.disabled = true
|
||||||
|
saveBtn.textContent = '저장 중...'
|
||||||
|
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
|
saveBtn.disabled = false
|
||||||
|
saveBtn.textContent = '저장'
|
||||||
|
if (j.ok) {
|
||||||
|
alert(j.note || '저장 완료')
|
||||||
|
location.href = '/op/folder/' + encodeURIComponent(folder)
|
||||||
|
} else {
|
||||||
|
alert(j.message || '저장 실패')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDuration(sec) {
|
||||||
|
if (!sec || sec <= 0) return '0초'
|
||||||
|
var s = Math.round(sec)
|
||||||
|
var h = Math.floor(s / 3600); s = s % 3600
|
||||||
|
var m = Math.floor(s / 60); s = s % 60
|
||||||
|
if (h > 0) return h + '시간 ' + m + '분 ' + s + '초'
|
||||||
|
if (m > 0) return m + '분 ' + s + '초'
|
||||||
|
return s + '초'
|
||||||
|
}
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB'
|
||||||
|
return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB'
|
||||||
|
}
|
||||||
|
})()
|
||||||
59
public/folder.js
Normal file
59
public/folder.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
(function () {
|
||||||
|
var folder = (window.__OP__ || {}).folder
|
||||||
|
var ctxMenu = document.getElementById('ctxMenu')
|
||||||
|
var targetId = null
|
||||||
|
var targetTitle = null
|
||||||
|
|
||||||
|
function showCtx(x, y) {
|
||||||
|
ctxMenu.style.left = x + 'px'
|
||||||
|
ctxMenu.style.top = y + 'px'
|
||||||
|
ctxMenu.hidden = false
|
||||||
|
}
|
||||||
|
function hideCtx() { ctxMenu.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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (!ctxMenu.contains(e.target)) hideCtx()
|
||||||
|
})
|
||||||
|
|
||||||
|
ctxMenu.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('button')
|
||||||
|
if (!btn) return
|
||||||
|
var action = btn.getAttribute('data-action')
|
||||||
|
if (action === 'edit') {
|
||||||
|
location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(targetId)
|
||||||
|
} else if (action === 'rename') {
|
||||||
|
var t = window.prompt('새 영상 제목', targetTitle)
|
||||||
|
if (t && t !== targetTitle) {
|
||||||
|
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/rename', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: targetId, 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', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: targetId })
|
||||||
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
|
if (j.ok) location.reload()
|
||||||
|
else alert(j.message || '삭제 실패')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hideCtx()
|
||||||
|
})
|
||||||
|
})()
|
||||||
51
public/player.js
Normal file
51
public/player.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
(function () {
|
||||||
|
var overlay = document.getElementById('playerOverlay')
|
||||||
|
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))
|
||||||
|
titleEl.textContent = title || ''
|
||||||
|
video.src = '/api/video/' + encodeURIComponent(videoId) + '/file'
|
||||||
|
overlay.hidden = false
|
||||||
|
video.play().catch(function () { /* 자동재생 막힘 무시 */ })
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePlayer() {
|
||||||
|
overlay.hidden = true
|
||||||
|
video.pause()
|
||||||
|
video.removeAttribute('src')
|
||||||
|
video.load()
|
||||||
|
// 폴더 페이지로 되돌리기
|
||||||
|
if (history.state && history.state.player) {
|
||||||
|
history.back()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.videoCard').forEach(function (card) {
|
||||||
|
card.addEventListener('click', function () {
|
||||||
|
var id = card.getAttribute('data-video-id')
|
||||||
|
var title = card.querySelector('.videoTitle')
|
||||||
|
openPlayer(id, title ? title.textContent : '')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (closeBtn) closeBtn.addEventListener('click', closePlayer)
|
||||||
|
if (overlay) overlay.addEventListener('click', function (e) {
|
||||||
|
if (e.target === overlay) closePlayer()
|
||||||
|
})
|
||||||
|
window.addEventListener('popstate', function () {
|
||||||
|
if (!overlay.hidden) {
|
||||||
|
overlay.hidden = true
|
||||||
|
video.pause()
|
||||||
|
video.removeAttribute('src')
|
||||||
|
video.load()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && !overlay.hidden) closePlayer()
|
||||||
|
})
|
||||||
|
})()
|
||||||
234
public/styles.css
Normal file
234
public/styles.css
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #0d1117;
|
||||||
|
--bg-alt: #161b22;
|
||||||
|
--bg-card: #1f242c;
|
||||||
|
--border: #30363d;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--text-muted: #8b949e;
|
||||||
|
--accent: #2f81f7;
|
||||||
|
--accent-hover: #1f6feb;
|
||||||
|
--danger: #f85149;
|
||||||
|
--success: #3fb950;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body.siteBody {
|
||||||
|
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
body.siteBody.centerLayout { display: flex; align-items: center; justify-content: center; }
|
||||||
|
|
||||||
|
.pageWrap { max-width: 1200px; margin: 0 auto; padding: 32px 24px 80px; }
|
||||||
|
|
||||||
|
/* nav */
|
||||||
|
.topNav, .publicNav {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 16px 32px; background: var(--bg-alt);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.navBrand {
|
||||||
|
display: inline-flex; align-items: center; gap: 10px;
|
||||||
|
text-decoration: none; color: inherit; font-weight: 600;
|
||||||
|
}
|
||||||
|
.navLogo { font-size: 22px; }
|
||||||
|
.navTitle { font-size: 16px; }
|
||||||
|
.navUser { position: relative; }
|
||||||
|
.navUserButton {
|
||||||
|
background: transparent; border: 1px solid var(--border); color: var(--text);
|
||||||
|
padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 14px;
|
||||||
|
}
|
||||||
|
.navUserButton:hover { background: var(--bg-card); }
|
||||||
|
.navUserMenu {
|
||||||
|
position: absolute; right: 0; top: calc(100% + 6px);
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 8px; min-width: 160px;
|
||||||
|
box-shadow: 0 12px 24px rgba(0,0,0,0.4); z-index: 50;
|
||||||
|
}
|
||||||
|
.navUserMenu form { margin: 0; }
|
||||||
|
.dangerLink {
|
||||||
|
background: transparent; border: none; color: var(--danger); cursor: pointer;
|
||||||
|
padding: 6px 8px; font-size: 14px; width: 100%; text-align: left;
|
||||||
|
}
|
||||||
|
.dangerLink:hover { background: rgba(248,81,73,0.1); border-radius: 6px; }
|
||||||
|
|
||||||
|
/* generic */
|
||||||
|
.hero h1 { margin: 8px 0 8px; font-size: 30px; }
|
||||||
|
.hero p { color: var(--text-muted); margin: 0 0 24px; }
|
||||||
|
.muted { color: var(--text-muted); font-size: 13px; text-decoration: none; }
|
||||||
|
|
||||||
|
.primaryButton, .secondaryButton, .dangerButton {
|
||||||
|
border-radius: 8px; padding: 9px 16px; font-size: 14px; cursor: pointer;
|
||||||
|
border: 1px solid transparent; text-decoration: none; display: inline-block;
|
||||||
|
}
|
||||||
|
.primaryButton { background: var(--accent); color: white; }
|
||||||
|
.primaryButton:hover { background: var(--accent-hover); }
|
||||||
|
.secondaryButton { background: var(--bg-card); color: var(--text); border-color: var(--border); }
|
||||||
|
.secondaryButton:hover { background: var(--bg-alt); }
|
||||||
|
.dangerButton { background: var(--danger); color: white; }
|
||||||
|
|
||||||
|
.dashboardHeader {
|
||||||
|
display: flex; justify-content: space-between; align-items: flex-end;
|
||||||
|
margin-bottom: 24px; gap: 16px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.dashboardActions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* folder grid */
|
||||||
|
.folderGrid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.folderCard {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 18px; text-decoration: none; color: var(--text);
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||||
|
cursor: pointer; transition: transform .08s ease;
|
||||||
|
}
|
||||||
|
.folderCard:hover { transform: translateY(-2px); border-color: var(--accent); }
|
||||||
|
.folderCardLink {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||||
|
text-decoration: none; color: inherit;
|
||||||
|
}
|
||||||
|
.folderIcon { font-size: 40px; }
|
||||||
|
.folderName { font-size: 15px; word-break: break-all; text-align: center; }
|
||||||
|
|
||||||
|
/* video grid */
|
||||||
|
.videoGrid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.videoCard {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; overflow: hidden; cursor: pointer; color: var(--text);
|
||||||
|
display: flex; flex-direction: column; padding: 0; text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.videoCard:hover { border-color: var(--accent); }
|
||||||
|
.videoThumb {
|
||||||
|
aspect-ratio: 16/9; background: linear-gradient(135deg, #1f242c, #0d1117);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 36px; color: var(--accent);
|
||||||
|
}
|
||||||
|
.videoTitle { padding: 10px 12px; font-size: 14px; }
|
||||||
|
|
||||||
|
/* login */
|
||||||
|
.loginCard {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 14px; padding: 32px; min-width: 320px;
|
||||||
|
}
|
||||||
|
.loginForm { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.loginForm label { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.loginForm input {
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
color: var(--text); padding: 10px 12px; font-size: 14px;
|
||||||
|
}
|
||||||
|
.errorBanner {
|
||||||
|
background: rgba(248,81,73,0.15); border: 1px solid var(--danger);
|
||||||
|
color: #ffb1ab; padding: 10px 12px; border-radius: 8px; font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* context menu */
|
||||||
|
.ctxMenu {
|
||||||
|
position: fixed; z-index: 100; background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border); border-radius: 8px; padding: 6px;
|
||||||
|
box-shadow: 0 12px 24px rgba(0,0,0,0.4); min-width: 160px;
|
||||||
|
}
|
||||||
|
.ctxMenu button {
|
||||||
|
display: block; width: 100%; text-align: left; background: transparent;
|
||||||
|
border: none; color: var(--text); padding: 8px 10px; font-size: 13px;
|
||||||
|
border-radius: 6px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.ctxMenu button:hover { background: var(--bg-alt); }
|
||||||
|
|
||||||
|
/* modal */
|
||||||
|
.modalOverlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 200;
|
||||||
|
}
|
||||||
|
.modalCard {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 14px; padding: 24px; min-width: 320px;
|
||||||
|
}
|
||||||
|
.modalCard h2 { margin: 0 0 16px; font-size: 18px; }
|
||||||
|
.modalCard label { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.modalCard input[type="text"] {
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
color: var(--text); padding: 10px 12px; font-size: 14px;
|
||||||
|
}
|
||||||
|
.modalActions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||||
|
|
||||||
|
/* player modal */
|
||||||
|
.playerOverlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.85);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 300;
|
||||||
|
}
|
||||||
|
.playerModal {
|
||||||
|
position: relative; background: #000; border-radius: 12px;
|
||||||
|
width: min(960px, 92vw); max-height: 92vh; padding: 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.playerClose {
|
||||||
|
position: absolute; top: 8px; right: 12px; background: transparent;
|
||||||
|
border: none; color: white; font-size: 22px; cursor: pointer; z-index: 1;
|
||||||
|
}
|
||||||
|
.playerTitle { color: white; font-size: 16px; margin-right: 36px; }
|
||||||
|
.playerModal video { width: 100%; max-height: 80vh; background: #000; }
|
||||||
|
|
||||||
|
.standalonePlayer { background: #000; border-radius: 10px; padding: 12px; }
|
||||||
|
.standalonePlayer video { width: 100%; max-height: 80vh; }
|
||||||
|
|
||||||
|
/* editor */
|
||||||
|
.editorNav {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 12px 24px; background: var(--bg-alt);
|
||||||
|
border-bottom: 1px solid var(--border); gap: 12px;
|
||||||
|
}
|
||||||
|
.editorNavLeft { display: flex; align-items: center; gap: 16px; flex: 1; }
|
||||||
|
.editorNavRight { display: flex; gap: 8px; }
|
||||||
|
.titleInput {
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
color: var(--text); padding: 8px 12px; font-size: 15px; min-width: 280px;
|
||||||
|
flex: 1; max-width: 480px;
|
||||||
|
}
|
||||||
|
.editorMain { padding: 24px; }
|
||||||
|
.editorStage { max-width: 1200px; margin: 0 auto; }
|
||||||
|
.dropZone {
|
||||||
|
background: var(--bg-card); border: 2px dashed var(--border);
|
||||||
|
border-radius: 12px; padding: 40px; text-align: center;
|
||||||
|
display: flex; flex-direction: column; gap: 14px; align-items: center;
|
||||||
|
}
|
||||||
|
.dropZone.dragOver { border-color: var(--accent); background: rgba(47,129,247,0.08); }
|
||||||
|
.dropTitle { font-size: 18px; margin: 0; }
|
||||||
|
.ytRow {
|
||||||
|
display: flex; gap: 8px; width: 100%; max-width: 640px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ytRow input {
|
||||||
|
flex: 1; background: var(--bg); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; color: var(--text); padding: 10px 12px; font-size: 14px;
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
#downloadProgress { width: 100%; max-width: 640px; height: 8px; }
|
||||||
|
|
||||||
|
.videoPanel { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.videoPanel video {
|
||||||
|
width: 100%; max-height: 60vh; background: #000; border-radius: 10px;
|
||||||
|
}
|
||||||
|
.trimControls {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 16px;
|
||||||
|
display: flex; flex-wrap: wrap; gap: 18px;
|
||||||
|
}
|
||||||
|
.trimControls label {
|
||||||
|
display: flex; flex-direction: column; gap: 6px; font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.trimControls input {
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
color: var(--text); padding: 8px 10px; font-size: 14px; width: 180px;
|
||||||
|
}
|
||||||
|
.adminFolder { position: relative; }
|
||||||
|
.adminVideo { position: relative; }
|
||||||
70
src/app.ts
Normal file
70
src/app.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import session from 'express-session'
|
||||||
|
import path from 'node:path'
|
||||||
|
import fsp from 'node:fs/promises'
|
||||||
|
import { dataDir, foldersDir, jobsDir, tmpDir, publicDir, viewsDir } from './paths.js'
|
||||||
|
import { publicRouter } from './routes/public.js'
|
||||||
|
import { opRouter } from './routes/op.js'
|
||||||
|
|
||||||
|
async function ensureDirs(): Promise<void> {
|
||||||
|
for (const dir of [dataDir, foldersDir, jobsDir, tmpDir]) {
|
||||||
|
await fsp.mkdir(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
await ensureDirs()
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT ?? 3000)
|
||||||
|
const HOST = process.env.HOST ?? '127.0.0.1'
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
app.set('view engine', 'ejs')
|
||||||
|
app.set('views', viewsDir)
|
||||||
|
app.set('trust proxy', 1)
|
||||||
|
|
||||||
|
app.use(express.urlencoded({ extended: true }))
|
||||||
|
app.use(express.json({ limit: '4mb' }))
|
||||||
|
|
||||||
|
app.use(session({
|
||||||
|
secret: process.env.SESSION_SECRET ?? 'make-video-site-dev-secret',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 1000 * 60 * 60 * 8
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// account.json 직접 노출 차단
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (/^\/account\.json/i.test(req.path)) {
|
||||||
|
res.status(404).send('Not Found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use('/static', express.static(publicDir))
|
||||||
|
|
||||||
|
app.use('/', publicRouter)
|
||||||
|
app.use('/', opRouter)
|
||||||
|
|
||||||
|
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
|
console.error(err)
|
||||||
|
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||||
|
if (res.headersSent) return
|
||||||
|
res.status(500).send(`서버 오류: ${message}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(PORT, HOST, () => {
|
||||||
|
console.log(`[server] http://${HOST}:${PORT}`)
|
||||||
|
console.log(`[server] views: ${path.relative(process.cwd(), viewsDir)}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
19
src/auth.ts
Normal file
19
src/auth.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Request, Response, NextFunction } from 'express'
|
||||||
|
|
||||||
|
declare module 'express-session' {
|
||||||
|
interface SessionData {
|
||||||
|
userId?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
if (req.session?.userId) {
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
res.redirect('/op')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.status(401).json({ ok: false, message: '인증이 필요합니다.' })
|
||||||
|
}
|
||||||
93
src/editor.ts
Normal file
93
src/editor.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
export class FfmpegUnavailableError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('ffmpeg 가 설치되어 있지 않습니다. ffmpeg 를 PATH 에 설치한 뒤 다시 시도해 주세요.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedFfmpegPath: string | null = null
|
||||||
|
|
||||||
|
export function getFfmpegPath(): string {
|
||||||
|
if (resolvedFfmpegPath) return resolvedFfmpegPath
|
||||||
|
const r = spawnSync('ffmpeg', ['-version'])
|
||||||
|
if (r.status === 0) {
|
||||||
|
resolvedFfmpegPath = 'ffmpeg'
|
||||||
|
return 'ffmpeg'
|
||||||
|
}
|
||||||
|
throw new FfmpegUnavailableError()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원본 파일을 그대로 둔 채 trim 결과를 edited.<ext> 로 저장한다.
|
||||||
|
* stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩.
|
||||||
|
*/
|
||||||
|
export async function applyTrimToVideo(
|
||||||
|
folder: string,
|
||||||
|
videoId: string,
|
||||||
|
trim: VideoTrim
|
||||||
|
): Promise<string> {
|
||||||
|
const bin = getFfmpegPath()
|
||||||
|
const meta = await loadVideoMeta(folder, videoId)
|
||||||
|
if (!meta) throw new Error('비디오를 찾을 수 없습니다.')
|
||||||
|
const dir = videoDir(folder, videoId)
|
||||||
|
const inputPath = path.join(dir, meta.originalFile)
|
||||||
|
await fs.access(inputPath)
|
||||||
|
|
||||||
|
const ext = path.extname(meta.originalFile) || '.mp4'
|
||||||
|
const outName = `edited${ext}`
|
||||||
|
const outPath = path.join(dir, outName)
|
||||||
|
const tmpPath = outPath + '.tmp' + ext
|
||||||
|
|
||||||
|
const startSec = Math.max(0, Number(trim.startSec) || 0)
|
||||||
|
const endSec = trim.endSec == null ? null : Math.max(startSec, Number(trim.endSec))
|
||||||
|
|
||||||
|
const baseArgs = ['-y', '-ss', String(startSec)]
|
||||||
|
if (endSec !== null) baseArgs.push('-to', String(endSec))
|
||||||
|
baseArgs.push('-i', inputPath)
|
||||||
|
|
||||||
|
// 시도 1: stream copy (빠름)
|
||||||
|
const copyArgs = [...baseArgs, '-c', 'copy', '-movflags', '+faststart', tmpPath]
|
||||||
|
let ok = await runFfmpeg(bin, copyArgs)
|
||||||
|
if (!ok) {
|
||||||
|
// 시도 2: 재인코딩
|
||||||
|
const encArgs = [
|
||||||
|
...baseArgs,
|
||||||
|
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23',
|
||||||
|
'-c:a', 'aac', '-b:a', '128k',
|
||||||
|
'-movflags', '+faststart',
|
||||||
|
tmpPath
|
||||||
|
]
|
||||||
|
ok = await runFfmpeg(bin, encArgs)
|
||||||
|
if (!ok) throw new Error('ffmpeg trim 실패')
|
||||||
|
}
|
||||||
|
await fs.rename(tmpPath, outPath)
|
||||||
|
|
||||||
|
meta.editedFile = outName
|
||||||
|
meta.trim = { startSec, endSec }
|
||||||
|
await saveVideoMeta(folder, meta)
|
||||||
|
return outName
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFfmpeg(bin: string, args: string[]): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn(bin, args)
|
||||||
|
let stderr = ''
|
||||||
|
child.stderr.on('data', (c) => {
|
||||||
|
stderr = (stderr + c.toString()).slice(-2000)
|
||||||
|
})
|
||||||
|
child.on('error', () => resolve(false))
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve(true)
|
||||||
|
} else {
|
||||||
|
// 디버그용 stderr 만 콘솔로
|
||||||
|
console.error('[ffmpeg] failed:', stderr.split('\n').slice(-5).join('\n'))
|
||||||
|
resolve(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
19
src/paths.ts
Normal file
19
src/paths.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
// CommonJS/ESM 둘 다 호환되도록 import.meta 가 있을 때만 사용.
|
||||||
|
// tsc(NodeNext) + .js 임포트로 dist 가 ESM 으로 출력되므로 import.meta.url 이 살아있다.
|
||||||
|
const here = fileURLToPath(import.meta.url)
|
||||||
|
const hereDir = path.dirname(here)
|
||||||
|
|
||||||
|
// dist/ 또는 src/ 에서 실행되어도 동일하게 프로젝트 루트를 가리키도록 한다.
|
||||||
|
export const projectRoot = path.resolve(hereDir, '..')
|
||||||
|
|
||||||
|
export const dataDir = path.join(projectRoot, 'data')
|
||||||
|
export const foldersDir = path.join(dataDir, 'folders')
|
||||||
|
export const jobsDir = path.join(dataDir, 'jobs')
|
||||||
|
export const tmpDir = path.join(dataDir, 'tmp')
|
||||||
|
|
||||||
|
export const viewsDir = path.join(projectRoot, 'views')
|
||||||
|
export const publicDir = path.join(projectRoot, 'public')
|
||||||
|
export const accountJsonPath = path.join(projectRoot, 'account.json')
|
||||||
300
src/routes/op.ts
Normal file
300
src/routes/op.ts
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import path from 'node:path'
|
||||||
|
import multer from 'multer'
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import { requireAuth } from '../auth.js'
|
||||||
|
import {
|
||||||
|
createFolder,
|
||||||
|
deleteFolder,
|
||||||
|
deleteVideo,
|
||||||
|
folderPath,
|
||||||
|
listFolders,
|
||||||
|
listVideos,
|
||||||
|
loadVideoMeta,
|
||||||
|
moveUploadIntoVideo,
|
||||||
|
newVideoId,
|
||||||
|
readAccounts,
|
||||||
|
renameFolder,
|
||||||
|
sanitizeFolderName,
|
||||||
|
saveVideoMeta,
|
||||||
|
type VideoMeta
|
||||||
|
} from '../store.js'
|
||||||
|
import { tmpDir } from '../paths.js'
|
||||||
|
import {
|
||||||
|
YtDlpUnavailableError,
|
||||||
|
getJob,
|
||||||
|
probeYoutube,
|
||||||
|
startYoutubeDownload
|
||||||
|
} from '../youtube.js'
|
||||||
|
import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js'
|
||||||
|
|
||||||
|
export const opRouter = Router()
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
dest: tmpDir,
|
||||||
|
limits: { fileSize: 4 * 1024 * 1024 * 1024 } // 4GB
|
||||||
|
})
|
||||||
|
|
||||||
|
function pickStr(v: unknown): string {
|
||||||
|
if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : ''
|
||||||
|
return typeof v === 'string' ? v : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
opRouter.get('/op', (req, res) => {
|
||||||
|
if (req.session?.userId) {
|
||||||
|
res.redirect('/op/dashboard')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.render('op/login', { error: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const password = pickStr(req.body.password)
|
||||||
|
const accounts = await readAccounts()
|
||||||
|
const matched = accounts.find((a) => a.password === password)
|
||||||
|
if (!matched) {
|
||||||
|
res.status(401).render('op/login', { error: '비밀번호가 올바르지 않습니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.session.userId = matched.id
|
||||||
|
res.redirect('/op/dashboard')
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op/logout', (req, res) => {
|
||||||
|
req.session.destroy(() => {
|
||||||
|
res.redirect('/op')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.get('/op/dashboard', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const folders = await listFolders()
|
||||||
|
res.render('op/dashboard', { userId: req.session.userId, folders })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op/folders', requireAuth, async (req, res, next) => {
|
||||||
|
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) {
|
||||||
|
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', {
|
||||||
|
userId: req.session.userId,
|
||||||
|
folder: safe,
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 업로드: 단일 파일. multipart/form-data, fields: title, file
|
||||||
|
opRouter.post(
|
||||||
|
'/op/folder/:name/video/upload',
|
||||||
|
requireAuth,
|
||||||
|
upload.single('file'),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const safe = sanitizeFolderName(req.params.name)
|
||||||
|
if (!safe) 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,
|
||||||
|
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 })
|
||||||
|
} 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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.get('/op/job/:id', requireAuth, (req, res) => {
|
||||||
|
const job = getJob(req.params.id)
|
||||||
|
if (!job) {
|
||||||
|
res.status(404).json({ ok: false, message: '작업을 찾을 수 없습니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.json({ ok: true, job })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 편집 저장: trim 정보를 받아 ffmpeg 로 edited.<ext> 생성. 원본은 그대로 보존.
|
||||||
|
opRouter.post('/op/folder/:name/video/save', 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()
|
||||||
|
const startSec = Number(req.body.startSec ?? 0) || 0
|
||||||
|
const endRaw = req.body.endSec
|
||||||
|
const endSec =
|
||||||
|
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)
|
||||||
|
// ffmpeg 가 없으면 trim 정보만 저장하고 안내.
|
||||||
|
try {
|
||||||
|
const outName = await applyTrimToVideo(safe, id, meta.trim)
|
||||||
|
res.json({ ok: true, editedFile: outName, note: '편집본 저장 완료' })
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof FfmpegUnavailableError) {
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
editedFile: null,
|
||||||
|
note: 'ffmpeg 가 설치되지 않아 편집본을 만들지 못했습니다. trim 설정만 저장됐습니다.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||||
|
}
|
||||||
|
})
|
||||||
94
src/routes/public.ts
Normal file
94
src/routes/public.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { Router } 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'
|
||||||
|
|
||||||
|
export const publicRouter = Router()
|
||||||
|
|
||||||
|
publicRouter.get('/', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const folders = await listFolders()
|
||||||
|
res.render('index', { folders })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
publicRouter.get('/folder/:name', 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('folder', { folder: safe, videos, isAdmin: false })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
publicRouter.get('/player/:videoId', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const found = await findVideoAnywhere(req.params.videoId)
|
||||||
|
if (!found) {
|
||||||
|
res.status(404).send('영상을 찾을 수 없습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.render('player', { folder: found.folder, video: found.meta })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 영상 파일 스트리밍. ?edited=1 이면 편집본을, 아니면 원본을 보낸다. */
|
||||||
|
publicRouter.get('/api/video/:videoId/file', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const found = await findVideoAnywhere(req.params.videoId)
|
||||||
|
if (!found) {
|
||||||
|
res.status(404).end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const wantEdited = req.query.edited === '1' || req.query.edited === 'true'
|
||||||
|
const fileName =
|
||||||
|
wantEdited && found.meta.editedFile ? found.meta.editedFile : found.meta.originalFile
|
||||||
|
if (!fileName || fileName.includes('%(ext)s')) {
|
||||||
|
res.status(404).end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fsPath = videoFileFsPath(found.folder, found.meta.id, fileName)
|
||||||
|
res.sendFile(fsPath)
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 비디오 메타 조회 (플레이어/관리자 양쪽에서 사용) */
|
||||||
|
publicRouter.get('/api/video/:videoId', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const found = await findVideoAnywhere(req.params.videoId)
|
||||||
|
if (!found) {
|
||||||
|
res.status(404).json({ ok: false, message: '영상을 찾을 수 없습니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.json({ ok: true, folder: found.folder, video: found.meta })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
220
src/store.ts
Normal file
220
src/store.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readAccounts(): Promise<Account[]> {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(accountJsonPath, 'utf8')
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!Array.isArray(parsed)) return []
|
||||||
|
return parsed.filter(
|
||||||
|
(entry): entry is Account =>
|
||||||
|
typeof entry?.id === 'string' && typeof entry?.password === 'string'
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
243
src/youtube.ts
Normal file
243
src/youtube.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import { spawn, spawnSync } from 'node:child_process'
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { jobsDir, projectRoot } from './paths.js'
|
||||||
|
import {
|
||||||
|
loadVideoMeta,
|
||||||
|
newVideoId,
|
||||||
|
saveVideoMeta,
|
||||||
|
sanitizeFolderName,
|
||||||
|
videoDir,
|
||||||
|
type VideoMeta
|
||||||
|
} from './store.js'
|
||||||
|
|
||||||
|
export class YtDlpUnavailableError extends Error {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super(message || 'yt-dlp 가 설치되어 있지 않습니다. yt-dlp 를 PATH 에 설치한 뒤 다시 시도해 주세요.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedYtDlpPath: string | null = null
|
||||||
|
|
||||||
|
export function getYtDlpPath(): string {
|
||||||
|
if (resolvedYtDlpPath) return resolvedYtDlpPath
|
||||||
|
// 1) 프로젝트 bin/yt-dlp(.exe)
|
||||||
|
const localCandidates =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? ['bin/yt-dlp.exe', 'bin/yt-dlp']
|
||||||
|
: ['bin/yt-dlp']
|
||||||
|
for (const rel of localCandidates) {
|
||||||
|
const abs = path.join(projectRoot, rel)
|
||||||
|
const r = spawnSync(abs, ['--version'])
|
||||||
|
if (r.status === 0) {
|
||||||
|
resolvedYtDlpPath = abs
|
||||||
|
return abs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2) PATH 에서 검색
|
||||||
|
const r = spawnSync('yt-dlp', ['--version'])
|
||||||
|
if (r.status === 0) {
|
||||||
|
resolvedYtDlpPath = 'yt-dlp'
|
||||||
|
return 'yt-dlp'
|
||||||
|
}
|
||||||
|
throw new YtDlpUnavailableError()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProbeResult {
|
||||||
|
title: string
|
||||||
|
durationSec: number
|
||||||
|
filesizeApprox: number | null
|
||||||
|
/** 추정 다운로드 ETA (초). filesize_approx / 가정대역폭. 모를 때 null. */
|
||||||
|
etaSec: number | null
|
||||||
|
warnOver5min: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASSUMED_BPS = 5 * 1024 * 1024 // 5 MB/s 가정. 실측은 어렵지만 경고 트리거용으로만 사용.
|
||||||
|
|
||||||
|
export async function probeYoutube(url: string): Promise<ProbeResult> {
|
||||||
|
const bin = getYtDlpPath()
|
||||||
|
return new Promise<ProbeResult>((resolve, reject) => {
|
||||||
|
const child = spawn(bin, [
|
||||||
|
'--no-warnings',
|
||||||
|
'--skip-download',
|
||||||
|
'--print',
|
||||||
|
'%(title)s\n%(duration)s\n%(filesize_approx)s'
|
||||||
|
].concat([url]))
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
child.stdout.on('data', (chunk) => (stdout += chunk.toString()))
|
||||||
|
child.stderr.on('data', (chunk) => (stderr += chunk.toString()))
|
||||||
|
child.on('error', (err) => reject(err))
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(stderr.trim() || `yt-dlp probe 실패 (code=${code})`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const lines = stdout.trim().split('\n')
|
||||||
|
const title = lines[0] || ''
|
||||||
|
const durationSec = Number(lines[1]) || 0
|
||||||
|
const sizeRaw = lines[2]
|
||||||
|
const filesizeApprox =
|
||||||
|
sizeRaw && sizeRaw !== 'NA' && Number.isFinite(Number(sizeRaw))
|
||||||
|
? Number(sizeRaw)
|
||||||
|
: null
|
||||||
|
const etaSec = filesizeApprox ? Math.round(filesizeApprox / ASSUMED_BPS) : null
|
||||||
|
const warnOver5min = (etaSec ?? 0) > 5 * 60 || durationSec > 60 * 60
|
||||||
|
resolve({ title, durationSec, filesizeApprox, etaSec, warnOver5min })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 백그라운드 다운로드 잡 ────────────────────────────────────────────────
|
||||||
|
export type JobStatus = 'queued' | 'downloading' | 'done' | 'error'
|
||||||
|
|
||||||
|
export interface DownloadJob {
|
||||||
|
id: string
|
||||||
|
folder: string
|
||||||
|
videoId: string
|
||||||
|
url: string
|
||||||
|
status: JobStatus
|
||||||
|
progress: number // 0..100
|
||||||
|
message: string
|
||||||
|
startedAt: string
|
||||||
|
finishedAt: string | null
|
||||||
|
outputFile: string | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobs = new Map<string, DownloadJob>()
|
||||||
|
|
||||||
|
async function persistJob(job: DownloadJob): Promise<void> {
|
||||||
|
await fs.mkdir(jobsDir, { recursive: true })
|
||||||
|
await fs.writeFile(path.join(jobsDir, `${job.id}.json`), JSON.stringify(job, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJob(id: string): DownloadJob | null {
|
||||||
|
return jobs.get(id) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listActiveJobs(): DownloadJob[] {
|
||||||
|
return Array.from(jobs.values()).filter((j) => j.status === 'queued' || j.status === 'downloading')
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartDownloadOpts {
|
||||||
|
folder: string
|
||||||
|
url: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 백그라운드 yt-dlp 다운로드를 시작. videoId 도 함께 생성/저장한다. */
|
||||||
|
export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<DownloadJob> {
|
||||||
|
const safeFolder = sanitizeFolderName(opts.folder)
|
||||||
|
if (!safeFolder) 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,
|
||||||
|
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)
|
||||||
|
|
||||||
|
const job: DownloadJob = {
|
||||||
|
id: newVideoId(),
|
||||||
|
folder: safeFolder,
|
||||||
|
videoId,
|
||||||
|
url: opts.url,
|
||||||
|
status: 'queued',
|
||||||
|
progress: 0,
|
||||||
|
message: '대기 중',
|
||||||
|
startedAt: now,
|
||||||
|
finishedAt: null,
|
||||||
|
outputFile: null,
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
jobs.set(job.id, job)
|
||||||
|
void persistJob(job)
|
||||||
|
setImmediate(() => runJob(job, bin).catch((err) => {
|
||||||
|
job.status = 'error'
|
||||||
|
job.error = err instanceof Error ? err.message : String(err)
|
||||||
|
job.finishedAt = new Date().toISOString()
|
||||||
|
void persistJob(job)
|
||||||
|
}))
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
||||||
|
job.status = 'downloading'
|
||||||
|
job.message = '다운로드 시작'
|
||||||
|
await persistJob(job)
|
||||||
|
|
||||||
|
const dir = videoDir(job.folder, job.videoId)
|
||||||
|
// yt-dlp 가 다운로드 후 결정한 실제 파일명을 알려주도록 --print after_move:filepath
|
||||||
|
// 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱.
|
||||||
|
const args = [
|
||||||
|
'--no-warnings',
|
||||||
|
'--no-playlist',
|
||||||
|
'--newline',
|
||||||
|
'--progress-template', 'download:PROGRESS %(progress._percent_str)s',
|
||||||
|
'--print', 'after_move:OUT %(filepath)s',
|
||||||
|
'-o', path.join(dir, 'original.%(ext)s'),
|
||||||
|
job.url
|
||||||
|
]
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const child = spawn(bin, args)
|
||||||
|
let outputFile: string | null = null
|
||||||
|
let stderrTail = ''
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
const text = chunk.toString()
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const m = /PROGRESS\s+([\d.]+)%/.exec(line)
|
||||||
|
if (m) {
|
||||||
|
job.progress = Math.min(99, Math.round(Number(m[1])))
|
||||||
|
job.message = `다운로드 ${job.progress}%`
|
||||||
|
}
|
||||||
|
const o = /^OUT\s+(.+)$/.exec(line.trim())
|
||||||
|
if (o) outputFile = o[1].trim()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
stderrTail = (stderrTail + chunk.toString()).slice(-2000)
|
||||||
|
})
|
||||||
|
child.on('error', (err) => reject(err))
|
||||||
|
child.on('close', async (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(stderrTail.trim() || `yt-dlp 실패 (code=${code})`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 실제 파일명 결정
|
||||||
|
let finalName = 'original'
|
||||||
|
if (outputFile) {
|
||||||
|
finalName = path.basename(outputFile)
|
||||||
|
} else {
|
||||||
|
// fallback: 디렉토리 내 original.* 찾기
|
||||||
|
const entries = await fs.readdir(dir).catch(() => [])
|
||||||
|
const found = entries.find((n) => n.startsWith('original.'))
|
||||||
|
if (found) finalName = found
|
||||||
|
}
|
||||||
|
const meta = await loadVideoMeta(job.folder, job.videoId)
|
||||||
|
if (meta) {
|
||||||
|
meta.originalFile = finalName
|
||||||
|
await saveVideoMeta(job.folder, meta)
|
||||||
|
}
|
||||||
|
job.progress = 100
|
||||||
|
job.status = 'done'
|
||||||
|
job.message = '완료'
|
||||||
|
job.outputFile = finalName
|
||||||
|
job.finishedAt = new Date().toISOString()
|
||||||
|
await persistJob(job)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
50
views/folder.ejs
Normal file
50
views/folder.ejs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title><%= folder %> · 비디오 사이트</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="siteBody">
|
||||||
|
<header class="publicNav">
|
||||||
|
<a class="navBrand" href="/">
|
||||||
|
<span class="navLogo">🎬</span>
|
||||||
|
<span class="navTitle">비디오 사이트</span>
|
||||||
|
</a>
|
||||||
|
<a class="secondaryButton" href="/op">관리자</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="pageWrap">
|
||||||
|
<section class="hero">
|
||||||
|
<a class="muted" href="/">← 폴더 목록</a>
|
||||||
|
<h1>📁 <%= folder %></h1>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="videoGrid">
|
||||||
|
<% if (videos.length === 0) { %>
|
||||||
|
<p class="muted">이 폴더에 영상이 없습니다.</p>
|
||||||
|
<% } %>
|
||||||
|
<% videos.forEach(function (v) { %>
|
||||||
|
<button type="button" class="videoCard" data-video-id="<%= v.id %>">
|
||||||
|
<div class="videoThumb">▶</div>
|
||||||
|
<div class="videoTitle"><%= v.title %></div>
|
||||||
|
</button>
|
||||||
|
<% }) %>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.__SITE__ = { folder: <%- JSON.stringify(folder) %> }
|
||||||
|
</script>
|
||||||
|
<script src="/static/player.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
37
views/index.ejs
Normal file
37
views/index.ejs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>비디오 사이트</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="siteBody">
|
||||||
|
<header class="publicNav">
|
||||||
|
<a class="navBrand" href="/">
|
||||||
|
<span class="navLogo">🎬</span>
|
||||||
|
<span class="navTitle">비디오 사이트</span>
|
||||||
|
</a>
|
||||||
|
<a class="secondaryButton" href="/op">관리자</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="pageWrap">
|
||||||
|
<section class="hero">
|
||||||
|
<h1>폴더</h1>
|
||||||
|
<p>저장된 영상 폴더를 선택해 안에 있는 영상을 확인하세요.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="folderGrid">
|
||||||
|
<% if (folders.length === 0) { %>
|
||||||
|
<p class="muted">아직 폴더가 없습니다. 관리자 페이지에서 폴더를 추가해 주세요.</p>
|
||||||
|
<% } %>
|
||||||
|
<% folders.forEach(function (name) { %>
|
||||||
|
<a class="folderCard" href="/folder/<%= encodeURIComponent(name) %>">
|
||||||
|
<span class="folderIcon">📁</span>
|
||||||
|
<span class="folderName"><%= name %></span>
|
||||||
|
</a>
|
||||||
|
<% }) %>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
56
views/op/dashboard.ejs
Normal file
56
views/op/dashboard.ejs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>관리자 · 폴더</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="siteBody">
|
||||||
|
<%- include('../partials/navbar', { userId }) %>
|
||||||
|
|
||||||
|
<main class="pageWrap">
|
||||||
|
<section class="dashboardHeader">
|
||||||
|
<h1>폴더</h1>
|
||||||
|
<div class="dashboardActions">
|
||||||
|
<button type="button" class="primaryButton" id="addFolderBtn">폴더 추가</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="folderGrid" id="folderGrid">
|
||||||
|
<% 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) %>">
|
||||||
|
<span class="folderIcon">📁</span>
|
||||||
|
<span class="folderName"><%= name %></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="ctxMenu" id="ctxMenu" hidden>
|
||||||
|
<button type="button" data-action="rename">이름 변경</button>
|
||||||
|
<button type="button" data-action="delete" class="dangerLink">삭제</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modalOverlay" id="addFolderModal" hidden>
|
||||||
|
<div class="modalCard">
|
||||||
|
<h2>폴더 추가</h2>
|
||||||
|
<label>
|
||||||
|
<span>폴더 이름</span>
|
||||||
|
<input type="text" id="addFolderInput" autofocus />
|
||||||
|
</label>
|
||||||
|
<div class="modalActions">
|
||||||
|
<button type="button" class="secondaryButton" id="addFolderCancel">취소</button>
|
||||||
|
<button type="button" class="primaryButton" id="addFolderConfirm">생성</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/dashboard.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
64
views/op/editor.ejs
Normal file
64
views/op/editor.ejs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>영상 편집 · <%= folder %></title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="siteBody">
|
||||||
|
<header class="editorNav">
|
||||||
|
<div class="editorNavLeft">
|
||||||
|
<a class="muted" href="/op/folder/<%= encodeURIComponent(folder) %>">← <%= folder %></a>
|
||||||
|
<input type="text" id="titleInput" class="titleInput" placeholder="영상 제목" value="<%= video ? video.title : '' %>" />
|
||||||
|
</div>
|
||||||
|
<div class="editorNavRight">
|
||||||
|
<button type="button" class="primaryButton" id="saveBtn">저장</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="editorMain">
|
||||||
|
<section class="editorStage">
|
||||||
|
<div id="dropZone" class="dropZone" <% if (video) { %>hidden<% } %>>
|
||||||
|
<p class="dropTitle">영상 추가</p>
|
||||||
|
<p class="muted">파일을 드래그&드롭하거나, 아래에서 직접 선택하거나, YouTube 주소를 붙여넣으세요.</p>
|
||||||
|
<input type="file" id="fileInput" accept="video/*" />
|
||||||
|
<div class="ytRow">
|
||||||
|
<input type="text" id="ytUrl" placeholder="https://www.youtube.com/watch?v=..." />
|
||||||
|
<button type="button" class="secondaryButton" id="ytProbeBtn">확인</button>
|
||||||
|
<button type="button" class="primaryButton" id="ytStartBtn" disabled>가져오기</button>
|
||||||
|
</div>
|
||||||
|
<p id="probeInfo" class="muted"></p>
|
||||||
|
<progress id="downloadProgress" value="0" max="100" hidden></progress>
|
||||||
|
<p id="uploadStatus" class="muted"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="videoPanel" class="videoPanel" <% if (!video) { %>hidden<% } %>>
|
||||||
|
<video id="editVideo" controls preload="metadata"
|
||||||
|
<% if (video) { %>src="/api/video/<%= video.id %>/file"<% } %>></video>
|
||||||
|
<div class="trimControls">
|
||||||
|
<label>
|
||||||
|
<span>시작(초)</span>
|
||||||
|
<input type="number" id="startSec" step="0.1" min="0" value="<%= video && video.trim ? video.trim.startSec : 0 %>" />
|
||||||
|
<button type="button" class="secondaryButton" data-set-current="start">현재 시점</button>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>종료(초, 비우면 끝까지)</span>
|
||||||
|
<input type="number" id="endSec" step="0.1" min="0" value="<%= video && video.trim && video.trim.endSec != null ? video.trim.endSec : '' %>" />
|
||||||
|
<button type="button" class="secondaryButton" data-set-current="end">현재 시점</button>
|
||||||
|
</label>
|
||||||
|
<p class="muted">저장하면 ffmpeg 가 원본을 보존한 채 편집본을 만듭니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.__EDITOR__ = {
|
||||||
|
folder: <%- JSON.stringify(folder) %>,
|
||||||
|
video: <%- JSON.stringify(video) %>
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="/static/editor.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
50
views/op/folder.ejs
Normal file
50
views/op/folder.ejs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>관리자 · <%= folder %></title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="siteBody">
|
||||||
|
<%- include('../partials/navbar', { userId }) %>
|
||||||
|
|
||||||
|
<main class="pageWrap">
|
||||||
|
<section class="dashboardHeader">
|
||||||
|
<div>
|
||||||
|
<a class="muted" href="/op/dashboard">← 폴더 목록</a>
|
||||||
|
<h1>📁 <%= folder %></h1>
|
||||||
|
</div>
|
||||||
|
<div class="dashboardActions">
|
||||||
|
<a class="primaryButton" href="/op/folder/<%= encodeURIComponent(folder) %>/video/editor">영상 추가</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="videoGrid" id="videoGrid">
|
||||||
|
<% if (videos.length === 0) { %>
|
||||||
|
<p class="muted">이 폴더에 영상이 없습니다. 우측 상단에서 영상을 추가하세요.</p>
|
||||||
|
<% } %>
|
||||||
|
<% videos.forEach(function (v) { %>
|
||||||
|
<div class="videoCard adminVideo" data-id="<%= v.id %>" data-title="<%= v.title %>">
|
||||||
|
<div class="videoThumb">▶</div>
|
||||||
|
<div class="videoTitle"><%= v.title %></div>
|
||||||
|
<% if (v.sourceType === 'youtube' && !v.originalFile.includes('original.') === false) { %>
|
||||||
|
<div class="muted">YouTube</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<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="delete" class="dangerLink">삭제</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.__OP__ = { folder: <%- JSON.stringify(folder) %> }
|
||||||
|
</script>
|
||||||
|
<script src="/static/folder.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
views/op/login.ejs
Normal file
24
views/op/login.ejs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>관리자 로그인 · 비디오 사이트</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="siteBody centerLayout">
|
||||||
|
<main class="loginCard">
|
||||||
|
<h1>관리자 로그인</h1>
|
||||||
|
<% if (error) { %>
|
||||||
|
<p class="errorBanner"><%= error %></p>
|
||||||
|
<% } %>
|
||||||
|
<form method="post" action="/op" class="loginForm">
|
||||||
|
<label>
|
||||||
|
<span>비밀번호</span>
|
||||||
|
<input name="password" type="password" autocomplete="current-password" required autofocus />
|
||||||
|
</label>
|
||||||
|
<button class="primaryButton" type="submit">로그인</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
views/partials/navbar.ejs
Normal file
30
views/partials/navbar.ejs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<header class="topNav">
|
||||||
|
<a class="navBrand" href="/op/dashboard">
|
||||||
|
<span class="navLogo">🎬</span>
|
||||||
|
<span class="navTitle">비디오 사이트 관리</span>
|
||||||
|
</a>
|
||||||
|
<div class="navUser">
|
||||||
|
<button type="button" class="navUserButton" id="userMenuToggle"><%= userId %></button>
|
||||||
|
<div class="navUserMenu" id="userMenu" hidden>
|
||||||
|
<form method="post" action="/op/logout">
|
||||||
|
<button type="submit" class="dangerLink">로그아웃</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var toggle = document.getElementById('userMenuToggle')
|
||||||
|
var menu = document.getElementById('userMenu')
|
||||||
|
if (!toggle || !menu) return
|
||||||
|
toggle.addEventListener('click', function () {
|
||||||
|
var hidden = menu.hasAttribute('hidden')
|
||||||
|
if (hidden) menu.removeAttribute('hidden')
|
||||||
|
else menu.setAttribute('hidden', '')
|
||||||
|
})
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
if (event.target === toggle || menu.contains(event.target)) return
|
||||||
|
menu.setAttribute('hidden', '')
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
29
views/player.ejs
Normal file
29
views/player.ejs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title><%= video.title %> · 재생</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="siteBody">
|
||||||
|
<header class="publicNav">
|
||||||
|
<a class="navBrand" href="/">
|
||||||
|
<span class="navLogo">🎬</span>
|
||||||
|
<span class="navTitle">비디오 사이트</span>
|
||||||
|
</a>
|
||||||
|
<a class="secondaryButton" href="/folder/<%= encodeURIComponent(folder) %>">폴더로</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="pageWrap">
|
||||||
|
<section class="hero">
|
||||||
|
<a class="muted" href="/folder/<%= encodeURIComponent(folder) %>">← <%= folder %></a>
|
||||||
|
<h1><%= video.title %></h1>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="standalonePlayer">
|
||||||
|
<video controls autoplay preload="metadata" src="/api/video/<%= video.id %>/file<%= video.editedFile ? '?edited=1' : '' %>"></video>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user