Compare commits
18 Commits
210787131b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
692a824e78 | ||
|
|
745b1db1cc | ||
|
|
0abd21533b | ||
|
|
584ec8b92b | ||
|
|
6850313c0b | ||
|
|
6ade552683 | ||
|
|
03e97d771d | ||
|
|
ff78b60a4b | ||
|
|
042d9f7059 | ||
|
|
0ec6b06b57 | ||
|
|
66469ca418 | ||
|
|
8ed3b7d82a | ||
|
|
f9a26aaa86 | ||
|
|
e9924805cf | ||
|
|
2f916fe958 | ||
|
|
7105349a45 | ||
|
|
34c040c15d | ||
|
|
066ae6b112 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,6 +12,8 @@ data/app.db
|
|||||||
data/app.db-journal
|
data/app.db-journal
|
||||||
data/app.db-shm
|
data/app.db-shm
|
||||||
data/app.db-wal
|
data/app.db-wal
|
||||||
|
data/bin/
|
||||||
|
data/cookies.txt
|
||||||
.env
|
.env
|
||||||
!.env.example
|
!.env.example
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ ENV HOST=0.0.0.0
|
|||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|
||||||
# ffmpeg: 트림·60fps·FHD 변환. ca-certificates: yt-dlp https.
|
# ffmpeg: 트림·60fps·FHD 변환. ca-certificates: yt-dlp https.
|
||||||
|
# xz-utils: 관리자 화면에서 최신 ffmpeg 정적 빌드(.tar.xz) 재설치 시 tar -J 압축 해제에 필요.
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
|
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates xz-utils \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# yt-dlp 바이너리는 BuildKit 이 빌드 타임에 받아 넣는다 (이미지에 wget 불필요).
|
# yt-dlp 바이너리는 BuildKit 이 빌드 타임에 받아 넣는다 (이미지에 wget 불필요).
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ npm start
|
|||||||
- `yt-dlp` — YouTube 영상 가져오기. `npm run setup` 이 `./bin/yt-dlp` 로 자동 설치하지만 PATH 에 이미 있어도 됩니다. Alpine / distroless 같이 glibc 가 없는 슬림 도커 베이스에서는 번들 바이너리가 안 도니 `apk add yt-dlp` (또는 `apt-get install yt-dlp`, `pip install yt-dlp`) 로 PATH 에 직접 설치하세요. 도커 빌드에서 아예 받지 않으려면 `SKIP_YT_DLP=1 npm run setup`. (검증 실패해도 setup 은 경고만 출력하고 빌드는 계속 진행됩니다.)
|
- `yt-dlp` — YouTube 영상 가져오기. `npm run setup` 이 `./bin/yt-dlp` 로 자동 설치하지만 PATH 에 이미 있어도 됩니다. Alpine / distroless 같이 glibc 가 없는 슬림 도커 베이스에서는 번들 바이너리가 안 도니 `apk add yt-dlp` (또는 `apt-get install yt-dlp`, `pip install yt-dlp`) 로 PATH 에 직접 설치하세요. 도커 빌드에서 아예 받지 않으려면 `SKIP_YT_DLP=1 npm run setup`. (검증 실패해도 setup 은 경고만 출력하고 빌드는 계속 진행됩니다.)
|
||||||
- `ffmpeg` / `ffprobe` — 영상 트림 저장, 60fps/FHD 변환, 메타 조회 (`PATH` 에 설치). 없으면 다운로드는 되지만 후처리가 건너뛰어집니다.
|
- `ffmpeg` / `ffprobe` — 영상 트림 저장, 60fps/FHD 변환, 메타 조회 (`PATH` 에 설치). 없으면 다운로드는 되지만 후처리가 건너뛰어집니다.
|
||||||
- 하드웨어 인코더(`h264_nvenc` / `h264_qsv` / `h264_videotoolbox` / `h264_vaapi`) — 있으면 자동 감지해 사용. 없으면 `libx264 -preset ultrafast` 폴백. VAAPI 는 `/dev/dri/renderD128` 이 보일 때만 후보로 들어갑니다.
|
- 하드웨어 인코더(`h264_nvenc` / `h264_qsv` / `h264_videotoolbox` / `h264_vaapi`) — 있으면 자동 감지해 사용. 없으면 `libx264 -preset ultrafast` 폴백. VAAPI 는 `/dev/dri/renderD128` 이 보일 때만 후보로 들어갑니다.
|
||||||
|
- YouTube 로그인 쿠키 — 연령 제한("Sign in to confirm your age") 영상은 로그인 세션 없이는 yt-dlp 가 받지 못합니다. 관리자 대시보드(`/op/dashboard`)의 "YouTube 쿠키" 패널에서 브라우저 확장으로 내보낸 Netscape 형식 `cookies.txt` 를 등록하면 단일 영상 probe·플레이리스트 probe·실제 다운로드에 모두 `--cookies` 로 적용됩니다. 파일은 `data/cookies.txt` 에 권한 `0600` 으로 저장됩니다(세션 토큰이 들어 있어 git 에는 올라가지 않음). 등록된 쿠키로도 못 보는 영상이면 그 영상을 볼 수 있는 계정의 쿠키가 필요합니다.
|
||||||
|
|
||||||
## 데이터 위치
|
## 데이터 위치
|
||||||
|
|
||||||
@@ -57,7 +58,8 @@ data/
|
|||||||
- `/folder/:topName` — 1단계 폴더 (하위 폴더 + 영상 혼합 표시)
|
- `/folder/:topName` — 1단계 폴더 (하위 폴더 + 영상 혼합 표시)
|
||||||
- `/folder/:topName/:subName` — 2단계 폴더 (영상만 표시, 더 깊이 들어갈 수 없음)
|
- `/folder/:topName/:subName` — 2단계 폴더 (영상만 표시, 더 깊이 들어갈 수 없음)
|
||||||
- `/video/:topName/:videoId` 또는 `/video/:topName/:subName/:videoId` — 외부 공유용 짧은 URL. 경로의 폴더 부분이 영상 위치와 일치할 때만 200.
|
- `/video/:topName/:videoId` 또는 `/video/:topName/:subName/:videoId` — 외부 공유용 짧은 URL. 경로의 폴더 부분이 영상 위치와 일치할 때만 200.
|
||||||
- `/file/video/:videoId` — 실제 파일 스트림 (편집본 있으면 편집본). `?edited=0` 으로 원본 강제.
|
- `/api/video/:topName[/:subName]/:videoId` — 실제 파일 스트림 (정규). 재생 페이지와 동일한 폴더 경로 규칙으로, 경로가 영상 위치와 일치할 때만 200. `?edited=0` 으로 원본 강제.
|
||||||
|
- `/file/video/:videoId` — ID 기반 파일 스트림 (레거시·외부 공유 호환). ID 가 전역에서 유일할 때만 200, 여러 폴더에 같은 ID 가 있으면 404.
|
||||||
|
|
||||||
관리자(`/op`):
|
관리자(`/op`):
|
||||||
- `/op` — 폴더 목록 (로그인 필요)
|
- `/op` — 폴더 목록 (로그인 필요)
|
||||||
|
|||||||
@@ -79,4 +79,123 @@
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 도구 관리(ffmpeg / yt-dlp)
|
||||||
|
function renderTool(name, tool) {
|
||||||
|
var card = document.querySelector('.toolCard[data-tool="' + name + '"]')
|
||||||
|
if (!card) return
|
||||||
|
var versionEl = card.querySelector('[data-field="version"]')
|
||||||
|
var pathEl = card.querySelector('[data-field="path"]')
|
||||||
|
if (!tool || !tool.available) {
|
||||||
|
versionEl.textContent = '설치되지 않음'
|
||||||
|
pathEl.textContent = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
versionEl.textContent = (tool.version || '버전 미상') + (tool.managed ? ' · 직접 설치본' : '')
|
||||||
|
pathEl.textContent = tool.path || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTools() {
|
||||||
|
fetch('/op/tools').then(function (r) { return r.json() }).then(function (j) {
|
||||||
|
if (!j.ok) return
|
||||||
|
renderTool('yt-dlp', j.tools.ytDlp)
|
||||||
|
renderTool('ffmpeg', j.tools.ffmpeg)
|
||||||
|
}).catch(function () {})
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-action="update-tool"]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var name = btn.getAttribute('data-name')
|
||||||
|
var card = btn.closest('.toolCard')
|
||||||
|
var statusEl = card.querySelector('[data-field="status"]')
|
||||||
|
btn.disabled = true
|
||||||
|
statusEl.textContent = '재설치 중… (수십 초 걸릴 수 있습니다)'
|
||||||
|
fetch('/op/tools/' + encodeURIComponent(name) + '/update', { method: 'POST' })
|
||||||
|
.then(function (r) { return r.json() })
|
||||||
|
.then(function (j) {
|
||||||
|
if (j.ok) {
|
||||||
|
statusEl.textContent = '완료'
|
||||||
|
renderTool(name, j.tool)
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = '실패: ' + (j.message || '알 수 없는 오류')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
statusEl.textContent = '실패: ' + (err && err.message ? err.message : '요청 오류')
|
||||||
|
})
|
||||||
|
.then(function () { btn.disabled = false })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (document.querySelector('.toolsSection')) loadTools()
|
||||||
|
|
||||||
|
// YouTube 쿠키 관리
|
||||||
|
var cookieSection = document.getElementById('cookiesSection')
|
||||||
|
if (cookieSection) {
|
||||||
|
var cookieStatusEl = document.getElementById('cookieStatus')
|
||||||
|
var cookieFileEl = document.getElementById('cookieFile')
|
||||||
|
var cookieUploadBtn = document.getElementById('cookieUploadBtn')
|
||||||
|
var cookieDeleteBtn = document.getElementById('cookieDeleteBtn')
|
||||||
|
var cookieMsgEl = document.getElementById('cookieMsg')
|
||||||
|
|
||||||
|
function renderCookies(c) {
|
||||||
|
if (c && c.present) {
|
||||||
|
var kb = Math.max(1, Math.round(c.bytes / 1024))
|
||||||
|
var when = c.updatedAt ? new Date(c.updatedAt).toLocaleString() : ''
|
||||||
|
cookieStatusEl.textContent = '등록됨 (' + kb + ' KB' + (when ? ' · ' + when : '') + ')'
|
||||||
|
cookieDeleteBtn.hidden = false
|
||||||
|
} else {
|
||||||
|
cookieStatusEl.textContent = '등록되지 않음'
|
||||||
|
cookieDeleteBtn.hidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCookies() {
|
||||||
|
fetch('/op/cookies').then(function (r) { return r.json() }).then(function (j) {
|
||||||
|
if (j.ok) renderCookies(j.cookies)
|
||||||
|
}).catch(function () {})
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieUploadBtn.addEventListener('click', function () {
|
||||||
|
var file = cookieFileEl.files && cookieFileEl.files[0]
|
||||||
|
if (!file) { cookieMsgEl.textContent = '파일을 선택해 주세요.'; return }
|
||||||
|
var fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
cookieUploadBtn.disabled = true
|
||||||
|
cookieMsgEl.textContent = '등록 중…'
|
||||||
|
fetch('/op/cookies', { method: 'POST', body: fd })
|
||||||
|
.then(function (r) { return r.json() })
|
||||||
|
.then(function (j) {
|
||||||
|
if (j.ok) {
|
||||||
|
cookieMsgEl.textContent = '등록 완료'
|
||||||
|
cookieFileEl.value = ''
|
||||||
|
renderCookies(j.cookies)
|
||||||
|
} else {
|
||||||
|
cookieMsgEl.textContent = '실패: ' + (j.message || '알 수 없는 오류')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
cookieMsgEl.textContent = '실패: ' + (err && err.message ? err.message : '요청 오류')
|
||||||
|
})
|
||||||
|
.then(function () { cookieUploadBtn.disabled = false })
|
||||||
|
})
|
||||||
|
|
||||||
|
cookieDeleteBtn.addEventListener('click', function () {
|
||||||
|
if (!window.confirm('등록된 YouTube 쿠키를 삭제할까요?')) return
|
||||||
|
cookieDeleteBtn.disabled = true
|
||||||
|
cookieMsgEl.textContent = '삭제 중…'
|
||||||
|
fetch('/op/cookies/delete', { method: 'POST' })
|
||||||
|
.then(function (r) { return r.json() })
|
||||||
|
.then(function (j) {
|
||||||
|
if (j.ok) { cookieMsgEl.textContent = '삭제됨'; renderCookies(j.cookies) }
|
||||||
|
else cookieMsgEl.textContent = '실패: ' + (j.message || '알 수 없는 오류')
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
cookieMsgEl.textContent = '실패: ' + (err && err.message ? err.message : '요청 오류')
|
||||||
|
})
|
||||||
|
.then(function () { cookieDeleteBtn.disabled = false })
|
||||||
|
})
|
||||||
|
|
||||||
|
loadCookies()
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
changeIdBtn.disabled = true
|
changeIdBtn.disabled = true
|
||||||
clearTimeout(idDebounce)
|
clearTimeout(idDebounce)
|
||||||
idDebounce = setTimeout(function () {
|
idDebounce = setTimeout(function () {
|
||||||
fetch('/op/videos/idAvailable?id=' + encodeURIComponent(v), { cache: 'no-store' })
|
fetch('/op/videos/idAvailable?id=' + encodeURIComponent(v) + '&folderId=' + encodeURIComponent(ctx.folderId), { cache: 'no-store' })
|
||||||
.then(function (r) { return r.json() })
|
.then(function (r) { return r.json() })
|
||||||
.then(function (j) {
|
.then(function (j) {
|
||||||
// stale 응답 (사용자가 그 사이에 입력을 더 바꾼 경우) 무시
|
// stale 응답 (사용자가 그 사이에 입력을 더 바꾼 경우) 무시
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
fetch('/op/videos/' + encodeURIComponent(video.id) + '/changeId', {
|
fetch('/op/videos/' + encodeURIComponent(video.id) + '/changeId', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ newId: newId })
|
body: JSON.stringify({ newId: newId, folderId: ctx.folderId })
|
||||||
}).then(function (r) { return r.json() }).then(function (j) {
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
if (!j.ok) {
|
if (!j.ok) {
|
||||||
setIdStatus(j.message || 'ID 변경 실패', 'idStatusBad')
|
setIdStatus(j.message || 'ID 변경 실패', 'idStatusBad')
|
||||||
@@ -464,7 +464,8 @@
|
|||||||
var payload = {
|
var payload = {
|
||||||
title: titleInput.value,
|
title: titleInput.value,
|
||||||
startSec: trimStart,
|
startSec: trimStart,
|
||||||
endSec: trimEnd
|
endSec: trimEnd,
|
||||||
|
folderId: ctx.folderId
|
||||||
}
|
}
|
||||||
saveBtn.disabled = true
|
saveBtn.disabled = true
|
||||||
saveBtn.textContent = '저장 중...'
|
saveBtn.textContent = '저장 중...'
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/rename', {
|
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/rename', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ title: t })
|
body: JSON.stringify({ title: t, folderId: op.folderId })
|
||||||
}).then(function (r) { return r.json() }).then(function (j) {
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
if (j.ok) location.reload()
|
if (j.ok) location.reload()
|
||||||
else alert(j.message || '이름 변경 실패')
|
else alert(j.message || '이름 변경 실패')
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/changeId', {
|
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/changeId', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ newId: newId })
|
body: JSON.stringify({ newId: newId, folderId: op.folderId })
|
||||||
}).then(function (r) { return r.json() }).then(function (j) {
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
if (j.ok) location.reload()
|
if (j.ok) location.reload()
|
||||||
else alert(j.message || 'ID 변경 실패')
|
else alert(j.message || 'ID 변경 실패')
|
||||||
@@ -60,7 +60,9 @@
|
|||||||
} else if (action === 'delete') {
|
} else if (action === 'delete') {
|
||||||
if (window.confirm('"' + videoTargetTitle + '" 영상을 정말 삭제할까요?')) {
|
if (window.confirm('"' + videoTargetTitle + '" 영상을 정말 삭제할까요?')) {
|
||||||
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/delete', {
|
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/delete', {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ folderId: op.folderId })
|
||||||
}).then(function (r) { return r.json() }).then(function (j) {
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
if (j.ok) location.reload()
|
if (j.ok) location.reload()
|
||||||
else alert(j.message || '삭제 실패')
|
else alert(j.message || '삭제 실패')
|
||||||
|
|||||||
@@ -11,7 +11,11 @@
|
|||||||
var url = shareUrl || ('/file/video/' + encodeURIComponent(videoId))
|
var url = shareUrl || ('/file/video/' + encodeURIComponent(videoId))
|
||||||
history.pushState({ player: true, videoId: videoId }, '', url)
|
history.pushState({ player: true, videoId: videoId }, '', url)
|
||||||
titleEl.textContent = title || ''
|
titleEl.textContent = title || ''
|
||||||
video.src = '/api/video/' + encodeURIComponent(videoId) + '/file'
|
// 파일 스트림 URL 은 공유 URL(/video/폴더/.../아이디)의 /video/ 를
|
||||||
|
// /api/video/ 로 바꾼 폴더 경로 기반 주소. shareUrl 이 없으면 레거시 폴백.
|
||||||
|
video.src = shareUrl
|
||||||
|
? shareUrl.replace(/^\/video\//, '/api/video/')
|
||||||
|
: ('/file/video/' + encodeURIComponent(videoId))
|
||||||
overlay.hidden = false
|
overlay.hidden = false
|
||||||
video.play().catch(function () { /* 자동재생 막힘 무시 */ })
|
video.play().catch(function () { /* 자동재생 막힘 무시 */ })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,12 @@
|
|||||||
var startUrl = folderBaseUrl + '/playlist/start'
|
var startUrl = folderBaseUrl + '/playlist/start'
|
||||||
|
|
||||||
var ID_RE = /^[a-zA-Z0-9_-]{1,64}$/
|
var ID_RE = /^[a-zA-Z0-9_-]{1,64}$/
|
||||||
|
var DEFAULT_LENGTH = 15 // 기본 재생 길이(초)
|
||||||
|
|
||||||
// ─── state ────────────────────────────────────────────────────────────
|
// ─── state ────────────────────────────────────────────────────────────
|
||||||
// entries: [{ url, title, videoId, durationSec, idStatus:'pending'|'ok'|'bad', idReason:string, idDebounce:number }]
|
// entries: [{ url, title, videoId, durationSec, thumbnailUrl, ytId,
|
||||||
|
// startSec:number(기본0), lengthSec:number(기본15, 시작부터 재생할 초),
|
||||||
|
// idStatus:'pending'|'ok'|'bad', idReason:string, idDebounce:number }]
|
||||||
var state = {
|
var state = {
|
||||||
entries: [],
|
entries: [],
|
||||||
idMode: 'random', // 'random' | 'sequential'
|
idMode: 'random', // 'random' | 'sequential'
|
||||||
@@ -39,6 +42,12 @@
|
|||||||
var zeroPadToggle = document.getElementById('zeroPadToggle')
|
var zeroPadToggle = document.getElementById('zeroPadToggle')
|
||||||
var registerBtn = document.getElementById('registerBtn')
|
var registerBtn = document.getElementById('registerBtn')
|
||||||
var registerStatus = document.getElementById('registerStatus')
|
var registerStatus = document.getElementById('registerStatus')
|
||||||
|
var probeSection = document.getElementById('probeSection')
|
||||||
|
var progressSection = document.getElementById('progressSection')
|
||||||
|
var progressList = document.getElementById('progressList')
|
||||||
|
var progressSummary = document.getElementById('progressSummary')
|
||||||
|
var progressOverall = document.getElementById('progressOverall')
|
||||||
|
var progressDoneBtn = document.getElementById('progressDoneBtn')
|
||||||
|
|
||||||
// ─── 유틸 ─────────────────────────────────────────────────────────────
|
// ─── 유틸 ─────────────────────────────────────────────────────────────
|
||||||
function randomId() {
|
function randomId() {
|
||||||
@@ -131,7 +140,7 @@
|
|||||||
entry.idDebounce = setTimeout(function () {
|
entry.idDebounce = setTimeout(function () {
|
||||||
var current = state.entries[idx]
|
var current = state.entries[idx]
|
||||||
if (!current || current.videoId !== v) return
|
if (!current || current.videoId !== v) return
|
||||||
fetch('/op/videos/idAvailable?id=' + encodeURIComponent(v), { cache: 'no-store' })
|
fetch('/op/videos/idAvailable?id=' + encodeURIComponent(v) + '&folderId=' + encodeURIComponent(ctx.folderId), { cache: 'no-store' })
|
||||||
.then(function (r) { return r.json() })
|
.then(function (r) { return r.json() })
|
||||||
.then(function (j) {
|
.then(function (j) {
|
||||||
var c = state.entries[idx]
|
var c = state.entries[idx]
|
||||||
@@ -175,7 +184,7 @@
|
|||||||
li.innerHTML = ''
|
li.innerHTML = ''
|
||||||
+ '<span class="dragHandle" title="드래그로 순서 변경">≡</span>'
|
+ '<span class="dragHandle" title="드래그로 순서 변경">≡</span>'
|
||||||
+ '<span class="entryNum">' + (idx + 1) + '</span>'
|
+ '<span class="entryNum">' + (idx + 1) + '</span>'
|
||||||
+ '<div class="entryThumb"></div>'
|
+ '<div class="entryThumb" title="클릭하면 재생"></div>'
|
||||||
+ '<div class="entryFields">'
|
+ '<div class="entryFields">'
|
||||||
+ '<input type="text" class="entryTitleInput" />'
|
+ '<input type="text" class="entryTitleInput" />'
|
||||||
+ '<div class="entryIdRow">'
|
+ '<div class="entryIdRow">'
|
||||||
@@ -183,12 +192,18 @@
|
|||||||
+ '<span class="entryIdStatus muted"></span>'
|
+ '<span class="entryIdStatus muted"></span>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
|
+ '<div class="entryTrim">'
|
||||||
|
+ '<label>시작 <input type="text" class="entryStartInput" autocomplete="off" /></label>'
|
||||||
|
+ '<label>길이(초) <input type="text" class="entryLengthInput" autocomplete="off" /></label>'
|
||||||
|
+ '</div>'
|
||||||
+ '<span class="entryDuration muted"></span>'
|
+ '<span class="entryDuration muted"></span>'
|
||||||
+ '<button type="button" class="entryRemove" title="제외">✕</button>'
|
+ '<button type="button" class="entryRemove" title="제외">✕</button>'
|
||||||
|
|
||||||
var thumbBox = li.querySelector('.entryThumb')
|
var thumbBox = li.querySelector('.entryThumb')
|
||||||
var titleInput = li.querySelector('.entryTitleInput')
|
var titleInput = li.querySelector('.entryTitleInput')
|
||||||
var idInput = li.querySelector('.entryIdInput')
|
var idInput = li.querySelector('.entryIdInput')
|
||||||
|
var startInput = li.querySelector('.entryStartInput')
|
||||||
|
var lengthInput = li.querySelector('.entryLengthInput')
|
||||||
var durSpan = li.querySelector('.entryDuration')
|
var durSpan = li.querySelector('.entryDuration')
|
||||||
var removeBtn = li.querySelector('.entryRemove')
|
var removeBtn = li.querySelector('.entryRemove')
|
||||||
|
|
||||||
@@ -207,6 +222,7 @@
|
|||||||
titleInput.value = entry.title || ''
|
titleInput.value = entry.title || ''
|
||||||
idInput.value = entry.videoId || ''
|
idInput.value = entry.videoId || ''
|
||||||
durSpan.textContent = formatDuration(entry.durationSec)
|
durSpan.textContent = formatDuration(entry.durationSec)
|
||||||
|
paintTrimInputs(idx, startInput, lengthInput)
|
||||||
|
|
||||||
titleInput.addEventListener('input', function () {
|
titleInput.addEventListener('input', function () {
|
||||||
state.entries[idx].title = titleInput.value
|
state.entries[idx].title = titleInput.value
|
||||||
@@ -217,6 +233,26 @@
|
|||||||
// 요청 내 중복은 다른 행에도 영향 → 가시적으로 갱신
|
// 요청 내 중복은 다른 행에도 영향 → 가시적으로 갱신
|
||||||
revalidateOtherIds(idx)
|
revalidateOtherIds(idx)
|
||||||
})
|
})
|
||||||
|
startInput.addEventListener('change', function () {
|
||||||
|
var e = state.entries[idx]
|
||||||
|
var sec = parseClock(startInput.value)
|
||||||
|
e.startSec = sec != null && sec > 0 ? sec : 0
|
||||||
|
paintTrimInputs(idx, startInput, lengthInput)
|
||||||
|
})
|
||||||
|
lengthInput.addEventListener('change', function () {
|
||||||
|
var e = state.entries[idx]
|
||||||
|
var sec = parseClock(lengthInput.value)
|
||||||
|
e.lengthSec = sec != null && sec > 0 ? sec : DEFAULT_LENGTH
|
||||||
|
paintTrimInputs(idx, startInput, lengthInput)
|
||||||
|
})
|
||||||
|
thumbBox.addEventListener('click', function () { openPreview(idx) })
|
||||||
|
li.addEventListener('contextmenu', function (ev) {
|
||||||
|
// input/버튼 위에서의 우클릭은 기본 메뉴를 살려 둔다 (텍스트 편집 편의).
|
||||||
|
var t = ev.target
|
||||||
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'BUTTON')) return
|
||||||
|
ev.preventDefault()
|
||||||
|
openEntryCtxMenu(idx, ev.clientX, ev.clientY)
|
||||||
|
})
|
||||||
removeBtn.addEventListener('click', function () {
|
removeBtn.addEventListener('click', function () {
|
||||||
state.entries.splice(idx, 1)
|
state.entries.splice(idx, 1)
|
||||||
// sequential 이면 번호 재계산
|
// sequential 이면 번호 재계산
|
||||||
@@ -229,6 +265,29 @@
|
|||||||
return li
|
return li
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 항목의 시작/길이 입력칸 값을 상태로부터 다시 그린다.
|
||||||
|
function paintTrimInputs(idx, startInput, lengthInput) {
|
||||||
|
var e = state.entries[idx]
|
||||||
|
if (!e) return
|
||||||
|
if (!startInput) {
|
||||||
|
var row = findRow(idx)
|
||||||
|
if (!row) return
|
||||||
|
startInput = row.querySelector('.entryStartInput')
|
||||||
|
lengthInput = row.querySelector('.entryLengthInput')
|
||||||
|
if (!startInput || !lengthInput) return
|
||||||
|
}
|
||||||
|
startInput.value = formatClock(e.startSec || 0)
|
||||||
|
lengthInput.value = formatLength(e.lengthSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRow(idx) {
|
||||||
|
var rows = entryList.children
|
||||||
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
if (Number(rows[i].dataset.idx) === idx) return rows[i]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function paintEntryStatus(idx) {
|
function paintEntryStatus(idx) {
|
||||||
var rows = entryList.children
|
var rows = entryList.children
|
||||||
for (var i = 0; i < rows.length; i++) {
|
for (var i = 0; i < rows.length; i++) {
|
||||||
@@ -379,7 +438,10 @@
|
|||||||
title: e.title || '제목 없음',
|
title: e.title || '제목 없음',
|
||||||
durationSec: e.durationSec,
|
durationSec: e.durationSec,
|
||||||
thumbnailUrl: e.thumbnailUrl || null,
|
thumbnailUrl: e.thumbnailUrl || null,
|
||||||
|
ytId: extractYouTubeId(e.url),
|
||||||
videoId: '',
|
videoId: '',
|
||||||
|
startSec: 0,
|
||||||
|
lengthSec: DEFAULT_LENGTH,
|
||||||
idStatus: 'pending',
|
idStatus: 'pending',
|
||||||
idReason: ''
|
idReason: ''
|
||||||
}
|
}
|
||||||
@@ -405,7 +467,9 @@
|
|||||||
return {
|
return {
|
||||||
url: e.url,
|
url: e.url,
|
||||||
title: (e.title || '').trim(),
|
title: (e.title || '').trim(),
|
||||||
videoId: (e.videoId || '').trim()
|
videoId: (e.videoId || '').trim(),
|
||||||
|
startSec: e.startSec || 0,
|
||||||
|
endSec: computeEndSec(e)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -426,13 +490,7 @@
|
|||||||
updateRegisterEnable()
|
updateRegisterEnable()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setRegisterStatus(
|
startDownloadProgress(res.body.jobs || [])
|
||||||
(res.body.jobs ? res.body.jobs.length : 0) + ' 개 다운로드가 큐에 등록되었습니다. 폴더 페이지로 이동합니다…',
|
|
||||||
'idStatusOk'
|
|
||||||
)
|
|
||||||
setTimeout(function () {
|
|
||||||
location.href = folderBaseUrl
|
|
||||||
}, 800)
|
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
state.busy = false
|
state.busy = false
|
||||||
@@ -441,6 +499,732 @@
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── 다운로드 진행 화면 ────────────────────────────────────────────────
|
||||||
|
// 등록 후 폴더로 바로 나가지 않고, 각 잡을 폴링하며 진행률을 보여준다.
|
||||||
|
// (백엔드는 잡을 큐에 넣고 순차 처리 — queued/downloading/done/error.)
|
||||||
|
function startDownloadProgress(jobs) {
|
||||||
|
if (probeSection) probeSection.hidden = true
|
||||||
|
if (entriesSection) entriesSection.hidden = true
|
||||||
|
if (progressSection) progressSection.hidden = false
|
||||||
|
var items = jobs.map(function (j, i) {
|
||||||
|
var title = (state.entries[i] && state.entries[i].title) || j.videoId
|
||||||
|
return {
|
||||||
|
jobId: j.jobId,
|
||||||
|
videoId: j.videoId,
|
||||||
|
title: title,
|
||||||
|
status: 'queued',
|
||||||
|
progress: 0,
|
||||||
|
message: '대기 중',
|
||||||
|
done: false,
|
||||||
|
error: false,
|
||||||
|
barEl: null,
|
||||||
|
msgEl: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
renderProgressRows(items)
|
||||||
|
updateProgressUI(items)
|
||||||
|
if (items.length === 0) { finishProgress(items); return }
|
||||||
|
pollAllJobs(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProgressRows(items) {
|
||||||
|
progressList.innerHTML = ''
|
||||||
|
items.forEach(function (it) {
|
||||||
|
var li = document.createElement('li')
|
||||||
|
li.className = 'progressRow'
|
||||||
|
var name = document.createElement('div')
|
||||||
|
name.className = 'progressName'
|
||||||
|
name.textContent = it.title
|
||||||
|
var bar = document.createElement('progress')
|
||||||
|
bar.className = 'progressBar'
|
||||||
|
bar.max = 100
|
||||||
|
bar.value = 0
|
||||||
|
var msg = document.createElement('div')
|
||||||
|
msg.className = 'progressMsg muted'
|
||||||
|
msg.textContent = it.message
|
||||||
|
var retry = document.createElement('button')
|
||||||
|
retry.type = 'button'
|
||||||
|
retry.className = 'secondaryButton progressRetry'
|
||||||
|
retry.textContent = '재시도'
|
||||||
|
retry.hidden = true
|
||||||
|
retry.addEventListener('click', function () { retryItem(it, items) })
|
||||||
|
li.appendChild(name)
|
||||||
|
li.appendChild(bar)
|
||||||
|
li.appendChild(msg)
|
||||||
|
li.appendChild(retry)
|
||||||
|
progressList.appendChild(li)
|
||||||
|
it.barEl = bar
|
||||||
|
it.msgEl = msg
|
||||||
|
it.rowEl = li
|
||||||
|
it.retryEl = retry
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실패한 항목을 같은 ID·URL·자르기 설정으로 다시 받는다. 백엔드가 기존 영상
|
||||||
|
// 레코드를 재사용하므로 ID 충돌 없이 새 잡이 시작된다.
|
||||||
|
function retryItem(it, items) {
|
||||||
|
if (!it.error || it.retrying) return
|
||||||
|
it.retrying = true
|
||||||
|
if (it.retryEl) { it.retryEl.disabled = true; it.retryEl.textContent = '재시도 중…' }
|
||||||
|
fetch('/op/job/' + encodeURIComponent(it.jobId) + '/retry', { method: 'POST' })
|
||||||
|
.then(function (r) { return r.json() })
|
||||||
|
.then(function (j) {
|
||||||
|
if (j && j.ok) {
|
||||||
|
it.jobId = j.jobId
|
||||||
|
it.done = false
|
||||||
|
it.error = false
|
||||||
|
it.retrying = false
|
||||||
|
it.status = 'queued'
|
||||||
|
it.progress = 0
|
||||||
|
it.message = '재시도 대기 중'
|
||||||
|
if (it.barEl) it.barEl.classList.remove('progressBarError')
|
||||||
|
if (it.retryEl) { it.retryEl.hidden = true; it.retryEl.disabled = false; it.retryEl.textContent = '재시도' }
|
||||||
|
updateProgressUI(items)
|
||||||
|
if (progressDoneBtn) progressDoneBtn.hidden = true
|
||||||
|
if (!polling) pollAllJobs(items)
|
||||||
|
} else {
|
||||||
|
it.retrying = false
|
||||||
|
if (it.retryEl) { it.retryEl.disabled = false; it.retryEl.textContent = '재시도' }
|
||||||
|
it.message = '재시도 실패: ' + ((j && j.message) || '오류')
|
||||||
|
updateProgressUI(items)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
it.retrying = false
|
||||||
|
if (it.retryEl) { it.retryEl.disabled = false; it.retryEl.textContent = '재시도' }
|
||||||
|
it.message = '재시도 실패: ' + (err && err.message ? err.message : '요청 오류')
|
||||||
|
updateProgressUI(items)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var polling = false
|
||||||
|
function pollAllJobs(items) {
|
||||||
|
var pending = items.filter(function (it) { return !it.done })
|
||||||
|
if (pending.length === 0) { polling = false; finishProgress(items); return }
|
||||||
|
polling = true
|
||||||
|
Promise.all(pending.map(function (it) {
|
||||||
|
return fetch('/op/job/' + encodeURIComponent(it.jobId), { cache: 'no-store' })
|
||||||
|
.then(function (r) { return r.json() })
|
||||||
|
.then(function (j) {
|
||||||
|
if (j && j.ok && j.job) {
|
||||||
|
it.status = j.job.status
|
||||||
|
it.message = j.job.message || ''
|
||||||
|
it.progress = typeof j.job.progress === 'number' ? j.job.progress : it.progress
|
||||||
|
if (j.job.status === 'done') {
|
||||||
|
it.done = true
|
||||||
|
it.progress = 100
|
||||||
|
it.message = '완료'
|
||||||
|
} else if (j.job.status === 'error') {
|
||||||
|
it.done = true
|
||||||
|
it.error = true
|
||||||
|
it.message = '실패: ' + (j.job.error || j.job.message || '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { /* 일시 오류는 다음 틱에 재시도 */ })
|
||||||
|
})).then(function () {
|
||||||
|
updateProgressUI(items)
|
||||||
|
if (items.some(function (it) { return !it.done })) {
|
||||||
|
setTimeout(function () { pollAllJobs(items) }, 1500)
|
||||||
|
} else {
|
||||||
|
polling = false
|
||||||
|
finishProgress(items)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgressUI(items) {
|
||||||
|
var total = items.length
|
||||||
|
var doneCount = 0
|
||||||
|
var sum = 0
|
||||||
|
items.forEach(function (it) {
|
||||||
|
if (it.barEl) {
|
||||||
|
it.barEl.value = it.progress
|
||||||
|
if (it.error) it.barEl.classList.add('progressBarError')
|
||||||
|
}
|
||||||
|
if (it.msgEl) it.msgEl.textContent = it.message
|
||||||
|
if (it.rowEl) {
|
||||||
|
it.rowEl.classList.toggle('progressRowDone', it.done && !it.error)
|
||||||
|
it.rowEl.classList.toggle('progressRowError', it.error)
|
||||||
|
}
|
||||||
|
if (it.retryEl && !it.retrying) it.retryEl.hidden = !it.error
|
||||||
|
if (it.done) doneCount += 1
|
||||||
|
sum += it.progress
|
||||||
|
})
|
||||||
|
if (progressOverall) progressOverall.value = total ? Math.round(sum / total) : 0
|
||||||
|
if (progressSummary) progressSummary.textContent = '다운로드 ' + doneCount + ' / ' + total + ' 완료'
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishProgress(items) {
|
||||||
|
var errCount = items.filter(function (it) { return it.error }).length
|
||||||
|
if (progressOverall) progressOverall.value = 100
|
||||||
|
if (progressSummary) {
|
||||||
|
progressSummary.textContent = errCount
|
||||||
|
? ('완료 — ' + items.length + ' 개 중 ' + errCount + ' 개 실패')
|
||||||
|
: ('전체 ' + items.length + ' 개 다운로드 완료')
|
||||||
|
}
|
||||||
|
var statusEl = document.getElementById('progressStatus')
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = errCount
|
||||||
|
? '실패한 항목은 아래 "재시도" 버튼으로 다시 받을 수 있습니다. (연령 제한 영상은 먼저 대시보드에서 YouTube 쿠키를 등록하세요.)'
|
||||||
|
: '모든 영상이 폴더에 추가되었습니다.'
|
||||||
|
}
|
||||||
|
if (progressDoneBtn) progressDoneBtn.hidden = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 시간 파싱/표시 + YouTube id 추출 ──────────────────────────────────
|
||||||
|
// "ss" / "mm:ss" / "hh:mm:ss" / 소수 초 를 모두 받아 초(number)로. 빈칸/오류는 null.
|
||||||
|
function parseClock(str) {
|
||||||
|
if (str == null) return null
|
||||||
|
str = String(str).trim()
|
||||||
|
if (!str) return null
|
||||||
|
var total = 0
|
||||||
|
if (str.indexOf(':') >= 0) {
|
||||||
|
var parts = str.split(':')
|
||||||
|
for (var i = 0; i < parts.length; i++) {
|
||||||
|
var n = Number(parts[i])
|
||||||
|
if (!isFinite(n) || n < 0) return null
|
||||||
|
total = total * 60 + n
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var v = Number(str)
|
||||||
|
if (!isFinite(v) || v < 0) return null
|
||||||
|
total = v
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
// 입력칸 표시용. formatDuration 과 동일 포맷(mm:ss / h:mm:ss).
|
||||||
|
function formatClock(sec) {
|
||||||
|
if (sec == null || !isFinite(sec)) return ''
|
||||||
|
return formatDuration(sec)
|
||||||
|
}
|
||||||
|
function extractYouTubeId(url) {
|
||||||
|
if (!url) return null
|
||||||
|
var m = String(url).match(
|
||||||
|
/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|v\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/
|
||||||
|
)
|
||||||
|
return m ? m[1] : null
|
||||||
|
}
|
||||||
|
// 재생 길이(초) 표시용. 정수면 "15", 소수면 "12.3".
|
||||||
|
function formatLength(sec) {
|
||||||
|
if (sec == null || !isFinite(sec) || sec <= 0) return ''
|
||||||
|
var r = Math.round(sec * 10) / 10
|
||||||
|
return r % 1 === 0 ? String(r) : r.toFixed(1)
|
||||||
|
}
|
||||||
|
// 시작 + 길이 → 백엔드용 종료초. 영상 끝을 넘으면 null(끝까지)로.
|
||||||
|
function computeEndSec(e) {
|
||||||
|
var start = e.startSec || 0
|
||||||
|
var len = e.lengthSec != null && e.lengthSec > 0 ? e.lengthSec : DEFAULT_LENGTH
|
||||||
|
var end = start + len
|
||||||
|
if (e.durationSec != null && end >= e.durationSec - 0.01) return null
|
||||||
|
return end
|
||||||
|
}
|
||||||
|
function clampNum(v, lo, hi) { return Math.min(hi, Math.max(lo, v)) }
|
||||||
|
|
||||||
|
// ─── 일괄 시작/길이 적용 + 초기화 ──────────────────────────────────────
|
||||||
|
var bulkStart = document.getElementById('bulkStart')
|
||||||
|
var bulkLength = document.getElementById('bulkLength')
|
||||||
|
var bulkApplyBtn = document.getElementById('bulkApplyBtn')
|
||||||
|
var bulkResetBtn = document.getElementById('bulkResetBtn')
|
||||||
|
|
||||||
|
bulkApplyBtn.addEventListener('click', function () {
|
||||||
|
var s = parseClock(bulkStart.value)
|
||||||
|
var startSec = s != null && s > 0 ? s : 0
|
||||||
|
var len = parseClock(bulkLength.value)
|
||||||
|
var lengthSec = len != null && len > 0 ? len : DEFAULT_LENGTH
|
||||||
|
for (var i = 0; i < state.entries.length; i++) {
|
||||||
|
var e = state.entries[i]
|
||||||
|
e.startSec = startSec
|
||||||
|
e.lengthSec = lengthSec
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
bulkResetBtn.addEventListener('click', function () {
|
||||||
|
for (var i = 0; i < state.entries.length; i++) {
|
||||||
|
state.entries[i].startSec = 0
|
||||||
|
state.entries[i].lengthSec = DEFAULT_LENGTH
|
||||||
|
}
|
||||||
|
bulkStart.value = ''
|
||||||
|
bulkLength.value = ''
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── YouTube IFrame API 준비 ───────────────────────────────────────────
|
||||||
|
var ytReady = false
|
||||||
|
var ytReadyCbs = []
|
||||||
|
window.onYouTubeIframeAPIReady = function () {
|
||||||
|
ytReady = true
|
||||||
|
var cbs = ytReadyCbs; ytReadyCbs = []
|
||||||
|
cbs.forEach(function (cb) { cb() })
|
||||||
|
}
|
||||||
|
function whenYt(cb) {
|
||||||
|
if (ytReady && window.YT && window.YT.Player) cb()
|
||||||
|
else ytReadyCbs.push(cb)
|
||||||
|
}
|
||||||
|
function resetHost(wrap, id) {
|
||||||
|
if (!wrap) return
|
||||||
|
wrap.innerHTML = '<div id="' + id + '"></div>'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 미리보기 재생 모달 ────────────────────────────────────────────────
|
||||||
|
var previewOverlay = document.getElementById('previewOverlay')
|
||||||
|
var previewClose = document.getElementById('previewClose')
|
||||||
|
var previewTitle = document.getElementById('previewTitle')
|
||||||
|
var previewPlayer = null
|
||||||
|
|
||||||
|
function openPreview(idx) {
|
||||||
|
var e = state.entries[idx]
|
||||||
|
if (!e) return
|
||||||
|
if (!e.ytId) {
|
||||||
|
setRegisterStatus('이 항목은 YouTube 영상이 아니라 미리보기를 지원하지 않습니다.', 'idStatusBad')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previewTitle.textContent = e.title || ''
|
||||||
|
previewOverlay.hidden = false
|
||||||
|
whenYt(function () {
|
||||||
|
if (previewOverlay.hidden) return // API 준비 전에 닫혔으면 무시
|
||||||
|
destroyPreview()
|
||||||
|
resetHost(previewOverlay.querySelector('.ytEmbedWrap'), 'previewPlayer')
|
||||||
|
previewPlayer = new YT.Player('previewPlayer', {
|
||||||
|
videoId: e.ytId,
|
||||||
|
playerVars: { autoplay: 1, rel: 0, modestbranding: 1, start: Math.floor(e.startSec || 0) }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function destroyPreview() {
|
||||||
|
if (previewPlayer) { try { previewPlayer.destroy() } catch (_) {} previewPlayer = null }
|
||||||
|
}
|
||||||
|
function closePreview() {
|
||||||
|
previewOverlay.hidden = true
|
||||||
|
destroyPreview()
|
||||||
|
}
|
||||||
|
previewClose.addEventListener('click', closePreview)
|
||||||
|
previewOverlay.addEventListener('click', function (e) {
|
||||||
|
if (e.target === previewOverlay) closePreview()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── 우클릭 컨텍스트 메뉴 (자르기) ─────────────────────────────────────
|
||||||
|
var entryCtxMenu = document.getElementById('entryCtxMenu')
|
||||||
|
var ctxIdx = -1
|
||||||
|
function openEntryCtxMenu(idx, x, y) {
|
||||||
|
ctxIdx = idx
|
||||||
|
entryCtxMenu.hidden = false
|
||||||
|
// 화면 밖으로 넘치지 않게 살짝 보정
|
||||||
|
var mw = 180, mh = 60
|
||||||
|
entryCtxMenu.style.left = Math.min(x, window.innerWidth - mw) + 'px'
|
||||||
|
entryCtxMenu.style.top = Math.min(y, window.innerHeight - mh) + 'px'
|
||||||
|
}
|
||||||
|
function closeCtxMenu() { entryCtxMenu.hidden = true }
|
||||||
|
document.addEventListener('click', closeCtxMenu)
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') { closeCtxMenu(); closePreview(); closeTrim() }
|
||||||
|
})
|
||||||
|
entryCtxMenu.querySelector('[data-action="trim"]').addEventListener('click', function () {
|
||||||
|
var idx = ctxIdx
|
||||||
|
closeCtxMenu()
|
||||||
|
if (idx >= 0) openTrim(idx)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── 자르기(트림) 모달 — YouTube 임베드 + 타임라인 ─────────────────────
|
||||||
|
var trimOverlay = document.getElementById('trimOverlay')
|
||||||
|
var trimClose = document.getElementById('trimClose')
|
||||||
|
var trimCancel = document.getElementById('trimCancel')
|
||||||
|
var trimApply = document.getElementById('trimApply')
|
||||||
|
var trimTitle = document.getElementById('trimTitle')
|
||||||
|
var plTrimBar = document.getElementById('plTrimBar')
|
||||||
|
var plTrimRangeFill = document.getElementById('plTrimRangeFill')
|
||||||
|
var plTrimHandleStart = document.getElementById('plTrimHandleStart')
|
||||||
|
var plTrimHandleEnd = document.getElementById('plTrimHandleEnd')
|
||||||
|
var plTrimPlayhead = document.getElementById('plTrimPlayhead')
|
||||||
|
var plTimeReadout = document.getElementById('plTimeReadout')
|
||||||
|
var plTrimDuration = document.getElementById('plTrimDuration')
|
||||||
|
var plStartSec = document.getElementById('plStartSec')
|
||||||
|
var plLengthSec = document.getElementById('plLengthSec')
|
||||||
|
|
||||||
|
var MIN_SEL = 0.05
|
||||||
|
var trimPlayer = null
|
||||||
|
var trimPoll = null
|
||||||
|
var selStopAt = null // 선택 재생 정지 지점(초). null 이면 비활성.
|
||||||
|
// 현재 편집 중 컨텍스트: { idx, duration, start, end(null=끝까지) }
|
||||||
|
var tctx = null
|
||||||
|
|
||||||
|
function tEnd() { return tctx.end != null ? tctx.end : tctx.duration }
|
||||||
|
|
||||||
|
function formatTimeF(sec) {
|
||||||
|
if (!isFinite(sec) || sec < 0) sec = 0
|
||||||
|
var m = Math.floor(sec / 60)
|
||||||
|
var s = sec - m * 60
|
||||||
|
var sStr = s.toFixed(1)
|
||||||
|
if (s < 10) sStr = '0' + sStr
|
||||||
|
return (m < 10 ? '0' + m : '' + m) + ':' + sStr
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTrimBar() {
|
||||||
|
if (!tctx) return
|
||||||
|
var d = tctx.duration || 0
|
||||||
|
var end = tEnd()
|
||||||
|
var startPct = d ? (tctx.start / d) * 100 : 0
|
||||||
|
var endPct = d ? (end / d) * 100 : 0
|
||||||
|
plTrimRangeFill.style.left = startPct + '%'
|
||||||
|
plTrimRangeFill.style.width = Math.max(0, endPct - startPct) + '%'
|
||||||
|
plTrimHandleStart.style.left = startPct + '%'
|
||||||
|
plTrimHandleEnd.style.left = endPct + '%'
|
||||||
|
plTrimDuration.textContent = '선택: ' + (end - tctx.start).toFixed(1) + '초'
|
||||||
|
plStartSec.value = (tctx.start || 0).toFixed(2)
|
||||||
|
// 끝 대신 재생 길이(초)를 보여준다 (끝까지면 영상 끝 - 시작).
|
||||||
|
plLengthSec.value = Math.max(0, end - tctx.start).toFixed(2)
|
||||||
|
}
|
||||||
|
function renderPlayhead() {
|
||||||
|
if (!tctx) return
|
||||||
|
var d = tctx.duration || 0
|
||||||
|
var cur = 0
|
||||||
|
if (trimPlayer && trimPlayer.getCurrentTime) {
|
||||||
|
try { cur = trimPlayer.getCurrentTime() || 0 } catch (_) { cur = 0 }
|
||||||
|
}
|
||||||
|
plTrimPlayhead.style.left = (d ? clampNum((cur / d) * 100, 0, 100) : 0) + '%'
|
||||||
|
plTimeReadout.textContent = formatTimeF(cur) + ' / ' + formatTimeF(d)
|
||||||
|
if (selStopAt != null && cur >= selStopAt - 0.05) {
|
||||||
|
try { trimPlayer.pauseVideo() } catch (_) {}
|
||||||
|
selStopAt = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTrim(idx) {
|
||||||
|
var e = state.entries[idx]
|
||||||
|
if (!e) return
|
||||||
|
if (!e.ytId) {
|
||||||
|
setRegisterStatus('이 항목은 YouTube 영상이 아니라 자르기를 지원하지 않습니다.', 'idStatusBad')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trimTitle.textContent = e.title || ''
|
||||||
|
trimOverlay.hidden = false
|
||||||
|
// 항목의 시작 + 길이를 타임라인의 시작/끝으로 변환. 영상 길이를 넘으면 끝까지로.
|
||||||
|
var startSec = e.startSec || 0
|
||||||
|
var lenSec = e.lengthSec != null && e.lengthSec > 0 ? e.lengthSec : DEFAULT_LENGTH
|
||||||
|
var dur = e.durationSec || 0
|
||||||
|
var endSec = startSec + lenSec
|
||||||
|
if (dur && endSec >= dur - 0.01) endSec = null
|
||||||
|
tctx = {
|
||||||
|
idx: idx,
|
||||||
|
duration: dur,
|
||||||
|
start: startSec,
|
||||||
|
end: endSec
|
||||||
|
}
|
||||||
|
selStopAt = null
|
||||||
|
renderTrimBar()
|
||||||
|
renderPlayhead()
|
||||||
|
whenYt(function () {
|
||||||
|
if (trimOverlay.hidden) return // API 준비 전에 닫혔으면 무시
|
||||||
|
destroyTrimPlayer()
|
||||||
|
resetHost(trimOverlay.querySelector('.ytEmbedWrap'), 'trimPlayer')
|
||||||
|
trimPlayer = new YT.Player('trimPlayer', {
|
||||||
|
videoId: e.ytId,
|
||||||
|
playerVars: { rel: 0, modestbranding: 1, start: Math.floor(tctx.start || 0) },
|
||||||
|
events: {
|
||||||
|
onReady: function () {
|
||||||
|
var d = 0
|
||||||
|
try { d = trimPlayer.getDuration() } catch (_) {}
|
||||||
|
if (d && isFinite(d) && d > 0) {
|
||||||
|
tctx.duration = d
|
||||||
|
tctx.start = clampNum(tctx.start, 0, Math.max(0, d - MIN_SEL))
|
||||||
|
if (tctx.end != null) tctx.end = clampNum(tctx.end, tctx.start + MIN_SEL, d)
|
||||||
|
renderTrimBar()
|
||||||
|
}
|
||||||
|
startTrimPoll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function startTrimPoll() {
|
||||||
|
if (trimPoll) clearInterval(trimPoll)
|
||||||
|
trimPoll = setInterval(renderPlayhead, 200)
|
||||||
|
}
|
||||||
|
function destroyTrimPlayer() {
|
||||||
|
if (trimPoll) { clearInterval(trimPoll); trimPoll = null }
|
||||||
|
if (trimPlayer) { try { trimPlayer.destroy() } catch (_) {} trimPlayer = null }
|
||||||
|
}
|
||||||
|
function closeTrim() {
|
||||||
|
trimOverlay.hidden = true
|
||||||
|
selStopAt = null
|
||||||
|
destroyTrimPlayer()
|
||||||
|
tctx = null
|
||||||
|
}
|
||||||
|
trimClose.addEventListener('click', closeTrim)
|
||||||
|
trimCancel.addEventListener('click', closeTrim)
|
||||||
|
trimOverlay.addEventListener('click', function (e) {
|
||||||
|
if (e.target === trimOverlay) closeTrim()
|
||||||
|
})
|
||||||
|
trimApply.addEventListener('click', function () {
|
||||||
|
if (!tctx) return
|
||||||
|
var e = state.entries[tctx.idx]
|
||||||
|
if (e) {
|
||||||
|
e.startSec = tctx.start > 0 ? tctx.start : 0
|
||||||
|
// 타임라인의 끝(없으면 영상 끝) - 시작 = 재생 길이.
|
||||||
|
var end = tctx.end != null ? tctx.end : tctx.duration
|
||||||
|
var len = end - e.startSec
|
||||||
|
e.lengthSec = len > 0 ? len : DEFAULT_LENGTH
|
||||||
|
if (tctx.duration && e.durationSec == null) e.durationSec = tctx.duration
|
||||||
|
paintTrimInputs(tctx.idx)
|
||||||
|
}
|
||||||
|
closeTrim()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 타임라인 드래그/클릭/마크/숫자입력
|
||||||
|
function trimClientXtoTime(clientX) {
|
||||||
|
var rect = plTrimBar.getBoundingClientRect()
|
||||||
|
var ratio = clampNum((clientX - rect.left) / rect.width, 0, 1)
|
||||||
|
return ratio * (tctx ? tctx.duration : 0)
|
||||||
|
}
|
||||||
|
function attachTrimHandle(handle, which) {
|
||||||
|
handle.addEventListener('pointerdown', function (e) {
|
||||||
|
if (!tctx || !tctx.duration) return
|
||||||
|
e.preventDefault()
|
||||||
|
handle.setPointerCapture(e.pointerId)
|
||||||
|
handle.classList.add('dragging')
|
||||||
|
function onMove(ev) {
|
||||||
|
var t = trimClientXtoTime(ev.clientX)
|
||||||
|
if (which === 'start') tctx.start = clampNum(t, 0, tEnd() - MIN_SEL)
|
||||||
|
else tctx.end = clampNum(t, tctx.start + MIN_SEL, tctx.duration)
|
||||||
|
renderTrimBar()
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
handle.classList.remove('dragging')
|
||||||
|
handle.removeEventListener('pointermove', onMove)
|
||||||
|
handle.removeEventListener('pointerup', onUp)
|
||||||
|
handle.removeEventListener('pointercancel', onUp)
|
||||||
|
}
|
||||||
|
handle.addEventListener('pointermove', onMove)
|
||||||
|
handle.addEventListener('pointerup', onUp)
|
||||||
|
handle.addEventListener('pointercancel', onUp)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
attachTrimHandle(plTrimHandleStart, 'start')
|
||||||
|
attachTrimHandle(plTrimHandleEnd, 'end')
|
||||||
|
plTrimBar.addEventListener('pointerdown', function (e) {
|
||||||
|
if (!tctx || !tctx.duration) return
|
||||||
|
if (e.target === plTrimHandleStart || e.target === plTrimHandleEnd) return
|
||||||
|
var t = trimClientXtoTime(e.clientX)
|
||||||
|
if (trimPlayer && trimPlayer.seekTo) { try { trimPlayer.seekTo(t, true) } catch (_) {} }
|
||||||
|
renderPlayhead()
|
||||||
|
})
|
||||||
|
trimOverlay.querySelectorAll('[data-pmark]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
if (!tctx || !tctx.duration || !trimPlayer) return
|
||||||
|
var t = 0
|
||||||
|
try { t = trimPlayer.getCurrentTime() || 0 } catch (_) {}
|
||||||
|
if (btn.getAttribute('data-pmark') === 'start') tctx.start = clampNum(t, 0, tEnd() - MIN_SEL)
|
||||||
|
else tctx.end = clampNum(t, tctx.start + MIN_SEL, tctx.duration)
|
||||||
|
renderTrimBar()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
trimOverlay.querySelectorAll('[data-paction]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var action = btn.getAttribute('data-paction')
|
||||||
|
if (action === 'reset') {
|
||||||
|
if (!tctx) return
|
||||||
|
tctx.start = 0; tctx.end = null
|
||||||
|
renderTrimBar()
|
||||||
|
} else if (action === 'playSelection') {
|
||||||
|
if (!tctx || !trimPlayer) return
|
||||||
|
try {
|
||||||
|
trimPlayer.seekTo(tctx.start, true)
|
||||||
|
trimPlayer.playVideo()
|
||||||
|
} catch (_) {}
|
||||||
|
selStopAt = tEnd()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
plStartSec.addEventListener('change', function () {
|
||||||
|
if (!tctx) return
|
||||||
|
var v = Number(plStartSec.value || 0)
|
||||||
|
if (!isFinite(v) || v < 0) v = 0
|
||||||
|
tctx.start = tctx.duration ? clampNum(v, 0, tEnd() - MIN_SEL) : v
|
||||||
|
renderTrimBar()
|
||||||
|
})
|
||||||
|
plLengthSec.addEventListener('change', function () {
|
||||||
|
if (!tctx) return
|
||||||
|
// 길이(초) 입력 → 끝 = 시작 + 길이.
|
||||||
|
var v = Number(plLengthSec.value)
|
||||||
|
if (!isFinite(v) || v <= 0) { renderTrimBar(); return }
|
||||||
|
var end = tctx.start + v
|
||||||
|
tctx.end = tctx.duration ? clampNum(end, tctx.start + MIN_SEL, tctx.duration) : end
|
||||||
|
renderTrimBar()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── 임시저장(draft) ───────────────────────────────────────────────────
|
||||||
|
// 다운로드 시작 전, 수정해둔 값(URL + 항목별 제목/ID/시작/길이 등)을 폴더당 1개 저장/복원.
|
||||||
|
var draftUrl = folderBaseUrl + '/playlist/draft'
|
||||||
|
var draftDeleteUrl = folderBaseUrl + '/playlist/draft/delete'
|
||||||
|
var draftSaveBtn = document.getElementById('draftSaveBtn')
|
||||||
|
var draftLoadBtn = document.getElementById('draftLoadBtn')
|
||||||
|
var draftDeleteBtn = document.getElementById('draftDeleteBtn')
|
||||||
|
var draftStatus = document.getElementById('draftStatus')
|
||||||
|
|
||||||
|
function setDraftStatus(text, cls) {
|
||||||
|
if (!draftStatus) return
|
||||||
|
draftStatus.textContent = text || ''
|
||||||
|
draftStatus.className = cls || 'muted'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSavedAt(iso) {
|
||||||
|
try {
|
||||||
|
var d = new Date(iso)
|
||||||
|
if (isNaN(d.getTime())) return ''
|
||||||
|
return d.toLocaleString()
|
||||||
|
} catch (_) { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 저장 대상: 편집 가능한 값만 추린다 (idStatus/idDebounce 같은 휘발 상태 제외).
|
||||||
|
function serializeDraft() {
|
||||||
|
return {
|
||||||
|
url: (urlInput.value || '').trim(),
|
||||||
|
idMode: state.idMode,
|
||||||
|
zeroPad: state.zeroPad,
|
||||||
|
entries: state.entries.map(function (e) {
|
||||||
|
return {
|
||||||
|
url: e.url,
|
||||||
|
title: e.title || '',
|
||||||
|
videoId: e.videoId || '',
|
||||||
|
durationSec: e.durationSec != null ? e.durationSec : null,
|
||||||
|
thumbnailUrl: e.thumbnailUrl || null,
|
||||||
|
ytId: e.ytId || null,
|
||||||
|
startSec: e.startSec || 0,
|
||||||
|
lengthSec: e.lengthSec != null ? e.lengthSec : DEFAULT_LENGTH
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// idMode 라디오 / zeroPad 토글을 상태에 맞춰 반영.
|
||||||
|
function reflectIdModeControls() {
|
||||||
|
for (var i = 0; i < idModeRadios.length; i++) {
|
||||||
|
idModeRadios[i].checked = idModeRadios[i].value === state.idMode
|
||||||
|
}
|
||||||
|
zeroPadWrap.hidden = state.idMode !== 'sequential'
|
||||||
|
zeroPadToggle.checked = !!state.zeroPad
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDraft(d) {
|
||||||
|
if (!d || typeof d !== 'object') return
|
||||||
|
urlInput.value = d.url || ''
|
||||||
|
state.idMode = d.idMode === 'sequential' ? 'sequential' : 'random'
|
||||||
|
state.zeroPad = !!d.zeroPad
|
||||||
|
var raw = Array.isArray(d.entries) ? d.entries : []
|
||||||
|
state.entries = raw.map(function (e) {
|
||||||
|
return {
|
||||||
|
url: e.url,
|
||||||
|
title: e.title || '',
|
||||||
|
videoId: (e.videoId || '').trim(),
|
||||||
|
durationSec: e.durationSec != null ? e.durationSec : null,
|
||||||
|
thumbnailUrl: e.thumbnailUrl || null,
|
||||||
|
ytId: e.ytId || extractYouTubeId(e.url),
|
||||||
|
startSec: e.startSec || 0,
|
||||||
|
lengthSec: e.lengthSec != null && e.lengthSec > 0 ? e.lengthSec : DEFAULT_LENGTH,
|
||||||
|
idStatus: 'pending',
|
||||||
|
idReason: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
reflectIdModeControls()
|
||||||
|
render()
|
||||||
|
entriesSection.hidden = state.entries.length === 0
|
||||||
|
// 저장된 ID 는 그대로 두고(자동 재생성 금지), 가용성만 다시 확인.
|
||||||
|
for (var i = 0; i < state.entries.length; i++) scheduleIdCheck(i, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDraftButtons(updatedAt) {
|
||||||
|
if (draftLoadBtn) draftLoadBtn.hidden = false
|
||||||
|
if (draftDeleteBtn) draftDeleteBtn.hidden = false
|
||||||
|
setDraftStatus('임시저장됨: ' + formatSavedAt(updatedAt), 'muted')
|
||||||
|
}
|
||||||
|
function hideDraftButtons() {
|
||||||
|
if (draftLoadBtn) draftLoadBtn.hidden = true
|
||||||
|
if (draftDeleteBtn) draftDeleteBtn.hidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draftSaveBtn) {
|
||||||
|
draftSaveBtn.addEventListener('click', function () {
|
||||||
|
draftSaveBtn.disabled = true
|
||||||
|
setDraftStatus('저장 중…', 'muted')
|
||||||
|
fetch(draftUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ data: serializeDraft() })
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json() })
|
||||||
|
.then(function (j) {
|
||||||
|
if (j && j.ok) {
|
||||||
|
showDraftButtons(j.updatedAt)
|
||||||
|
setDraftStatus('임시저장 완료 (' + formatSavedAt(j.updatedAt) + ')', 'idStatusOk')
|
||||||
|
} else {
|
||||||
|
setDraftStatus((j && j.message) || '저장 실패', 'idStatusBad')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
setDraftStatus('저장 실패: ' + (err && err.message ? err.message : err), 'idStatusBad')
|
||||||
|
})
|
||||||
|
.finally(function () { draftSaveBtn.disabled = false })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draftLoadBtn) {
|
||||||
|
draftLoadBtn.addEventListener('click', function () {
|
||||||
|
if (state.entries.length > 0 &&
|
||||||
|
!window.confirm('현재 편집 중인 목록을 임시저장 내용으로 덮어쓸까요?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draftLoadBtn.disabled = true
|
||||||
|
setDraftStatus('불러오는 중…', 'muted')
|
||||||
|
fetch(draftUrl, { cache: 'no-store' })
|
||||||
|
.then(function (r) { return r.json() })
|
||||||
|
.then(function (j) {
|
||||||
|
if (j && j.ok && j.draft) {
|
||||||
|
var data
|
||||||
|
try { data = JSON.parse(j.draft.data) } catch (_) { data = null }
|
||||||
|
if (!data) { setDraftStatus('임시저장 데이터를 읽을 수 없습니다.', 'idStatusBad'); return }
|
||||||
|
restoreDraft(data)
|
||||||
|
setDraftStatus('임시저장 불러옴 (' + formatSavedAt(j.draft.updatedAt) + ')', 'idStatusOk')
|
||||||
|
} else {
|
||||||
|
hideDraftButtons()
|
||||||
|
setDraftStatus('저장된 임시저장이 없습니다.', 'muted')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
setDraftStatus('불러오기 실패: ' + (err && err.message ? err.message : err), 'idStatusBad')
|
||||||
|
})
|
||||||
|
.finally(function () { draftLoadBtn.disabled = false })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draftDeleteBtn) {
|
||||||
|
draftDeleteBtn.addEventListener('click', function () {
|
||||||
|
if (!window.confirm('저장된 임시저장을 삭제할까요?')) return
|
||||||
|
draftDeleteBtn.disabled = true
|
||||||
|
fetch(draftDeleteUrl, { method: 'POST' })
|
||||||
|
.then(function (r) { return r.json() })
|
||||||
|
.then(function (j) {
|
||||||
|
if (j && j.ok) {
|
||||||
|
hideDraftButtons()
|
||||||
|
setDraftStatus('임시저장을 삭제했습니다.', 'muted')
|
||||||
|
} else {
|
||||||
|
setDraftStatus((j && j.message) || '삭제 실패', 'idStatusBad')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
setDraftStatus('삭제 실패: ' + (err && err.message ? err.message : err), 'idStatusBad')
|
||||||
|
})
|
||||||
|
.finally(function () { draftDeleteBtn.disabled = false })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 진입 시 기존 임시저장 여부 확인 → 있으면 불러오기/삭제 버튼 노출.
|
||||||
|
fetch(draftUrl, { cache: 'no-store' })
|
||||||
|
.then(function (r) { return r.json() })
|
||||||
|
.then(function (j) {
|
||||||
|
if (j && j.ok && j.draft) showDraftButtons(j.draft.updatedAt)
|
||||||
|
})
|
||||||
|
.catch(function () {})
|
||||||
|
|
||||||
// 초기 상태
|
// 초기 상태
|
||||||
updateRegisterEnable()
|
updateRegisterEnable()
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -102,6 +102,25 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
|
|||||||
.folderIcon { font-size: 40px; }
|
.folderIcon { font-size: 40px; }
|
||||||
.folderName { font-size: 15px; word-break: break-all; text-align: center; }
|
.folderName { font-size: 15px; word-break: break-all; text-align: center; }
|
||||||
|
|
||||||
|
/* tools (ffmpeg / yt-dlp) */
|
||||||
|
.toolsSection { margin-top: 40px; }
|
||||||
|
.toolsSection h2 { margin-bottom: 6px; }
|
||||||
|
.toolsSection .muted { margin-bottom: 16px; }
|
||||||
|
.toolGrid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.toolCard {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 16px; display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.toolHead { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
|
||||||
|
.toolName { font-size: 16px; font-weight: 600; }
|
||||||
|
.toolVersion { font-size: 13px; color: var(--accent); text-align: right; }
|
||||||
|
.toolPath { font-size: 12px; word-break: break-all; }
|
||||||
|
.toolRow { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.toolStatus { font-size: 12px; }
|
||||||
|
|
||||||
/* video grid */
|
/* video grid */
|
||||||
.videoGrid {
|
.videoGrid {
|
||||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
@@ -115,10 +134,16 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
|
|||||||
}
|
}
|
||||||
.videoCard:hover { border-color: var(--accent); }
|
.videoCard:hover { border-color: var(--accent); }
|
||||||
.videoThumb {
|
.videoThumb {
|
||||||
|
position: relative;
|
||||||
aspect-ratio: 16/9; background: linear-gradient(135deg, #1f242c, #0d1117);
|
aspect-ratio: 16/9; background: linear-gradient(135deg, #1f242c, #0d1117);
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
font-size: 36px; color: var(--accent);
|
font-size: 36px; color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.videoThumbImg {
|
||||||
|
position: absolute; inset: 0; width: 100%; height: 100%;
|
||||||
|
object-fit: cover; display: block; background: #0d1117; z-index: 1;
|
||||||
|
}
|
||||||
|
.videoThumbPlay { position: relative; z-index: 0; }
|
||||||
.videoTitle { padding: 10px 12px; font-size: 14px; }
|
.videoTitle { padding: 10px 12px; font-size: 14px; }
|
||||||
|
|
||||||
/* login */
|
/* login */
|
||||||
@@ -331,6 +356,9 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
|
|||||||
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
||||||
color: var(--text); padding: 8px 12px; font-size: 14px;
|
color: var(--text); padding: 8px 12px; font-size: 14px;
|
||||||
}
|
}
|
||||||
|
.playlistDraftRow {
|
||||||
|
display: flex; gap: 10px; margin-top: 12px; align-items: center; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.playlistEntriesSection {
|
.playlistEntriesSection {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
@@ -357,7 +385,7 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
|
|||||||
}
|
}
|
||||||
.playlistEntryRow {
|
.playlistEntryRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 26px 36px 96px 1fr 70px 28px;
|
grid-template-columns: 24px 28px 90px minmax(0, 1fr) 210px 60px 26px;
|
||||||
align-items: center; gap: 10px;
|
align-items: center; gap: 10px;
|
||||||
background: var(--bg-card); border: 1px solid var(--border);
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
border-radius: 10px; padding: 8px 10px;
|
border-radius: 10px; padding: 8px 10px;
|
||||||
@@ -372,10 +400,18 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
|
|||||||
color: var(--text-muted); font-size: 13px; text-align: right;
|
color: var(--text-muted); font-size: 13px; text-align: right;
|
||||||
}
|
}
|
||||||
.entryThumb {
|
.entryThumb {
|
||||||
width: 96px; aspect-ratio: 16 / 9;
|
width: 90px; aspect-ratio: 16 / 9;
|
||||||
border-radius: 6px; overflow: hidden;
|
border-radius: 6px; overflow: hidden; position: relative;
|
||||||
background: var(--bg); border: 1px solid var(--border);
|
background: var(--bg); border: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.entryThumb::after {
|
||||||
|
content: '▶'; position: absolute; inset: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: #fff; font-size: 18px; text-shadow: 0 1px 4px rgba(0,0,0,0.7);
|
||||||
|
opacity: 0; transition: opacity 120ms ease; pointer-events: none;
|
||||||
|
}
|
||||||
|
.entryThumb:hover::after { opacity: 1; }
|
||||||
.entryThumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
.entryThumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
.entryThumbEmpty { opacity: 0.6; }
|
.entryThumbEmpty { opacity: 0.6; }
|
||||||
.entryFields {
|
.entryFields {
|
||||||
@@ -406,7 +442,85 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
|
|||||||
}
|
}
|
||||||
.entryRemove:hover { color: #e57373; border-color: #e57373; }
|
.entryRemove:hover { color: #e57373; border-color: #e57373; }
|
||||||
|
|
||||||
|
/* 항목별 시작/종료 시간 입력 (제목 오른쪽 절반 공간) */
|
||||||
|
.entryTrim {
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}
|
||||||
|
.entryTrim label {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 12px; color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.entryTrim input {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
color: var(--text); padding: 3px 6px; font-size: 12px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 일괄 시작/종료 + 적용/초기화 */
|
||||||
|
.bulkTrimBox {
|
||||||
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.bulkTrimBox label {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
color: var(--text); font-size: 13px;
|
||||||
|
}
|
||||||
|
.bulkTrimBox input {
|
||||||
|
width: 70px;
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
color: var(--text); padding: 4px 8px; font-size: 13px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* YouTube 임베드 (미리보기 / 자르기 모달) */
|
||||||
|
.ytEmbedWrap {
|
||||||
|
position: relative; width: 100%; aspect-ratio: 16 / 9;
|
||||||
|
background: #000; border-radius: 8px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.ytEmbedWrap iframe, .ytEmbedWrap > div {
|
||||||
|
position: absolute; inset: 0; width: 100%; height: 100%; border: 0;
|
||||||
|
}
|
||||||
|
.trimModal { width: min(900px, 94vw); gap: 12px; }
|
||||||
|
.trimModal .trimTimeline { background: var(--bg-alt); }
|
||||||
|
|
||||||
.playlistFooter {
|
.playlistFooter {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
gap: 12px; flex-wrap: wrap; margin-top: 8px;
|
gap: 12px; flex-wrap: wrap; margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── 플레이리스트 다운로드 진행 화면 ─────────────────────────────── */
|
||||||
|
.playlistProgressSection {
|
||||||
|
margin-top: 18px;
|
||||||
|
display: flex; flex-direction: column; gap: 14px;
|
||||||
|
}
|
||||||
|
.progressHeader {
|
||||||
|
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.progressHeader #progressSummary { font-size: 14px; color: var(--text); }
|
||||||
|
.progressHeader #progressOverall { flex: 1; min-width: 200px; height: 12px; }
|
||||||
|
.progressList { list-style: none; margin: 0; padding: 0;
|
||||||
|
display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.progressRow {
|
||||||
|
display: grid; grid-template-columns: 1fr 180px auto auto; align-items: center;
|
||||||
|
gap: 12px; background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.progressName {
|
||||||
|
color: var(--text); font-size: 14px;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.progressBar { width: 100%; height: 10px; }
|
||||||
|
.progressBar.progressBarError::-webkit-progress-value { background: #e5534b; }
|
||||||
|
.progressMsg { font-size: 12px; white-space: nowrap; text-align: right; }
|
||||||
|
.progressRetry { padding: 4px 12px; font-size: 12px; white-space: nowrap; justify-self: end; }
|
||||||
|
.progressRowDone { border-color: #2ea043; }
|
||||||
|
.progressRowError { border-color: #e5534b; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.progressRow { grid-template-columns: 1fr; gap: 6px; }
|
||||||
|
.progressMsg { text-align: left; }
|
||||||
|
.progressRetry { justify-self: start; }
|
||||||
|
}
|
||||||
|
|||||||
89
src/cookies.ts
Normal file
89
src/cookies.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* YouTube 로그인 쿠키(Netscape `cookies.txt`) 관리.
|
||||||
|
*
|
||||||
|
* 동기:
|
||||||
|
* - 연령 제한("Sign in to confirm your age") 영상은 로그인 세션 없이는 yt-dlp 가
|
||||||
|
* 받지 못한다. 브라우저에서 내보낸 Netscape cookies.txt 를 `--cookies` 로 넘기면
|
||||||
|
* 그 계정 권한으로 다운로드된다.
|
||||||
|
* - 쿠키에는 세션 토큰이 들어 있어 민감하므로 data 볼륨 하위에 0600 으로 보관한다.
|
||||||
|
* (data 볼륨이라 컨테이너 재생성에도 유지된다.)
|
||||||
|
*/
|
||||||
|
import { promises as fs, existsSync, statSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { dataDir } from './paths.js'
|
||||||
|
|
||||||
|
/** 등록된 쿠키 파일 경로 (data 볼륨 하위, 영속). */
|
||||||
|
export const cookiesPath = path.join(dataDir, 'cookies.txt')
|
||||||
|
|
||||||
|
export interface CookiesStatus {
|
||||||
|
present: boolean
|
||||||
|
bytes: number
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 현재 등록된 쿠키 상태 (UI 표시용). */
|
||||||
|
export function cookiesStatus(): CookiesStatus {
|
||||||
|
try {
|
||||||
|
const st = statSync(cookiesPath)
|
||||||
|
if (!st.isFile() || st.size === 0) return { present: false, bytes: 0, updatedAt: null }
|
||||||
|
return { present: true, bytes: st.size, updatedAt: st.mtime.toISOString() }
|
||||||
|
} catch {
|
||||||
|
return { present: false, bytes: 0, updatedAt: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* yt-dlp 에 넘길 쿠키 인자. 등록돼 있고 비어 있지 않을 때만 `['--cookies', 경로]`,
|
||||||
|
* 아니면 빈 배열. spawn 직전에 동기로 호출하므로 존재 여부만 빠르게 확인한다.
|
||||||
|
*/
|
||||||
|
export function getCookieArgs(): string[] {
|
||||||
|
try {
|
||||||
|
if (existsSync(cookiesPath) && statSync(cookiesPath).size > 0) {
|
||||||
|
return ['--cookies', cookiesPath]
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 무시 — 쿠키 없이 진행 */
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 등록된 쿠키가 있는지 (에러 메시지 분기용). */
|
||||||
|
export function hasCookies(): boolean {
|
||||||
|
return getCookieArgs().length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Netscape cookies.txt 로 보이는지 가볍게 검증한다. 사용자가 실수로 HTML/JSON 등
|
||||||
|
* 엉뚱한 파일을 올리면 조용히 저장돼 다운로드가 계속 실패하므로 미리 막는다.
|
||||||
|
* - `# Netscape HTTP Cookie File` 헤더가 있거나
|
||||||
|
* - 탭으로 7개 필드가 나뉜 쿠키 라인이 한 줄이라도 있으면 통과.
|
||||||
|
*/
|
||||||
|
function looksLikeNetscapeCookies(text: string): boolean {
|
||||||
|
if (/^#\s*(Netscape\s+)?HTTP\s+Cookie\s+File/im.test(text)) return true
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
if (!line || line.startsWith('#')) continue
|
||||||
|
if (line.split('\t').length >= 7) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 쿠키 내용을 검증 후 0600 으로 저장한다. */
|
||||||
|
export async function saveCookies(content: string): Promise<CookiesStatus> {
|
||||||
|
const text = content.replace(/\r\n/g, '\n')
|
||||||
|
if (!text.trim()) throw new Error('쿠키 파일이 비어 있습니다.')
|
||||||
|
if (!looksLikeNetscapeCookies(text)) {
|
||||||
|
throw new Error(
|
||||||
|
'Netscape 형식 cookies.txt 가 아닙니다. 브라우저 확장(Get cookies.txt 등)으로 내보낸 파일을 올려 주세요.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await fs.mkdir(dataDir, { recursive: true })
|
||||||
|
await fs.writeFile(cookiesPath, text, { mode: 0o600 })
|
||||||
|
// 기존 파일이 더 느슨한 권한이었을 수 있으니 명시적으로 다시 조인다.
|
||||||
|
await fs.chmod(cookiesPath, 0o600).catch(() => {})
|
||||||
|
return cookiesStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 등록된 쿠키를 삭제한다. 없으면 그냥 통과. */
|
||||||
|
export async function deleteCookies(): Promise<void> {
|
||||||
|
await fs.rm(cookiesPath, { force: true })
|
||||||
|
}
|
||||||
76
src/db.ts
76
src/db.ts
@@ -14,7 +14,8 @@
|
|||||||
* 폴더 깊이 제약: 최상위(parent_id IS NULL) 또는 그 자식(서브폴더) 까지만.
|
* 폴더 깊이 제약: 최상위(parent_id IS NULL) 또는 그 자식(서브폴더) 까지만.
|
||||||
* 서브폴더는 자식 폴더를 가질 수 없다. 코드 레벨에서 검사.
|
* 서브폴더는 자식 폴더를 가질 수 없다. 코드 레벨에서 검사.
|
||||||
*
|
*
|
||||||
* 영상 ID: 사용자 편집 가능. 전역 유일 (PRIMARY KEY). 변경 시 디렉토리 이름도 같이 바뀐다.
|
* 영상 ID: 사용자 편집 가능. 폴더 안에서만 유일 (UNIQUE(folder_id, id)).
|
||||||
|
* 서로 다른 폴더면 같은 ID(예: 1,2,3,4)를 써도 된다. 변경 시 디렉토리 이름도 같이 바뀐다.
|
||||||
*/
|
*/
|
||||||
import Database from 'better-sqlite3'
|
import Database from 'better-sqlite3'
|
||||||
import { promises as fsp } from 'node:fs'
|
import { promises as fsp } from 'node:fs'
|
||||||
@@ -43,6 +44,7 @@ export async function initDatabase(): Promise<void> {
|
|||||||
db.pragma('synchronous = NORMAL')
|
db.pragma('synchronous = NORMAL')
|
||||||
|
|
||||||
createSchema(db)
|
createSchema(db)
|
||||||
|
migrateVideosSchema(db)
|
||||||
_db = db
|
_db = db
|
||||||
|
|
||||||
// 비어 있으면 디스크 스캔 → 마이그레이션.
|
// 비어 있으면 디스크 스캔 → 마이그레이션.
|
||||||
@@ -70,7 +72,7 @@ function createSchema(db: Database.Database): void {
|
|||||||
ON folders(name) WHERE parent_id IS NULL;
|
ON folders(name) WHERE parent_id IS NULL;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS videos (
|
CREATE TABLE IF NOT EXISTS videos (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT NOT NULL,
|
||||||
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
original_file TEXT NOT NULL,
|
original_file TEXT NOT NULL,
|
||||||
@@ -82,12 +84,80 @@ function createSchema(db: Database.Database): void {
|
|||||||
trim_end_sec REAL,
|
trim_end_sec REAL,
|
||||||
position INTEGER NOT NULL DEFAULT 0,
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(folder_id, id)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS videos_folder_idx ON videos(folder_id);
|
CREATE INDEX IF NOT EXISTS videos_folder_idx ON videos(folder_id);
|
||||||
|
|
||||||
|
-- 플레이리스트 추가 화면의 "임시저장": 폴더당 1개의 작업중 상태(JSON)를 보관한다.
|
||||||
|
-- 다운로드 시작 전에 수정해둔 값(플레이리스트 URL + 항목별 제목/ID/구간 등)을
|
||||||
|
-- 저장해 두었다가 나중에 불러온다.
|
||||||
|
CREATE TABLE IF NOT EXISTS playlist_drafts (
|
||||||
|
folder_id INTEGER PRIMARY KEY REFERENCES folders(id) ON DELETE CASCADE,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 구(舊) 스키마(videos.id 가 전역 PRIMARY KEY)를 신(新) 스키마(UNIQUE(folder_id, id))로
|
||||||
|
* 마이그레이션한다. createSchema 의 CREATE TABLE IF NOT EXISTS 는 이미 존재하는 테이블을
|
||||||
|
* 건드리지 않으므로, 운영 DB 의 기존 videos 테이블은 이 함수로만 바뀐다.
|
||||||
|
*
|
||||||
|
* 판별: PRAGMA table_info(videos) 에서 id 컬럼의 pk 가 1 이면 구 스키마.
|
||||||
|
* 기존 id 는 전역 유일이므로 (folder_id, id) 로 옮겨도 충돌이 없다.
|
||||||
|
*/
|
||||||
|
function migrateVideosSchema(db: Database.Database): void {
|
||||||
|
const cols = db.prepare(`PRAGMA table_info(videos)`).all() as Array<{
|
||||||
|
name: string
|
||||||
|
pk: number
|
||||||
|
}>
|
||||||
|
const idCol = cols.find((c) => c.name === 'id')
|
||||||
|
// 테이블이 없거나(첫 생성), id 가 PK 가 아니면(이미 신 스키마) 할 일 없음.
|
||||||
|
if (!idCol || idCol.pk === 0) return
|
||||||
|
|
||||||
|
db.pragma('foreign_keys = OFF')
|
||||||
|
try {
|
||||||
|
const rebuild = db.transaction(() => {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE videos_new (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
original_file TEXT NOT NULL,
|
||||||
|
edited_file TEXT,
|
||||||
|
duration_sec REAL,
|
||||||
|
source_type TEXT NOT NULL CHECK (source_type IN ('upload', 'youtube')),
|
||||||
|
source_url TEXT,
|
||||||
|
trim_start_sec REAL,
|
||||||
|
trim_end_sec REAL,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(folder_id, id)
|
||||||
|
);
|
||||||
|
INSERT INTO videos_new
|
||||||
|
(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)
|
||||||
|
SELECT
|
||||||
|
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
|
||||||
|
FROM videos;
|
||||||
|
DROP TABLE videos;
|
||||||
|
ALTER TABLE videos_new RENAME TO videos;
|
||||||
|
CREATE INDEX IF NOT EXISTS videos_folder_idx ON videos(folder_id);
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
rebuild()
|
||||||
|
console.log('[db] videos 스키마 마이그레이션 완료: UNIQUE(folder_id, id)')
|
||||||
|
} finally {
|
||||||
|
db.pragma('foreign_keys = ON')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 기존 디스크 트리 (data/folders/{name}/{videoId}/meta.json) 를 스캔해서 DB 에 적재.
|
* 기존 디스크 트리 (data/folders/{name}/{videoId}/meta.json) 를 스캔해서 DB 에 적재.
|
||||||
* 기존은 1단계(폴더→영상) 만 있으므로 서브폴더는 생기지 않는다.
|
* 기존은 1단계(폴더→영상) 만 있으므로 서브폴더는 생기지 않는다.
|
||||||
|
|||||||
136
src/editor.ts
136
src/editor.ts
@@ -1,7 +1,9 @@
|
|||||||
import { spawn, spawnSync } from 'node:child_process'
|
import { spawn, spawnSync } from 'node:child_process'
|
||||||
import { promises as fs, existsSync } from 'node:fs'
|
import { promises as fs, existsSync } from 'node:fs'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { getVideo, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js'
|
import { getVideoInFolder, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js'
|
||||||
|
import { binDir } from './paths.js'
|
||||||
|
|
||||||
export class FfmpegUnavailableError extends Error {
|
export class FfmpegUnavailableError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -9,21 +11,53 @@ export class FfmpegUnavailableError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 바이너리 해석: data/bin 에 재설치본이 있으면 그것을 우선 쓰고,
|
||||||
|
* 없으면 PATH 에서 찾는다. 둘 다 없으면 null.
|
||||||
|
* (win32 면 .exe 후보도 본다.)
|
||||||
|
*/
|
||||||
|
function resolveBinary(name: string): string | null {
|
||||||
|
const candidates =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? [path.join(binDir, name + '.exe'), path.join(binDir, name), name]
|
||||||
|
: [path.join(binDir, name), name]
|
||||||
|
for (const cand of candidates) {
|
||||||
|
const r = spawnSync(cand, ['-version'])
|
||||||
|
if (r.status === 0) return cand
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
let resolvedFfmpegPath: string | null = null
|
let resolvedFfmpegPath: string | null = null
|
||||||
|
let resolvedFfprobePath: string | null = null
|
||||||
|
|
||||||
export function getFfmpegPath(): string {
|
export function getFfmpegPath(): string {
|
||||||
if (resolvedFfmpegPath) return resolvedFfmpegPath
|
if (resolvedFfmpegPath) return resolvedFfmpegPath
|
||||||
const r = spawnSync('ffmpeg', ['-version'])
|
const p = resolveBinary('ffmpeg')
|
||||||
if (r.status === 0) {
|
if (!p) throw new FfmpegUnavailableError()
|
||||||
resolvedFfmpegPath = 'ffmpeg'
|
resolvedFfmpegPath = p
|
||||||
return 'ffmpeg'
|
return p
|
||||||
}
|
}
|
||||||
throw new FfmpegUnavailableError()
|
|
||||||
|
/** ffprobe 경로. data/bin 우선, 없으면 PATH. 못 찾으면 null (호출부에서 graceful 처리). */
|
||||||
|
function getFfprobePath(): string | null {
|
||||||
|
if (resolvedFfprobePath) return resolvedFfprobePath
|
||||||
|
const p = resolveBinary('ffprobe')
|
||||||
|
if (p) resolvedFfprobePath = p
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
/** data/bin 에 새 바이너리를 설치한 뒤 캐시된 해석 결과를 비운다 (다음 호출에서 재탐색). */
|
||||||
|
export function resetFfmpegResolution(): void {
|
||||||
|
resolvedFfmpegPath = null
|
||||||
|
resolvedFfprobePath = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 입력 영상의 평균 fps 를 ffprobe 로 조회. 실패하면 null. */
|
/** 입력 영상의 평균 fps 를 ffprobe 로 조회. 실패하면 null. */
|
||||||
export function probeVideoFps(inputPath: string): number | null {
|
export function probeVideoFps(inputPath: string): number | null {
|
||||||
const r = spawnSync('ffprobe', [
|
const probe = getFfprobePath()
|
||||||
|
if (!probe) return null
|
||||||
|
const r = spawnSync(probe, [
|
||||||
'-v', 'error',
|
'-v', 'error',
|
||||||
'-select_streams', 'v:0',
|
'-select_streams', 'v:0',
|
||||||
'-show_entries', 'stream=avg_frame_rate',
|
'-show_entries', 'stream=avg_frame_rate',
|
||||||
@@ -55,14 +89,16 @@ function probeVideoMeta(inputPath: string): {
|
|||||||
height: number | null
|
height: number | null
|
||||||
durationSec: number | null
|
durationSec: number | null
|
||||||
} {
|
} {
|
||||||
const r = spawnSync('ffprobe', [
|
const empty = { fps: null, width: null, height: null, durationSec: null }
|
||||||
|
const probe = getFfprobePath()
|
||||||
|
if (!probe) return empty
|
||||||
|
const r = spawnSync(probe, [
|
||||||
'-v', 'error',
|
'-v', 'error',
|
||||||
'-select_streams', 'v:0',
|
'-select_streams', 'v:0',
|
||||||
'-show_entries', 'stream=avg_frame_rate,width,height:format=duration',
|
'-show_entries', 'stream=avg_frame_rate,width,height:format=duration',
|
||||||
'-of', 'default=noprint_wrappers=1',
|
'-of', 'default=noprint_wrappers=1',
|
||||||
inputPath
|
inputPath
|
||||||
])
|
])
|
||||||
const empty = { fps: null, width: null, height: null, durationSec: null }
|
|
||||||
if (r.status !== 0) return empty
|
if (r.status !== 0) return empty
|
||||||
const fields: Record<string, string> = {}
|
const fields: Record<string, string> = {}
|
||||||
for (const line of String(r.stdout).split(/\r?\n/)) {
|
for (const line of String(r.stdout).split(/\r?\n/)) {
|
||||||
@@ -300,7 +336,8 @@ export async function upscaleOriginalTo60Fps(
|
|||||||
// 재인코딩 결과는 항상 mp4 로 통일 (소스가 webm/mkv 여도).
|
// 재인코딩 결과는 항상 mp4 로 통일 (소스가 webm/mkv 여도).
|
||||||
const outName = 'original.mp4'
|
const outName = 'original.mp4'
|
||||||
const outPath = path.join(dir, outName)
|
const outPath = path.join(dir, outName)
|
||||||
const tmpPath = path.join(dir, 'original.bump.tmp.mp4')
|
// 동시 호출이 같은 tmp 파일을 덮어쓰지 않도록 호출마다 고유한 임시 경로를 쓴다.
|
||||||
|
const tmpPath = path.join(dir, `original.bump.${randomUUID()}.tmp.mp4`)
|
||||||
|
|
||||||
// vfilter 조립:
|
// vfilter 조립:
|
||||||
// - fps=60 : motion interpolation 대신 단순 프레임 복제 (속도 우선).
|
// - fps=60 : motion interpolation 대신 단순 프레임 복제 (속도 우선).
|
||||||
@@ -373,20 +410,22 @@ export async function upscaleOriginalTo60Fps(
|
|||||||
* stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩.
|
* stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩.
|
||||||
*/
|
*/
|
||||||
export async function applyTrimToVideo(
|
export async function applyTrimToVideo(
|
||||||
|
folderId: number,
|
||||||
videoId: string,
|
videoId: string,
|
||||||
trim: VideoTrim
|
trim: VideoTrim
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const bin = getFfmpegPath()
|
const bin = getFfmpegPath()
|
||||||
const meta = getVideo(videoId)
|
const meta = getVideoInFolder(folderId, videoId)
|
||||||
if (!meta) throw new Error('비디오를 찾을 수 없습니다.')
|
if (!meta) throw new Error('비디오를 찾을 수 없습니다.')
|
||||||
const dir = videoDiskDir(videoId)
|
const dir = videoDiskDir(folderId, videoId)
|
||||||
const inputPath = path.join(dir, meta.originalFile)
|
const inputPath = path.join(dir, meta.originalFile)
|
||||||
await fs.access(inputPath)
|
await fs.access(inputPath)
|
||||||
|
|
||||||
const ext = path.extname(meta.originalFile) || '.mp4'
|
const ext = path.extname(meta.originalFile) || '.mp4'
|
||||||
const outName = `edited${ext}`
|
const outName = `edited${ext}`
|
||||||
const outPath = path.join(dir, outName)
|
const outPath = path.join(dir, outName)
|
||||||
const tmpPath = outPath + '.tmp' + ext
|
// 동시 호출이 같은 tmp 파일을 덮어쓰지 않도록 호출마다 고유한 임시 경로를 쓴다.
|
||||||
|
const tmpPath = `${outPath}.${randomUUID()}.tmp${ext}`
|
||||||
|
|
||||||
const startSec = Math.max(0, Number(trim.startSec) || 0)
|
const startSec = Math.max(0, Number(trim.startSec) || 0)
|
||||||
const endSec = trim.endSec == null ? null : Math.max(startSec, Number(trim.endSec))
|
const endSec = trim.endSec == null ? null : Math.max(startSec, Number(trim.endSec))
|
||||||
@@ -441,13 +480,80 @@ export async function applyTrimToVideo(
|
|||||||
}
|
}
|
||||||
await fs.rename(tmpPath, outPath)
|
await fs.rename(tmpPath, outPath)
|
||||||
|
|
||||||
updateVideo(videoId, {
|
updateVideo(folderId, videoId, {
|
||||||
editedFile: outName,
|
editedFile: outName,
|
||||||
trim: { startSec, endSec }
|
trim: { startSec, endSec }
|
||||||
})
|
})
|
||||||
return outName
|
return outName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 썸네일 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const THUMB_NAME = 'thumb.jpg'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 영상 디렉토리에 thumb.jpg 가 없으면 ffmpeg 로 한 장 추출해 만든다.
|
||||||
|
* - 편집본이 있으면 편집본, 없으면 원본에서 1초 지점 프레임을 뽑는다.
|
||||||
|
* (1초가 영상보다 길면 0초로 재시도.)
|
||||||
|
* - 폭 480 으로 다운스케일 (카드 썸네일용).
|
||||||
|
* - ffmpeg 없음 / 소스 없음 / 실패 시 null 반환 → 호출자가 404 로 응답하고
|
||||||
|
* 카드에서는 ▶ placeholder 로 폴백.
|
||||||
|
* 한 번 만들면 파일로 캐시되어 다음 요청부터는 즉시 응답.
|
||||||
|
*/
|
||||||
|
export async function ensureThumbnail(folderId: number, videoId: string): Promise<string | null> {
|
||||||
|
const meta = getVideoInFolder(folderId, videoId)
|
||||||
|
if (!meta) return null
|
||||||
|
const dir = videoDiskDir(folderId, videoId)
|
||||||
|
const thumbPath = path.join(dir, THUMB_NAME)
|
||||||
|
try {
|
||||||
|
await fs.access(thumbPath)
|
||||||
|
return thumbPath
|
||||||
|
} catch {
|
||||||
|
/* 없으면 아래에서 생성 */
|
||||||
|
}
|
||||||
|
const sourceName = meta.editedFile || meta.originalFile
|
||||||
|
if (!sourceName || sourceName.includes('%(ext)s')) return null
|
||||||
|
const inputPath = path.join(dir, sourceName)
|
||||||
|
try {
|
||||||
|
await fs.access(inputPath)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
let bin: string
|
||||||
|
try {
|
||||||
|
bin = getFfmpegPath()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// 동시 호출이 같은 tmp 파일을 덮어쓰지 않도록 호출마다 고유한 임시 경로를 쓴다.
|
||||||
|
// (두 명이 같은 영상을 동시에 열면 thumb 가 아직 없을 때 generation 이 겹쳐
|
||||||
|
// 서로의 tmp 를 덮어써 일부 요청이 헛되이 실패하던 레이스를 막는다.)
|
||||||
|
const tmpPath = `${thumbPath}.${randomUUID()}.tmp.jpg`
|
||||||
|
const vf = "scale='min(480,iw)':-2"
|
||||||
|
// -ss 1 (1초 지점). 영상이 1초보다 짧으면 프레임이 안 나와 실패하므로 0초로 재시도.
|
||||||
|
let ok = await runFfmpeg(bin, [
|
||||||
|
'-y', '-ss', '1', '-i', inputPath,
|
||||||
|
'-frames:v', '1', '-vf', vf, '-q:v', '4', tmpPath
|
||||||
|
])
|
||||||
|
if (!ok) {
|
||||||
|
ok = await runFfmpeg(bin, [
|
||||||
|
'-y', '-i', inputPath,
|
||||||
|
'-frames:v', '1', '-vf', vf, '-q:v', '4', tmpPath
|
||||||
|
])
|
||||||
|
}
|
||||||
|
if (!ok) {
|
||||||
|
await fs.unlink(tmpPath).catch(() => undefined)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fs.rename(tmpPath, thumbPath)
|
||||||
|
} catch {
|
||||||
|
await fs.unlink(tmpPath).catch(() => undefined)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return thumbPath
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HW 인코더로 한 번 시도하고, 실패하면 같은 잡에 한해 libx264 ultrafast 로 재시도.
|
* HW 인코더로 한 번 시도하고, 실패하면 같은 잡에 한해 libx264 ultrafast 로 재시도.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export const foldersDir = path.join(dataDir, 'folders')
|
|||||||
export const jobsDir = path.join(dataDir, 'jobs')
|
export const jobsDir = path.join(dataDir, 'jobs')
|
||||||
export const tmpDir = path.join(dataDir, 'tmp')
|
export const tmpDir = path.join(dataDir, 'tmp')
|
||||||
export const dbPath = path.join(dataDir, 'app.db')
|
export const dbPath = path.join(dataDir, 'app.db')
|
||||||
|
// 런타임에 최신 버전으로 재설치한 ffmpeg/yt-dlp 바이너리를 두는 곳.
|
||||||
|
// data 볼륨 하위라 컨테이너를 재생성해도 유지된다. 바이너리 해석 시 PATH 보다 우선.
|
||||||
|
export const binDir = path.join(dataDir, 'bin')
|
||||||
|
|
||||||
export const viewsDir = path.join(projectRoot, 'views')
|
export const viewsDir = path.join(projectRoot, 'views')
|
||||||
export const publicDir = path.join(projectRoot, 'public')
|
export const publicDir = path.join(projectRoot, 'public')
|
||||||
|
|||||||
263
src/routes/op.ts
263
src/routes/op.ts
@@ -13,7 +13,8 @@ import {
|
|||||||
folderPathNames,
|
folderPathNames,
|
||||||
getFolder,
|
getFolder,
|
||||||
getFolderByPath,
|
getFolderByPath,
|
||||||
getVideo,
|
getVideoInFolder,
|
||||||
|
getVideoByIdGlobal,
|
||||||
isSafeVideoId,
|
isSafeVideoId,
|
||||||
listChildFolders,
|
listChildFolders,
|
||||||
listTopFolders,
|
listTopFolders,
|
||||||
@@ -21,7 +22,10 @@ import {
|
|||||||
moveUploadIntoVideoDir,
|
moveUploadIntoVideoDir,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
updateVideo,
|
updateVideo,
|
||||||
videoIdExists
|
videoExistsInFolder,
|
||||||
|
savePlaylistDraft,
|
||||||
|
getPlaylistDraft,
|
||||||
|
deletePlaylistDraft
|
||||||
} from '../storeDb.js'
|
} from '../storeDb.js'
|
||||||
import { tmpDir } from '../paths.js'
|
import { tmpDir } from '../paths.js'
|
||||||
import {
|
import {
|
||||||
@@ -29,10 +33,14 @@ import {
|
|||||||
getJob,
|
getJob,
|
||||||
probeYoutube,
|
probeYoutube,
|
||||||
probeYoutubePlaylist,
|
probeYoutubePlaylist,
|
||||||
startYoutubeDownload
|
startYoutubeDownload,
|
||||||
|
retryDownloadJob
|
||||||
} from '../youtube.js'
|
} from '../youtube.js'
|
||||||
import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js'
|
import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js'
|
||||||
|
import { getToolsStatus, updateFfmpeg, updateYtDlp } from '../tools.js'
|
||||||
|
import { cookiesStatus, saveCookies, deleteCookies } from '../cookies.js'
|
||||||
import { collectFolderSegments } from './helpers.js'
|
import { collectFolderSegments } from './helpers.js'
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
|
||||||
export const opRouter = Router()
|
export const opRouter = Router()
|
||||||
|
|
||||||
@@ -57,6 +65,12 @@ const upload = multer({
|
|||||||
limits: { fileSize: uploadMaxBytes }
|
limits: { fileSize: uploadMaxBytes }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 쿠키 파일은 작다 — 별도 multer 로 1 MiB 로 제한해 대형 파일 오용을 막는다.
|
||||||
|
const cookieUpload = multer({
|
||||||
|
dest: tmpDir,
|
||||||
|
limits: { fileSize: 1024 * 1024 }
|
||||||
|
})
|
||||||
|
|
||||||
function pickStr(v: unknown): string {
|
function pickStr(v: unknown): string {
|
||||||
if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : ''
|
if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : ''
|
||||||
return typeof v === 'string' ? v : ''
|
return typeof v === 'string' ? v : ''
|
||||||
@@ -119,6 +133,55 @@ opRouter.get('/op/dashboard', requireAuth, (req, res, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── 외부 도구(ffmpeg/yt-dlp) 상태 + 최신 버전 재설치 ───────────────────
|
||||||
|
opRouter.get('/op/tools', requireAuth, (req, res) => {
|
||||||
|
res.json({ ok: true, tools: getToolsStatus() })
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op/tools/:name/update', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const name = req.params.name
|
||||||
|
let tool
|
||||||
|
if (name === 'yt-dlp') tool = await updateYtDlp()
|
||||||
|
else if (name === 'ffmpeg') tool = await updateFfmpeg()
|
||||||
|
else {
|
||||||
|
res.status(400).json({ ok: false, message: '알 수 없는 도구입니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.json({ ok: true, tool })
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, message: (err as Error).message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── YouTube 로그인 쿠키 (연령 제한 영상 다운로드용) ───────────────────────
|
||||||
|
opRouter.get('/op/cookies', requireAuth, (req, res) => {
|
||||||
|
res.json({ ok: true, cookies: cookiesStatus() })
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op/cookies', requireAuth, cookieUpload.single('file'), async (req, res) => {
|
||||||
|
const file = req.file
|
||||||
|
try {
|
||||||
|
if (!file) throw new Error('cookies.txt 파일을 선택해 주세요.')
|
||||||
|
const content = await fs.readFile(file.path, 'utf8')
|
||||||
|
const status = await saveCookies(content)
|
||||||
|
res.json({ ok: true, cookies: status })
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||||
|
} finally {
|
||||||
|
if (file) await fs.rm(file.path, { force: true }).catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op/cookies/delete', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await deleteCookies()
|
||||||
|
res.json({ ok: true, cookies: cookiesStatus() })
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function folderViewHandler(req: Request, res: Response, next: NextFunction): void {
|
function folderViewHandler(req: Request, res: Response, next: NextFunction): void {
|
||||||
try {
|
try {
|
||||||
const segments = collectFolderSegments(req.params)
|
const segments = collectFolderSegments(req.params)
|
||||||
@@ -202,7 +265,7 @@ opRouter.get(
|
|||||||
}
|
}
|
||||||
// 영상은 1단계·2단계 폴더 어디서나 추가할 수 있다. (플레이리스트만 2단계 한정)
|
// 영상은 1단계·2단계 폴더 어디서나 추가할 수 있다. (플레이리스트만 2단계 한정)
|
||||||
const videoId = typeof req.query.id === 'string' ? req.query.id : null
|
const videoId = typeof req.query.id === 'string' ? req.query.id : null
|
||||||
const video = videoId ? getVideo(videoId) : null
|
const video = videoId ? getVideoInFolder(folder.id, videoId) : null
|
||||||
res.render('op/editor', {
|
res.render('op/editor', {
|
||||||
userId: req.session.userId,
|
userId: req.session.userId,
|
||||||
folder,
|
folder,
|
||||||
@@ -243,6 +306,120 @@ opRouter.get(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ─── 목록 뽑기 (영상 ID + 실제 영상 주소 txt) ──────────────────────────
|
||||||
|
|
||||||
|
/** YouTube URL 의 11자 영상 ID 추출. watch?v= / youtu.be / embed / shorts / live, nocookie 도메인 지원. */
|
||||||
|
function extractYoutubeId(url: string): string | null {
|
||||||
|
const m = url.match(
|
||||||
|
/(?:youtu\.be\/|(?:youtube\.com|youtube-nocookie\.com)\/(?:watch\?(?:[^#]*&)?v=|embed\/|shorts\/|live\/|v\/))([\w-]{11})/
|
||||||
|
)
|
||||||
|
return m ? m[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 영상의 "실제 주소". YouTube 는 https://youtu.be/ID 로 간결화, 그 외(네이버 등)는 원본 그대로. */
|
||||||
|
function videoSourceUrl(sourceUrl: string | null): string {
|
||||||
|
if (!sourceUrl) return ''
|
||||||
|
const ytId = extractYoutubeId(sourceUrl)
|
||||||
|
return ytId ? `https://youtu.be/${ytId}` : sourceUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
opRouter.get(
|
||||||
|
'/op/folder/:topName/:subName/list.txt',
|
||||||
|
requireAuth,
|
||||||
|
(req, res, next) => {
|
||||||
|
try {
|
||||||
|
const folder = getFolderByPath(collectFolderSegments(req.params))
|
||||||
|
if (!folder) {
|
||||||
|
res.status(404).send('폴더를 찾을 수 없습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 실제 외부 주소(유튜브/네이버 등)가 있는 영상만. 업로드 영상처럼 주소가
|
||||||
|
// 없는 항목은 `[ID] [URL]` 두 칸 형식이 깨지므로 목록에서 제외한다.
|
||||||
|
const lines = listVideosInFolder(folder.id)
|
||||||
|
.map((v) => ({ id: v.id, url: videoSourceUrl(v.sourceUrl) }))
|
||||||
|
.filter((r) => r.url !== '')
|
||||||
|
.map((r) => `${r.id} ${r.url}`)
|
||||||
|
const body = lines.join('\n') + (lines.length ? '\n' : '')
|
||||||
|
const baseName = folderPathNames(folder.id).join('_') || 'list'
|
||||||
|
const asciiName = baseName.replace(/[^\x20-\x7e]+/g, '_').replace(/["\\]/g, '_')
|
||||||
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename="${asciiName}.txt"; filename*=UTF-8''${encodeURIComponent(baseName)}.txt`
|
||||||
|
)
|
||||||
|
res.send(body)
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── 플레이리스트 임시저장(draft) ──────────────────────────────────────
|
||||||
|
// 다운로드 시작 전 수정해둔 값(URL + 항목별 제목/ID/구간)을 폴더당 1개 저장/복원.
|
||||||
|
|
||||||
|
/** 하위 폴더를 경로로 해석. 못 찾거나 최상위면 throw. */
|
||||||
|
function requireSubFolder(req: Request): { id: number } {
|
||||||
|
const folder = getFolderByPath(collectFolderSegments(req.params))
|
||||||
|
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
|
||||||
|
if (folder.parentId === null) {
|
||||||
|
throw new Error('플레이리스트는 하위 폴더에서만 사용할 수 있습니다.')
|
||||||
|
}
|
||||||
|
return folder
|
||||||
|
}
|
||||||
|
|
||||||
|
// 임시저장 JSON 크기 상한 (과도한 payload 방지). 2 MiB 면 수백 항목도 충분.
|
||||||
|
const DRAFT_MAX_CHARS = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
opRouter.get(
|
||||||
|
'/op/folder/:topName/:subName/playlist/draft',
|
||||||
|
requireAuth,
|
||||||
|
(req, res) => {
|
||||||
|
try {
|
||||||
|
const folder = requireSubFolder(req)
|
||||||
|
const draft = getPlaylistDraft(folder.id)
|
||||||
|
res.json({ ok: true, draft })
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
opRouter.post(
|
||||||
|
'/op/folder/:topName/:subName/playlist/draft',
|
||||||
|
requireAuth,
|
||||||
|
(req, res) => {
|
||||||
|
try {
|
||||||
|
const folder = requireSubFolder(req)
|
||||||
|
const data = req.body?.data
|
||||||
|
if (typeof data !== 'object' || data === null) {
|
||||||
|
throw new Error('저장할 데이터가 올바르지 않습니다.')
|
||||||
|
}
|
||||||
|
const json = JSON.stringify(data)
|
||||||
|
if (json.length > DRAFT_MAX_CHARS) {
|
||||||
|
throw new Error('임시저장 데이터가 너무 큽니다.')
|
||||||
|
}
|
||||||
|
const saved = savePlaylistDraft(folder.id, json)
|
||||||
|
res.json({ ok: true, updatedAt: saved.updatedAt })
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
opRouter.post(
|
||||||
|
'/op/folder/:topName/:subName/playlist/draft/delete',
|
||||||
|
requireAuth,
|
||||||
|
(req, res) => {
|
||||||
|
try {
|
||||||
|
const folder = requireSubFolder(req)
|
||||||
|
deletePlaylistDraft(folder.id)
|
||||||
|
res.json({ ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// ─── 영상 업로드 / 유튜브 ───────────────────────────────────────────────
|
// ─── 영상 업로드 / 유튜브 ───────────────────────────────────────────────
|
||||||
|
|
||||||
function uploadSingle(fieldName: string) {
|
function uploadSingle(fieldName: string) {
|
||||||
@@ -282,7 +459,7 @@ opRouter.post(
|
|||||||
originalFile: destName,
|
originalFile: destName,
|
||||||
sourceType: 'upload'
|
sourceType: 'upload'
|
||||||
})
|
})
|
||||||
await moveUploadIntoVideoDir(video.id, file.path, destName)
|
await moveUploadIntoVideoDir(folder.id, video.id, file.path, destName)
|
||||||
res.json({ ok: true, videoId: video.id })
|
res.json({ ok: true, videoId: video.id })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||||
@@ -385,7 +562,13 @@ opRouter.post(
|
|||||||
throw new Error('등록할 항목이 없습니다.')
|
throw new Error('등록할 항목이 없습니다.')
|
||||||
}
|
}
|
||||||
|
|
||||||
type Cleaned = { url: string; title: string; videoId: string }
|
type Cleaned = {
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
videoId: string
|
||||||
|
startSec: number
|
||||||
|
endSec: number | null
|
||||||
|
}
|
||||||
const cleaned: Cleaned[] = []
|
const cleaned: Cleaned[] = []
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
for (let i = 0; i < rawEntries.length; i += 1) {
|
for (let i = 0; i < rawEntries.length; i += 1) {
|
||||||
@@ -402,11 +585,17 @@ opRouter.post(
|
|||||||
if (seen.has(videoId)) {
|
if (seen.has(videoId)) {
|
||||||
throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 요청 내에서 중복됩니다.`)
|
throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 요청 내에서 중복됩니다.`)
|
||||||
}
|
}
|
||||||
if (videoIdExists(videoId)) {
|
if (videoExistsInFolder(folder.id, videoId)) {
|
||||||
throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 이미 사용 중입니다.`)
|
throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 이미 사용 중입니다.`)
|
||||||
}
|
}
|
||||||
|
// 자르기 구간(선택). 잘못된 값은 무시하고 전체로 처리.
|
||||||
|
const startRaw = Number(e?.startSec)
|
||||||
|
const startSec = Number.isFinite(startRaw) && startRaw > 0 ? startRaw : 0
|
||||||
|
const endRaw = Number(e?.endSec)
|
||||||
|
const endSec =
|
||||||
|
Number.isFinite(endRaw) && endRaw > startSec ? endRaw : null
|
||||||
seen.add(videoId)
|
seen.add(videoId)
|
||||||
cleaned.push({ url, title, videoId })
|
cleaned.push({ url, title, videoId, startSec, endSec })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검증 통과 → 순차적으로 잡 시작. createVideo 의 unique 제약이 마지막 안전망.
|
// 검증 통과 → 순차적으로 잡 시작. createVideo 의 unique 제약이 마지막 안전망.
|
||||||
@@ -416,7 +605,9 @@ opRouter.post(
|
|||||||
folderId: folder.id,
|
folderId: folder.id,
|
||||||
url: entry.url,
|
url: entry.url,
|
||||||
title: entry.title,
|
title: entry.title,
|
||||||
videoId: entry.videoId
|
videoId: entry.videoId,
|
||||||
|
startSec: entry.startSec,
|
||||||
|
endSec: entry.endSec
|
||||||
})
|
})
|
||||||
jobs.push({ jobId: job.id, videoId: job.videoId })
|
jobs.push({ jobId: job.id, videoId: job.videoId })
|
||||||
}
|
}
|
||||||
@@ -443,15 +634,39 @@ opRouter.get('/op/job/:id', requireAuth, (req, res) => {
|
|||||||
res.json({ ok: true, job })
|
res.json({ ok: true, job })
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── 영상 mutation (id 기반 — 전역 유일 ID) ─────────────────────────────
|
/** 실패한 다운로드를 같은 ID·URL·자르기 설정으로 재시도 (기존 영상 레코드 재사용). */
|
||||||
|
opRouter.post('/op/job/:id/retry', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const job = await retryDownloadJob(req.params.id)
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── 영상 mutation (folderId + id 기반 — ID 는 폴더 안에서만 유일) ───────
|
||||||
|
|
||||||
|
/** POST body 의 folderId 를 검증해 폴더를 돌려준다. 없거나 못 찾으면 throw. */
|
||||||
|
function folderFromBody(req: Request): { id: number } {
|
||||||
|
const fid = pickIntId(req.body?.folderId)
|
||||||
|
if (fid === null) throw new Error('폴더 ID 가 올바르지 않습니다.')
|
||||||
|
const folder = getFolder(fid)
|
||||||
|
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
|
||||||
|
return folder
|
||||||
|
}
|
||||||
|
|
||||||
opRouter.post('/op/videos/:id/rename', requireAuth, (req, res) => {
|
opRouter.post('/op/videos/:id/rename', requireAuth, (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const folder = folderFromBody(req)
|
||||||
const id = req.params.id
|
const id = req.params.id
|
||||||
const title = pickStr(req.body.title).trim()
|
const title = pickStr(req.body.title).trim()
|
||||||
if (!title) throw new Error('제목을 입력해 주세요.')
|
if (!title) throw new Error('제목을 입력해 주세요.')
|
||||||
if (!getVideo(id)) throw new Error('영상을 찾을 수 없습니다.')
|
if (!getVideoInFolder(folder.id, id)) throw new Error('영상을 찾을 수 없습니다.')
|
||||||
updateVideo(id, { title })
|
updateVideo(folder.id, id, { title })
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||||
@@ -460,19 +675,21 @@ opRouter.post('/op/videos/:id/rename', requireAuth, (req, res) => {
|
|||||||
|
|
||||||
opRouter.post('/op/videos/:id/changeId', requireAuth, async (req, res) => {
|
opRouter.post('/op/videos/:id/changeId', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const folder = folderFromBody(req)
|
||||||
const oldId = req.params.id
|
const oldId = req.params.id
|
||||||
const newId = pickStr(req.body.newId).trim()
|
const newId = pickStr(req.body.newId).trim()
|
||||||
if (!newId) throw new Error('새 ID 를 입력해 주세요.')
|
if (!newId) throw new Error('새 ID 를 입력해 주세요.')
|
||||||
const video = await changeVideoId(oldId, newId)
|
const video = await changeVideoId(folder.id, oldId, newId)
|
||||||
res.json({ ok: true, video })
|
res.json({ ok: true, video })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 새 ID 가 사용 가능한지 미리 확인 (인풋 옆 실시간 표시용). */
|
/** 새 ID 가 (해당 폴더 안에서) 사용 가능한지 미리 확인 (인풋 옆 실시간 표시용). */
|
||||||
opRouter.get('/op/videos/idAvailable', requireAuth, (req, res) => {
|
opRouter.get('/op/videos/idAvailable', requireAuth, (req, res) => {
|
||||||
const id = typeof req.query.id === 'string' ? req.query.id.trim() : ''
|
const id = typeof req.query.id === 'string' ? req.query.id.trim() : ''
|
||||||
|
const folderId = pickIntId(req.query.folderId)
|
||||||
if (!id) {
|
if (!id) {
|
||||||
res.json({ ok: true, available: false, reason: '비어 있음' })
|
res.json({ ok: true, available: false, reason: '비어 있음' })
|
||||||
return
|
return
|
||||||
@@ -485,13 +702,18 @@ opRouter.get('/op/videos/idAvailable', requireAuth, (req, res) => {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const available = !videoIdExists(id)
|
if (folderId === null) {
|
||||||
|
res.json({ ok: true, available: false, reason: '폴더 정보 없음' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const available = !videoExistsInFolder(folderId, id)
|
||||||
res.json({ ok: true, available })
|
res.json({ ok: true, available })
|
||||||
})
|
})
|
||||||
|
|
||||||
opRouter.post('/op/videos/:id/delete', requireAuth, async (req, res) => {
|
opRouter.post('/op/videos/:id/delete', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await deleteVideo(req.params.id)
|
const folder = folderFromBody(req)
|
||||||
|
await deleteVideo(folder.id, req.params.id)
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||||
@@ -500,8 +722,9 @@ opRouter.post('/op/videos/:id/delete', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => {
|
opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const folder = folderFromBody(req)
|
||||||
const id = req.params.id
|
const id = req.params.id
|
||||||
const video = getVideo(id)
|
const video = getVideoInFolder(folder.id, id)
|
||||||
if (!video) throw new Error('영상을 찾을 수 없습니다.')
|
if (!video) throw new Error('영상을 찾을 수 없습니다.')
|
||||||
const title = pickStr(req.body.title).trim()
|
const title = pickStr(req.body.title).trim()
|
||||||
const startSec = Number(req.body.startSec ?? 0) || 0
|
const startSec = Number(req.body.startSec ?? 0) || 0
|
||||||
@@ -514,10 +737,10 @@ opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => {
|
|||||||
startSec,
|
startSec,
|
||||||
endSec: endSec == null || Number.isNaN(endSec) ? null : endSec
|
endSec: endSec == null || Number.isNaN(endSec) ? null : endSec
|
||||||
}
|
}
|
||||||
updateVideo(id, { trim, ...(title ? { title } : {}) })
|
updateVideo(folder.id, id, { trim, ...(title ? { title } : {}) })
|
||||||
// ffmpeg 가 없으면 trim 정보만 저장하고 안내.
|
// ffmpeg 가 없으면 trim 정보만 저장하고 안내.
|
||||||
try {
|
try {
|
||||||
const outName = await applyTrimToVideo(id, trim)
|
const outName = await applyTrimToVideo(folder.id, id, trim)
|
||||||
res.json({ ok: true, editedFile: outName, note: '편집본 저장 완료' })
|
res.json({ ok: true, editedFile: outName, note: '편집본 저장 완료' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof FfmpegUnavailableError) {
|
if (err instanceof FfmpegUnavailableError) {
|
||||||
@@ -545,7 +768,7 @@ export function adminFolderUrl(folderId: number): string {
|
|||||||
|
|
||||||
/** 비디오 ID → 공개 공유 URL (/video/topName/[subName/]videoId). */
|
/** 비디오 ID → 공개 공유 URL (/video/topName/[subName/]videoId). */
|
||||||
export function videoShareUrl(videoId: string): string | null {
|
export function videoShareUrl(videoId: string): string | null {
|
||||||
const v = getVideo(videoId)
|
const v = getVideoByIdGlobal(videoId)
|
||||||
if (!v) return null
|
if (!v) return null
|
||||||
const names = folderPathNames(v.folderId)
|
const names = folderPathNames(v.folderId)
|
||||||
return '/video/' + [...names, videoId].map((s) => encodeURIComponent(s)).join('/')
|
return '/video/' + [...names, videoId].map((s) => encodeURIComponent(s)).join('/')
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Router, type RequestHandler } from 'express'
|
import { Router, type RequestHandler } from 'express'
|
||||||
import {
|
import {
|
||||||
getFolderByPath,
|
getFolderByPath,
|
||||||
getVideo,
|
getVideoInFolder,
|
||||||
|
getVideoByIdGlobal,
|
||||||
listChildFolders,
|
listChildFolders,
|
||||||
listTopFolders,
|
listTopFolders,
|
||||||
listVideosInFolder,
|
listVideosInFolder,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
type Video
|
type Video
|
||||||
} from '../storeDb.js'
|
} from '../storeDb.js'
|
||||||
import { collectFolderSegments } from './helpers.js'
|
import { collectFolderSegments } from './helpers.js'
|
||||||
|
import { ensureThumbnail } from '../editor.js'
|
||||||
|
|
||||||
export const publicRouter = Router()
|
export const publicRouter = Router()
|
||||||
|
|
||||||
@@ -64,17 +66,17 @@ publicRouter.get('/folder/:topName/:subName', folderViewHandler)
|
|||||||
const videoViewHandler: RequestHandler = (req, res, next) => {
|
const videoViewHandler: RequestHandler = (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { folderSegments, videoId } = collectVideoSegments(req.params)
|
const { folderSegments, videoId } = collectVideoSegments(req.params)
|
||||||
const video = getVideo(videoId)
|
const folder = getFolderByPath(folderSegments)
|
||||||
|
if (!folder) {
|
||||||
|
res.status(404).send('영상을 찾을 수 없습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const video = getVideoInFolder(folder.id, videoId)
|
||||||
if (!video) {
|
if (!video) {
|
||||||
res.status(404).send('영상을 찾을 수 없습니다.')
|
res.status(404).send('영상을 찾을 수 없습니다.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const folderPath = folderPathNames(video.folderId)
|
res.render('player', { video, breadcrumb: folderPathNames(folder.id) })
|
||||||
if (!segmentsEqual(folderPath, folderSegments)) {
|
|
||||||
res.status(404).send('영상 경로가 일치하지 않습니다.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
res.render('player', { video, breadcrumb: folderPath })
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err)
|
next(err)
|
||||||
}
|
}
|
||||||
@@ -82,40 +84,121 @@ const videoViewHandler: RequestHandler = (req, res, next) => {
|
|||||||
publicRouter.get('/video/:topName/:videoId', videoViewHandler)
|
publicRouter.get('/video/:topName/:videoId', videoViewHandler)
|
||||||
publicRouter.get('/video/:topName/:subName/:videoId', videoViewHandler)
|
publicRouter.get('/video/:topName/:subName/:videoId', videoViewHandler)
|
||||||
|
|
||||||
|
/** 결정된 비디오의 파일을 응답으로 보낸다 (?edited=0 이면 원본 강제). */
|
||||||
|
function sendVideoFile(video: Video, req: Parameters<RequestHandler>[0], res: Parameters<RequestHandler>[1]): void {
|
||||||
|
const editedParam = typeof req.query.edited === 'string' ? req.query.edited : ''
|
||||||
|
const wantOriginal = editedParam === '0' || editedParam === 'false'
|
||||||
|
const fileName =
|
||||||
|
!wantOriginal && video.editedFile ? video.editedFile : video.originalFile
|
||||||
|
if (!fileName || fileName.includes('%(ext)s')) {
|
||||||
|
res.status(404).end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.sendFile(videoFileFsPath(video.folderId, video.id, fileName))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 영상 파일 스트리밍 (짧은 ID-only URL — 외부 공유 호환용).
|
* 영상 파일 스트리밍 (짧은 ID-only URL — 외부 공유 호환용 / 레거시).
|
||||||
* - GET /file/video/:videoId
|
* - GET /file/video/:videoId
|
||||||
* - GET /api/video/:videoId/file (legacy alias)
|
* ID 가 전역에서 정확히 1건일 때만 제공한다. 같은 ID 가 여러 폴더에 있으면
|
||||||
|
* getVideoByIdGlobal 이 null 을 돌려주므로 404 (어느 폴더인지 모호).
|
||||||
|
* 폴더가 확실한 경우엔 정규 경로 URL `/api/video/폴더/[폴더/]ID` 를 쓸 것.
|
||||||
* ?edited=0 이면 원본을 강제, 기본은 편집본 있으면 편집본.
|
* ?edited=0 이면 원본을 강제, 기본은 편집본 있으면 편집본.
|
||||||
|
*
|
||||||
|
* 주의: 예전 `/api/video/:videoId/file` 레거시 라우트는 제거했다. 정규 경로
|
||||||
|
* 라우트 `/api/video/:topName/:videoId` 와 2세그먼트에서 충돌해서, ID 가
|
||||||
|
* 리터럴 "file" 인 영상의 정규 URL `/api/video/폴더/file` 을 가로챘기 때문.
|
||||||
*/
|
*/
|
||||||
const streamVideoHandler: RequestHandler = (req, res, next) => {
|
const streamVideoHandler: RequestHandler = (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const video = getVideo(req.params.videoId)
|
const video = getVideoByIdGlobal(req.params.videoId)
|
||||||
if (!video) {
|
if (!video) {
|
||||||
res.status(404).end()
|
res.status(404).end()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const editedParam = typeof req.query.edited === 'string' ? req.query.edited : ''
|
sendVideoFile(video, req, res)
|
||||||
const wantOriginal = editedParam === '0' || editedParam === 'false'
|
|
||||||
const fileName =
|
|
||||||
!wantOriginal && video.editedFile ? video.editedFile : video.originalFile
|
|
||||||
if (!fileName || fileName.includes('%(ext)s')) {
|
|
||||||
res.status(404).end()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const fsPath = videoFileFsPath(video.id, fileName)
|
|
||||||
res.sendFile(fsPath)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err)
|
next(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
publicRouter.get('/file/video/:videoId', streamVideoHandler)
|
publicRouter.get('/file/video/:videoId', streamVideoHandler)
|
||||||
publicRouter.get('/api/video/:videoId/file', streamVideoHandler)
|
|
||||||
|
/**
|
||||||
|
* 영상 파일 스트리밍 (폴더 경로 + ID — 정규 공유 URL).
|
||||||
|
* - GET /api/video/:topName/:videoId
|
||||||
|
* - GET /api/video/:topName/:subName/:videoId
|
||||||
|
*
|
||||||
|
* 재생 페이지(/video/...)와 동일한 경로 규칙. 마지막 세그먼트가 영상 ID 이고
|
||||||
|
* 앞쪽 세그먼트는 그 영상이 실제 속한 폴더 경로와 일치해야 한다.
|
||||||
|
* (예전 `/api/video/:videoId/file` 레거시 라우트와의 2세그먼트 충돌은 그 라우트를
|
||||||
|
* 제거해서 해소했다 — 이제 ID 가 "file" 인 영상도 정규 URL 로 정확히 찾는다.)
|
||||||
|
*/
|
||||||
|
const streamVideoByPathHandler: RequestHandler = (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { folderSegments, videoId } = collectVideoSegments(req.params)
|
||||||
|
const folder = getFolderByPath(folderSegments)
|
||||||
|
if (!folder) {
|
||||||
|
res.status(404).end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const video = getVideoInFolder(folder.id, videoId)
|
||||||
|
if (!video) {
|
||||||
|
res.status(404).end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendVideoFile(video, req, res)
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publicRouter.get('/api/video/:topName/:videoId', streamVideoByPathHandler)
|
||||||
|
publicRouter.get('/api/video/:topName/:subName/:videoId', streamVideoByPathHandler)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 영상 썸네일 (카드용). 없으면 ffmpeg 로 즉석 생성 후 thumb.jpg 로 캐시.
|
||||||
|
* 생성 불가(ffmpeg 없음/다운로드 미완 등)면 404 → 카드에서 ▶ placeholder 로 폴백.
|
||||||
|
*/
|
||||||
|
function sendThumb(res: Parameters<RequestHandler>[1], thumbPath: string | null): void {
|
||||||
|
if (!thumbPath) {
|
||||||
|
res.status(404).end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.set('Cache-Control', 'public, max-age=3600')
|
||||||
|
res.sendFile(thumbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 폴더 경로 + ID — 정규 썸네일 URL. 같은 ID 가 여러 폴더에 있어도 정확히 찾는다. */
|
||||||
|
const thumbByPathHandler: RequestHandler = (req, res, next) => {
|
||||||
|
const { folderSegments, videoId } = collectVideoSegments(req.params)
|
||||||
|
const folder = getFolderByPath(folderSegments)
|
||||||
|
if (!folder) {
|
||||||
|
res.status(404).end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureThumbnail(folder.id, videoId)
|
||||||
|
.then((thumbPath) => sendThumb(res, thumbPath))
|
||||||
|
.catch(next)
|
||||||
|
}
|
||||||
|
publicRouter.get('/file/video/:topName/:videoId/thumb', thumbByPathHandler)
|
||||||
|
publicRouter.get('/file/video/:topName/:subName/:videoId/thumb', thumbByPathHandler)
|
||||||
|
|
||||||
|
/** ID-only 레거시 썸네일 fallback (전역에서 유일한 경우만, 중복이면 404). */
|
||||||
|
const thumbHandler: RequestHandler = (req, res, next) => {
|
||||||
|
const video = getVideoByIdGlobal(req.params.videoId)
|
||||||
|
if (!video) {
|
||||||
|
res.status(404).end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureThumbnail(video.folderId, video.id)
|
||||||
|
.then((thumbPath) => sendThumb(res, thumbPath))
|
||||||
|
.catch(next)
|
||||||
|
}
|
||||||
|
publicRouter.get('/file/video/:videoId/thumb', thumbHandler)
|
||||||
|
|
||||||
/** 비디오 메타 조회 (플레이어/관리자 양쪽에서 사용). */
|
/** 비디오 메타 조회 (플레이어/관리자 양쪽에서 사용). */
|
||||||
publicRouter.get('/api/video/:videoId', (req, res, next) => {
|
publicRouter.get('/api/video/:videoId', (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const video = getVideo(req.params.videoId)
|
const video = getVideoByIdGlobal(req.params.videoId)
|
||||||
if (!video) {
|
if (!video) {
|
||||||
res.status(404).json({ ok: false, message: '영상을 찾을 수 없습니다.' })
|
res.status(404).json({ ok: false, message: '영상을 찾을 수 없습니다.' })
|
||||||
return
|
return
|
||||||
@@ -142,12 +225,4 @@ function collectVideoSegments(params: Record<string, string | undefined>): {
|
|||||||
return { folderSegments, videoId: params.videoId ?? '' }
|
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 }
|
export type { Folder, Video }
|
||||||
|
|||||||
131
src/storeDb.ts
131
src/storeDb.ts
@@ -299,14 +299,30 @@ export async function deleteFolder(folderId: number): Promise<void> {
|
|||||||
|
|
||||||
// ─── 영상 조회 ─────────────────────────────────────────────────────────
|
// ─── 영상 조회 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getVideo(id: string): Video | null {
|
/** 폴더 안에서 영상 1건. ID 는 폴더 안에서만 유일하므로 folderId 가 반드시 필요. */
|
||||||
|
export function getVideoInFolder(folderId: number, id: string): Video | null {
|
||||||
if (!isSafeVideoId(id)) return null
|
if (!isSafeVideoId(id)) return null
|
||||||
const r = getDb()
|
const r = getDb()
|
||||||
.prepare(`SELECT * FROM videos WHERE id = ?`)
|
.prepare(`SELECT * FROM videos WHERE folder_id = ? AND id = ?`)
|
||||||
.get(id) as VideoRow | undefined
|
.get(folderId, id) as VideoRow | undefined
|
||||||
return r ? rowToVideo(r) : null
|
return r ? rowToVideo(r) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID 만으로 전역에서 영상을 찾는다 (폴더 경로 없는 레거시/외부공유 fallback).
|
||||||
|
* ID 는 이제 폴더별로만 유일하므로, 같은 ID 가 여러 폴더에 있으면 어느 쪽인지
|
||||||
|
* 모호하다. 그런 경우엔 아무 것도 돌려주지 않아 호출 측이 404 로 처리하게 한다.
|
||||||
|
* 전역에서 정확히 1건일 때만 그 영상을 돌려준다.
|
||||||
|
*/
|
||||||
|
export function getVideoByIdGlobal(id: string): Video | null {
|
||||||
|
if (!isSafeVideoId(id)) return null
|
||||||
|
const rows = getDb()
|
||||||
|
.prepare(`SELECT * FROM videos WHERE id = ? ORDER BY folder_id LIMIT 2`)
|
||||||
|
.all(id) as VideoRow[]
|
||||||
|
if (rows.length !== 1) return null // 없음 또는 중복(모호) → 404
|
||||||
|
return rowToVideo(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
export function listVideosInFolder(folderId: number): Video[] {
|
export function listVideosInFolder(folderId: number): Video[] {
|
||||||
const rows = getDb()
|
const rows = getDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -316,34 +332,30 @@ export function listVideosInFolder(folderId: number): Video[] {
|
|||||||
return rows.map(rowToVideo)
|
return rows.map(rowToVideo)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function videoIdExists(id: string): boolean {
|
export function videoExistsInFolder(folderId: number, id: string): boolean {
|
||||||
if (!isSafeVideoId(id)) return false
|
if (!isSafeVideoId(id)) return false
|
||||||
const r = getDb()
|
const r = getDb()
|
||||||
.prepare(`SELECT 1 FROM videos WHERE id = ?`)
|
.prepare(`SELECT 1 FROM videos WHERE folder_id = ? AND id = ?`)
|
||||||
.get(id)
|
.get(folderId, id)
|
||||||
return !!r
|
return !!r
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 영상 → 디스크 디렉토리 (폴더 경로 + videoId). */
|
/** 영상 → 디스크 디렉토리 (폴더 경로 + videoId). DB 조회 없이 폴더경로로 합성. */
|
||||||
export function videoDiskDir(videoId: string): string {
|
export function videoDiskDir(folderId: number, id: string): string {
|
||||||
const v = getVideo(videoId)
|
return path.join(folderDiskPath(folderId), id)
|
||||||
if (!v) throw new Error('영상을 찾을 수 없습니다.')
|
|
||||||
return path.join(folderDiskPath(v.folderId), v.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 영상 → 공유 URL 의 경로 부분 (인코딩 전 세그먼트). */
|
/** 영상 → 공유 URL 의 경로 부분 (인코딩 전 세그먼트). */
|
||||||
export function videoPathNames(videoId: string): string[] | null {
|
export function videoPathNames(folderId: number, id: string): string[] {
|
||||||
const v = getVideo(videoId)
|
return [...folderPathNames(folderId), id]
|
||||||
if (!v) return null
|
|
||||||
return [...folderPathNames(v.folderId), v.id]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 영상 디렉토리 내 파일의 절대 경로. 경로 탈출 방지. */
|
/** 영상 디렉토리 내 파일의 절대 경로. 경로 탈출 방지. */
|
||||||
export function videoFileFsPath(videoId: string, rel: string): string {
|
export function videoFileFsPath(folderId: number, id: string, rel: string): string {
|
||||||
if (!rel || rel.includes('/') || rel.includes('\\') || rel.includes('..')) {
|
if (!rel || rel.includes('/') || rel.includes('\\') || rel.includes('..')) {
|
||||||
throw new Error('잘못된 파일 경로입니다.')
|
throw new Error('잘못된 파일 경로입니다.')
|
||||||
}
|
}
|
||||||
return path.join(videoDiskDir(videoId), rel)
|
return path.join(videoDiskDir(folderId, id), rel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 영상 변경 ─────────────────────────────────────────────────────────
|
// ─── 영상 변경 ─────────────────────────────────────────────────────────
|
||||||
@@ -362,7 +374,9 @@ export async function createVideo(input: CreateVideoInput): Promise<Video> {
|
|||||||
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
|
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
|
||||||
let id = input.id ?? newRandomVideoId()
|
let id = input.id ?? newRandomVideoId()
|
||||||
if (!isSafeVideoId(id)) throw new Error('영상 ID 가 올바르지 않습니다.')
|
if (!isSafeVideoId(id)) throw new Error('영상 ID 가 올바르지 않습니다.')
|
||||||
if (videoIdExists(id)) throw new Error('이미 사용 중인 영상 ID 입니다.')
|
if (videoExistsInFolder(input.folderId, id)) {
|
||||||
|
throw new Error('이미 사용 중인 영상 ID 입니다.')
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
@@ -392,30 +406,37 @@ export async function createVideo(input: CreateVideoInput): Promise<Video> {
|
|||||||
try {
|
try {
|
||||||
await fsp.mkdir(diskDir, { recursive: true })
|
await fsp.mkdir(diskDir, { recursive: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
db.prepare(`DELETE FROM videos WHERE id = ?`).run(id)
|
db.prepare(`DELETE FROM videos WHERE folder_id = ? AND id = ?`).run(input.folderId, id)
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
return getVideo(id)!
|
return getVideoInFolder(input.folderId, id)!
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function changeVideoId(oldId: string, newId: string): Promise<Video> {
|
export async function changeVideoId(
|
||||||
|
folderId: number,
|
||||||
|
oldId: string,
|
||||||
|
newId: string
|
||||||
|
): Promise<Video> {
|
||||||
if (!isSafeVideoId(newId)) throw new Error('새 영상 ID 가 올바르지 않습니다.')
|
if (!isSafeVideoId(newId)) throw new Error('새 영상 ID 가 올바르지 않습니다.')
|
||||||
if (oldId === newId) {
|
if (oldId === newId) {
|
||||||
const v = getVideo(oldId)
|
const v = getVideoInFolder(folderId, oldId)
|
||||||
if (!v) throw new Error('영상을 찾을 수 없습니다.')
|
if (!v) throw new Error('영상을 찾을 수 없습니다.')
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
const current = getVideo(oldId)
|
const current = getVideoInFolder(folderId, oldId)
|
||||||
if (!current) throw new Error('영상을 찾을 수 없습니다.')
|
if (!current) throw new Error('영상을 찾을 수 없습니다.')
|
||||||
if (videoIdExists(newId)) throw new Error('이미 사용 중인 영상 ID 입니다.')
|
if (videoExistsInFolder(folderId, newId)) {
|
||||||
|
throw new Error('이미 사용 중인 영상 ID 입니다.')
|
||||||
|
}
|
||||||
|
|
||||||
const oldDir = path.join(folderDiskPath(current.folderId), current.id)
|
const oldDir = path.join(folderDiskPath(current.folderId), current.id)
|
||||||
const newDir = path.join(folderDiskPath(current.folderId), newId)
|
const newDir = path.join(folderDiskPath(current.folderId), newId)
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
try {
|
try {
|
||||||
db.prepare(`UPDATE videos SET id = ?, updated_at = ? WHERE id = ?`).run(
|
db.prepare(`UPDATE videos SET id = ?, updated_at = ? WHERE folder_id = ? AND id = ?`).run(
|
||||||
newId,
|
newId,
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
|
folderId,
|
||||||
oldId
|
oldId
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -431,11 +452,15 @@ export async function changeVideoId(oldId: string, newId: string): Promise<Video
|
|||||||
// 디스크에 디렉토리가 없으면 빈 디렉토리 생성 (메타만 있는 케이스).
|
// 디스크에 디렉토리가 없으면 빈 디렉토리 생성 (메타만 있는 케이스).
|
||||||
await fsp.mkdir(newDir, { recursive: true })
|
await fsp.mkdir(newDir, { recursive: true })
|
||||||
} else {
|
} else {
|
||||||
db.prepare(`UPDATE videos SET id = ? WHERE id = ?`).run(oldId, newId)
|
db.prepare(`UPDATE videos SET id = ? WHERE folder_id = ? AND id = ?`).run(
|
||||||
|
oldId,
|
||||||
|
folderId,
|
||||||
|
newId
|
||||||
|
)
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return getVideo(newId)!
|
return getVideoInFolder(folderId, newId)!
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateVideoPatch {
|
export interface UpdateVideoPatch {
|
||||||
@@ -447,8 +472,8 @@ export interface UpdateVideoPatch {
|
|||||||
trim?: VideoTrim | null
|
trim?: VideoTrim | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateVideo(id: string, patch: UpdateVideoPatch): Video {
|
export function updateVideo(folderId: number, id: string, patch: UpdateVideoPatch): Video {
|
||||||
const current = getVideo(id)
|
const current = getVideoInFolder(folderId, id)
|
||||||
if (!current) throw new Error('영상을 찾을 수 없습니다.')
|
if (!current) throw new Error('영상을 찾을 수 없습니다.')
|
||||||
|
|
||||||
const fields: string[] = []
|
const fields: string[] = []
|
||||||
@@ -484,22 +509,25 @@ export function updateVideo(id: string, patch: UpdateVideoPatch): Video {
|
|||||||
if (fields.length === 0) return current
|
if (fields.length === 0) return current
|
||||||
fields.push('updated_at = ?')
|
fields.push('updated_at = ?')
|
||||||
params.push(new Date().toISOString())
|
params.push(new Date().toISOString())
|
||||||
params.push(id)
|
params.push(folderId, id)
|
||||||
getDb().prepare(`UPDATE videos SET ${fields.join(', ')} WHERE id = ?`).run(...params)
|
getDb()
|
||||||
return getVideo(id)!
|
.prepare(`UPDATE videos SET ${fields.join(', ')} WHERE folder_id = ? AND id = ?`)
|
||||||
|
.run(...params)
|
||||||
|
return getVideoInFolder(folderId, id)!
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteVideo(id: string): Promise<void> {
|
export async function deleteVideo(folderId: number, id: string): Promise<void> {
|
||||||
const v = getVideo(id)
|
const v = getVideoInFolder(folderId, id)
|
||||||
if (!v) return
|
if (!v) return
|
||||||
const diskDir = path.join(folderDiskPath(v.folderId), v.id)
|
const diskDir = path.join(folderDiskPath(v.folderId), v.id)
|
||||||
getDb().prepare(`DELETE FROM videos WHERE id = ?`).run(id)
|
getDb().prepare(`DELETE FROM videos WHERE folder_id = ? AND id = ?`).run(folderId, id)
|
||||||
await fsp.rm(diskDir, { recursive: true, force: true })
|
await fsp.rm(diskDir, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 업로드 임시 파일 → 영상 디렉토리 이동 ─────────────────────────────
|
// ─── 업로드 임시 파일 → 영상 디렉토리 이동 ─────────────────────────────
|
||||||
|
|
||||||
export async function moveUploadIntoVideoDir(
|
export async function moveUploadIntoVideoDir(
|
||||||
|
folderId: number,
|
||||||
videoId: string,
|
videoId: string,
|
||||||
tmpFile: string,
|
tmpFile: string,
|
||||||
destName: string
|
destName: string
|
||||||
@@ -507,7 +535,7 @@ export async function moveUploadIntoVideoDir(
|
|||||||
if (!destName || destName.includes('/') || destName.includes('\\') || destName.includes('..')) {
|
if (!destName || destName.includes('/') || destName.includes('\\') || destName.includes('..')) {
|
||||||
throw new Error('잘못된 파일명입니다.')
|
throw new Error('잘못된 파일명입니다.')
|
||||||
}
|
}
|
||||||
const dir = videoDiskDir(videoId)
|
const dir = videoDiskDir(folderId, videoId)
|
||||||
await fsp.mkdir(dir, { recursive: true })
|
await fsp.mkdir(dir, { recursive: true })
|
||||||
const target = path.join(dir, destName)
|
const target = path.join(dir, destName)
|
||||||
try {
|
try {
|
||||||
@@ -522,3 +550,34 @@ export async function moveUploadIntoVideoDir(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 플레이리스트 임시저장(draft) ──────────────────────────────────────
|
||||||
|
// 폴더당 1개의 작업중 상태(JSON 문자열)를 보관. 다운로드 시작 전 수정값을 저장/복원.
|
||||||
|
|
||||||
|
export interface PlaylistDraft {
|
||||||
|
data: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePlaylistDraft(folderId: number, data: string): PlaylistDraft {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO playlist_drafts (folder_id, data, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(folder_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
|
||||||
|
)
|
||||||
|
.run(folderId, data, now)
|
||||||
|
return { data, updatedAt: now }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlaylistDraft(folderId: number): PlaylistDraft | null {
|
||||||
|
const r = getDb()
|
||||||
|
.prepare(`SELECT data, updated_at FROM playlist_drafts WHERE folder_id = ?`)
|
||||||
|
.get(folderId) as { data: string; updated_at: string } | undefined
|
||||||
|
return r ? { data: r.data, updatedAt: r.updated_at } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePlaylistDraft(folderId: number): void {
|
||||||
|
getDb().prepare(`DELETE FROM playlist_drafts WHERE folder_id = ?`).run(folderId)
|
||||||
|
}
|
||||||
|
|||||||
207
src/tools.ts
Normal file
207
src/tools.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* 외부 도구(ffmpeg / yt-dlp) 버전 조회 + 최신 버전 재설치.
|
||||||
|
*
|
||||||
|
* 동기:
|
||||||
|
* - yt-dlp 는 YouTube 변경에 맞춰 자주 깨진다. 오래된 버전이면 다운로드가 실패하므로
|
||||||
|
* 운영 중에 최신 버전으로 갈아끼울 수 있어야 한다.
|
||||||
|
* - 새 바이너리는 data 볼륨 하위 `data/bin` 에 설치한다(컨테이너 재생성에도 유지).
|
||||||
|
* 바이너리 해석은 `data/bin` 을 PATH/이미지 기본본보다 먼저 본다(editor.ts / youtube.ts).
|
||||||
|
*
|
||||||
|
* Linux 컨테이너(운영 런타임) 기준으로 구현. 다른 OS 에서는 명확한 에러를 던진다.
|
||||||
|
*/
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { promises as fs, createWriteStream } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import https from 'node:https'
|
||||||
|
import { binDir } from './paths.js'
|
||||||
|
import { getFfmpegPath, resetFfmpegResolution } from './editor.js'
|
||||||
|
import { getYtDlpPath, resetYtDlpResolution } from './youtube.js'
|
||||||
|
|
||||||
|
export interface ToolStatus {
|
||||||
|
name: 'ffmpeg' | 'yt-dlp'
|
||||||
|
available: boolean
|
||||||
|
version: string | null
|
||||||
|
path: string | null
|
||||||
|
/** data/bin 에 재설치된(직접 관리하는) 바이너리인지 여부. */
|
||||||
|
managed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFfmpegVersion(out: string): string | null {
|
||||||
|
const first = String(out).split(/\r?\n/)[0] || ''
|
||||||
|
const m = /ffmpeg version (\S+)/.exec(first)
|
||||||
|
return m ? m[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ffmpegStatus(): ToolStatus {
|
||||||
|
try {
|
||||||
|
const p = getFfmpegPath()
|
||||||
|
const r = spawnSync(p, ['-version'], { encoding: 'utf8' })
|
||||||
|
const ok = r.status === 0
|
||||||
|
return {
|
||||||
|
name: 'ffmpeg',
|
||||||
|
available: ok,
|
||||||
|
version: ok ? parseFfmpegVersion(r.stdout) : null,
|
||||||
|
path: p,
|
||||||
|
managed: p.startsWith(binDir)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { name: 'ffmpeg', available: false, version: null, path: null, managed: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ytDlpStatus(): ToolStatus {
|
||||||
|
try {
|
||||||
|
const p = getYtDlpPath()
|
||||||
|
const r = spawnSync(p, ['--version'], { encoding: 'utf8' })
|
||||||
|
const ok = r.status === 0
|
||||||
|
return {
|
||||||
|
name: 'yt-dlp',
|
||||||
|
available: ok,
|
||||||
|
version: ok ? String(r.stdout).trim() : null,
|
||||||
|
path: p,
|
||||||
|
managed: p.startsWith(binDir)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { name: 'yt-dlp', available: false, version: null, path: null, managed: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToolsStatus(): { ffmpeg: ToolStatus; ytDlp: ToolStatus } {
|
||||||
|
return { ffmpeg: ffmpegStatus(), ytDlp: ytDlpStatus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTPS 다운로드. 3xx 리다이렉트를 직접 따라간다(GitHub→objects.githubusercontent 등). */
|
||||||
|
function download(url: string, dest: string, redirectsLeft = 5): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https.get(
|
||||||
|
url,
|
||||||
|
{ headers: { 'User-Agent': 'make-video-site' } },
|
||||||
|
(res) => {
|
||||||
|
const status = res.statusCode || 0
|
||||||
|
if (status >= 300 && status < 400 && res.headers.location) {
|
||||||
|
res.resume()
|
||||||
|
if (redirectsLeft <= 0) {
|
||||||
|
reject(new Error('리다이렉트 한도 초과'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next = new URL(res.headers.location, url).toString()
|
||||||
|
download(next, dest, redirectsLeft - 1).then(resolve, reject)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (status !== 200) {
|
||||||
|
res.resume()
|
||||||
|
reject(new Error('HTTP ' + status + ' — ' + url))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const out = createWriteStream(dest)
|
||||||
|
res.pipe(out)
|
||||||
|
out.on('finish', () => out.close(() => resolve()))
|
||||||
|
out.on('error', reject)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
req.on('error', reject)
|
||||||
|
req.setTimeout(180000, () => {
|
||||||
|
req.destroy(new Error('다운로드 타임아웃'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertLinux(): void {
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
throw new Error('자동 재설치는 Linux 런타임에서만 지원합니다. 현재: ' + process.platform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 최신 yt-dlp 를 data/bin/yt-dlp 로 내려받아 교체한다. */
|
||||||
|
export async function updateYtDlp(): Promise<ToolStatus> {
|
||||||
|
assertLinux()
|
||||||
|
const asset = process.arch === 'arm64' ? 'yt-dlp_linux_aarch64' : 'yt-dlp_linux'
|
||||||
|
const url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`
|
||||||
|
await fs.mkdir(binDir, { recursive: true })
|
||||||
|
const tmp = path.join(binDir, 'yt-dlp.download')
|
||||||
|
const dest = path.join(binDir, 'yt-dlp')
|
||||||
|
await fs.rm(tmp, { force: true })
|
||||||
|
await download(url, tmp)
|
||||||
|
await fs.chmod(tmp, 0o755)
|
||||||
|
// 검증: 내려받은 바이너리가 실제로 실행되는지 확인 후에만 교체.
|
||||||
|
const v = spawnSync(tmp, ['--version'], { encoding: 'utf8' })
|
||||||
|
if (v.status !== 0) {
|
||||||
|
await fs.rm(tmp, { force: true })
|
||||||
|
throw new Error('내려받은 yt-dlp 가 실행되지 않습니다.')
|
||||||
|
}
|
||||||
|
await fs.rename(tmp, dest)
|
||||||
|
resetYtDlpResolution()
|
||||||
|
return ytDlpStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 추출 디렉토리에서 지정한 이름의 파일을 재귀로 찾는다. */
|
||||||
|
async function findBinaries(
|
||||||
|
root: string,
|
||||||
|
names: string[]
|
||||||
|
): Promise<Record<string, string | null>> {
|
||||||
|
const result: Record<string, string | null> = {}
|
||||||
|
for (const n of names) result[n] = null
|
||||||
|
async function walk(dir: string): Promise<void> {
|
||||||
|
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||||
|
for (const e of entries) {
|
||||||
|
const full = path.join(dir, e.name)
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
await walk(full)
|
||||||
|
} else if (names.includes(e.name) && result[e.name] === null) {
|
||||||
|
result[e.name] = full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await walk(root)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최신 ffmpeg 정적 빌드(johnvansickle)를 받아 ffmpeg/ffprobe 를 data/bin 으로 교체한다.
|
||||||
|
* 압축 해제에 xz(tar -J)가 필요하다 — 런타임 이미지에 xz-utils 가 포함돼야 한다.
|
||||||
|
*/
|
||||||
|
export async function updateFfmpeg(): Promise<ToolStatus> {
|
||||||
|
assertLinux()
|
||||||
|
const arch =
|
||||||
|
process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'amd64' : null
|
||||||
|
if (!arch) throw new Error('지원하지 않는 아키텍처: ' + process.arch)
|
||||||
|
const url = `https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-${arch}-static.tar.xz`
|
||||||
|
await fs.mkdir(binDir, { recursive: true })
|
||||||
|
const work = path.join(binDir, 'ffmpeg-dl')
|
||||||
|
await fs.rm(work, { recursive: true, force: true })
|
||||||
|
await fs.mkdir(work, { recursive: true })
|
||||||
|
try {
|
||||||
|
const tarPath = path.join(work, 'ffmpeg.tar.xz')
|
||||||
|
await download(url, tarPath)
|
||||||
|
const ex = spawnSync('tar', ['-xJf', tarPath, '-C', work], { encoding: 'utf8' })
|
||||||
|
if (ex.status !== 0) {
|
||||||
|
throw new Error('압축 해제 실패(xz 필요): ' + (ex.stderr || ex.error?.message || '').trim())
|
||||||
|
}
|
||||||
|
const found = await findBinaries(work, ['ffmpeg', 'ffprobe'])
|
||||||
|
if (!found.ffmpeg) throw new Error('압축에서 ffmpeg 바이너리를 찾지 못했습니다.')
|
||||||
|
if (!found.ffprobe) throw new Error('압축에서 ffprobe 바이너리를 찾지 못했습니다.')
|
||||||
|
// 1) 임시 위치(*.new)로 복사 + 실행 검증. 둘 다 통과해야만 교체한다.
|
||||||
|
const staged: Array<{ name: 'ffmpeg' | 'ffprobe'; tmp: string; dest: string }> = []
|
||||||
|
for (const name of ['ffmpeg', 'ffprobe'] as const) {
|
||||||
|
const src = found[name]
|
||||||
|
if (!src) continue
|
||||||
|
const dest = path.join(binDir, name)
|
||||||
|
const tmp = dest + '.new'
|
||||||
|
await fs.rm(tmp, { force: true })
|
||||||
|
await fs.copyFile(src, tmp)
|
||||||
|
await fs.chmod(tmp, 0o755)
|
||||||
|
const v = spawnSync(tmp, ['-version'], { encoding: 'utf8' })
|
||||||
|
if (v.status !== 0) {
|
||||||
|
await fs.rm(tmp, { force: true })
|
||||||
|
throw new Error('내려받은 ' + name + ' 가 실행되지 않습니다.')
|
||||||
|
}
|
||||||
|
staged.push({ name, tmp, dest })
|
||||||
|
}
|
||||||
|
// 2) 검증을 모두 통과한 뒤에만 원자적 교체(rename).
|
||||||
|
for (const s of staged) await fs.rename(s.tmp, s.dest)
|
||||||
|
} finally {
|
||||||
|
await fs.rm(work, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
resetFfmpegResolution()
|
||||||
|
return ffmpegStatus()
|
||||||
|
}
|
||||||
183
src/youtube.ts
183
src/youtube.ts
@@ -1,14 +1,16 @@
|
|||||||
import { spawn, spawnSync } from 'node:child_process'
|
import { spawn, spawnSync } from 'node:child_process'
|
||||||
import { promises as fs } from 'node:fs'
|
import { promises as fs } from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { jobsDir, projectRoot } from './paths.js'
|
import { binDir, jobsDir, projectRoot } from './paths.js'
|
||||||
import { upscaleOriginalTo60Fps } from './editor.js'
|
import { getCookieArgs, hasCookies } from './cookies.js'
|
||||||
|
import { upscaleOriginalTo60Fps, applyTrimToVideo } from './editor.js'
|
||||||
import {
|
import {
|
||||||
createVideo,
|
createVideo,
|
||||||
getFolder,
|
getFolder,
|
||||||
getVideo,
|
getVideoInFolder,
|
||||||
updateVideo,
|
updateVideo,
|
||||||
videoDiskDir
|
videoDiskDir,
|
||||||
|
type VideoTrim
|
||||||
} from './storeDb.js'
|
} from './storeDb.js'
|
||||||
|
|
||||||
export class YtDlpUnavailableError extends Error {
|
export class YtDlpUnavailableError extends Error {
|
||||||
@@ -21,13 +23,18 @@ let resolvedYtDlpPath: string | null = null
|
|||||||
|
|
||||||
export function getYtDlpPath(): string {
|
export function getYtDlpPath(): string {
|
||||||
if (resolvedYtDlpPath) return resolvedYtDlpPath
|
if (resolvedYtDlpPath) return resolvedYtDlpPath
|
||||||
// 1) 프로젝트 bin/yt-dlp(.exe)
|
// 0) data/bin/yt-dlp — 런타임에 최신으로 재설치한 바이너리 (가장 우선, 볼륨 영속)
|
||||||
const localCandidates =
|
// 1) 프로젝트 bin/yt-dlp(.exe) — 이미지에 포함된 기본 바이너리
|
||||||
|
const candidates =
|
||||||
process.platform === 'win32'
|
process.platform === 'win32'
|
||||||
? ['bin/yt-dlp.exe', 'bin/yt-dlp']
|
? [
|
||||||
: ['bin/yt-dlp']
|
path.join(binDir, 'yt-dlp.exe'),
|
||||||
for (const rel of localCandidates) {
|
path.join(binDir, 'yt-dlp'),
|
||||||
const abs = path.join(projectRoot, rel)
|
path.join(projectRoot, 'bin/yt-dlp.exe'),
|
||||||
|
path.join(projectRoot, 'bin/yt-dlp')
|
||||||
|
]
|
||||||
|
: [path.join(binDir, 'yt-dlp'), path.join(projectRoot, 'bin/yt-dlp')]
|
||||||
|
for (const abs of candidates) {
|
||||||
const r = spawnSync(abs, ['--version'])
|
const r = spawnSync(abs, ['--version'])
|
||||||
if (r.status === 0) {
|
if (r.status === 0) {
|
||||||
resolvedYtDlpPath = abs
|
resolvedYtDlpPath = abs
|
||||||
@@ -43,6 +50,27 @@ export function getYtDlpPath(): string {
|
|||||||
throw new YtDlpUnavailableError()
|
throw new YtDlpUnavailableError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** data/bin 에 새 yt-dlp 를 설치한 뒤 캐시된 해석 결과를 비운다. */
|
||||||
|
export function resetYtDlpResolution(): void {
|
||||||
|
resolvedYtDlpPath = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* yt-dlp stderr 가 연령/로그인 확인을 요구하면 사용자 친화 메시지로 바꾼다.
|
||||||
|
* 그 외에는 원본 stderr 를 그대로 돌려준다.
|
||||||
|
*/
|
||||||
|
function friendlyYtDlpError(stderr: string, fallback: string): string {
|
||||||
|
const text = stderr.trim()
|
||||||
|
if (/Sign in to confirm your age|confirm your age|inappropriate for some users|Sign in to confirm you'?re not a bot|members[- ]only|account.+cookies/i.test(text)) {
|
||||||
|
const base =
|
||||||
|
'이 영상은 연령 제한/로그인이 필요합니다. 관리자 대시보드의 "YouTube 쿠키" 에서 로그인된 계정의 cookies.txt 를 등록한 뒤 다시 시도해 주세요.'
|
||||||
|
return hasCookies()
|
||||||
|
? base + ' (현재 등록된 쿠키로는 이 영상을 볼 수 없습니다 — 해당 영상을 볼 수 있는 계정의 쿠키가 필요합니다.)'
|
||||||
|
: base
|
||||||
|
}
|
||||||
|
return text || fallback
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProbeResult {
|
export interface ProbeResult {
|
||||||
title: string
|
title: string
|
||||||
durationSec: number
|
durationSec: number
|
||||||
@@ -59,6 +87,7 @@ export async function probeYoutube(url: string): Promise<ProbeResult> {
|
|||||||
return new Promise<ProbeResult>((resolve, reject) => {
|
return new Promise<ProbeResult>((resolve, reject) => {
|
||||||
const child = spawn(bin, [
|
const child = spawn(bin, [
|
||||||
'--no-warnings',
|
'--no-warnings',
|
||||||
|
...getCookieArgs(),
|
||||||
'--skip-download',
|
'--skip-download',
|
||||||
'--print',
|
'--print',
|
||||||
'%(title)s\n%(duration)s\n%(filesize_approx)s'
|
'%(title)s\n%(duration)s\n%(filesize_approx)s'
|
||||||
@@ -71,7 +100,7 @@ export async function probeYoutube(url: string): Promise<ProbeResult> {
|
|||||||
child.on('error', (err) => reject(err))
|
child.on('error', (err) => reject(err))
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
reject(new Error(stderr.trim() || `yt-dlp probe 실패 (code=${code})`))
|
reject(new Error(friendlyYtDlpError(stderr, `yt-dlp probe 실패 (code=${code})`)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const lines = stdout.trim().split('\n')
|
const lines = stdout.trim().split('\n')
|
||||||
@@ -114,6 +143,7 @@ export async function probeYoutubePlaylist(url: string): Promise<PlaylistProbeRe
|
|||||||
// --flat-playlist + --dump-json 한 줄당 한 JSON 항목.
|
// --flat-playlist + --dump-json 한 줄당 한 JSON 항목.
|
||||||
const child = spawn(bin, [
|
const child = spawn(bin, [
|
||||||
'--no-warnings',
|
'--no-warnings',
|
||||||
|
...getCookieArgs(),
|
||||||
'--flat-playlist',
|
'--flat-playlist',
|
||||||
'--dump-json',
|
'--dump-json',
|
||||||
url
|
url
|
||||||
@@ -126,7 +156,7 @@ export async function probeYoutubePlaylist(url: string): Promise<PlaylistProbeRe
|
|||||||
child.on('error', (err) => reject(err))
|
child.on('error', (err) => reject(err))
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
reject(new Error(stderr.trim() || `yt-dlp playlist probe 실패 (code=${code})`))
|
reject(new Error(friendlyYtDlpError(stderr, `yt-dlp playlist probe 실패 (code=${code})`)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const entries: PlaylistEntry[] = []
|
const entries: PlaylistEntry[] = []
|
||||||
@@ -185,6 +215,8 @@ export interface DownloadJob {
|
|||||||
folderId: number
|
folderId: number
|
||||||
videoId: string
|
videoId: string
|
||||||
url: string
|
url: string
|
||||||
|
/** 영상 제목. 재시도 시 레코드가 지워졌을 때 복원용으로도 쓴다. */
|
||||||
|
title: string
|
||||||
status: JobStatus
|
status: JobStatus
|
||||||
progress: number // 0..100
|
progress: number // 0..100
|
||||||
message: string
|
message: string
|
||||||
@@ -192,6 +224,10 @@ export interface DownloadJob {
|
|||||||
finishedAt: string | null
|
finishedAt: string | null
|
||||||
outputFile: string | null
|
outputFile: string | null
|
||||||
error: string | null
|
error: string | null
|
||||||
|
/** 다운로드 완료 후 적용할 자르기 구간. null 이면 자르기 없음(전체). */
|
||||||
|
trim: VideoTrim | null
|
||||||
|
/** 이 (실패한) 잡을 재시도해 만든 새 잡 id. 한 잡당 재시도 1회만 허용하는 가드. */
|
||||||
|
retriedBy: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobs = new Map<string, DownloadJob>()
|
const jobs = new Map<string, DownloadJob>()
|
||||||
@@ -266,6 +302,10 @@ export interface StartDownloadOpts {
|
|||||||
title?: string
|
title?: string
|
||||||
/** 호출자가 영상 ID 를 지정 (플레이리스트 일괄 등록용). 미지정 시 랜덤 생성. */
|
/** 호출자가 영상 ID 를 지정 (플레이리스트 일괄 등록용). 미지정 시 랜덤 생성. */
|
||||||
videoId?: string
|
videoId?: string
|
||||||
|
/** 다운로드 후 적용할 자르기 시작 초 (기본 0). */
|
||||||
|
startSec?: number
|
||||||
|
/** 다운로드 후 적용할 자르기 종료 초. null/미지정 이면 끝까지. */
|
||||||
|
endSec?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -288,24 +328,107 @@ export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<Dow
|
|||||||
sourceUrl: opts.url
|
sourceUrl: opts.url
|
||||||
})
|
})
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
// 자르기 구간 정규화. start>0 이거나 end 가 지정된 경우에만 의미가 있다.
|
||||||
const job: DownloadJob = {
|
const startSec = Math.max(0, Number(opts.startSec) || 0)
|
||||||
id: 'job-' + Math.random().toString(36).slice(2, 14),
|
const endSecRaw = opts.endSec == null ? null : Number(opts.endSec)
|
||||||
|
const endSec =
|
||||||
|
endSecRaw != null && Number.isFinite(endSecRaw) && endSecRaw > startSec
|
||||||
|
? endSecRaw
|
||||||
|
: null
|
||||||
|
const trim: VideoTrim | null =
|
||||||
|
startSec > 0.01 || endSec != null ? { startSec, endSec } : null
|
||||||
|
|
||||||
|
const job = enqueueJob({
|
||||||
folderId: opts.folderId,
|
folderId: opts.folderId,
|
||||||
videoId: video.id,
|
videoId: video.id,
|
||||||
url: opts.url,
|
url: opts.url,
|
||||||
|
title: video.title,
|
||||||
|
trim
|
||||||
|
})
|
||||||
|
// 같은 tick 에서 여러 startYoutubeDownload 가 연달아 호출될 수 있으니
|
||||||
|
// pump 는 다음 tick 에 한 번만 돌아도 충분 (반복 호출이 안전하긴 함).
|
||||||
|
setImmediate(pump)
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DownloadJob 을 만들어 jobs 맵에 등록하고 디스크에 기록한다 (pump 는 호출자 책임). */
|
||||||
|
function enqueueJob(p: {
|
||||||
|
folderId: number
|
||||||
|
videoId: string
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
trim: VideoTrim | null
|
||||||
|
}): DownloadJob {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const job: DownloadJob = {
|
||||||
|
id: 'job-' + Math.random().toString(36).slice(2, 14),
|
||||||
|
folderId: p.folderId,
|
||||||
|
videoId: p.videoId,
|
||||||
|
url: p.url,
|
||||||
|
title: p.title,
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
message: '대기 중',
|
message: '대기 중',
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
finishedAt: null,
|
finishedAt: null,
|
||||||
outputFile: null,
|
outputFile: null,
|
||||||
error: null
|
error: null,
|
||||||
|
trim: p.trim,
|
||||||
|
retriedBy: null
|
||||||
}
|
}
|
||||||
jobs.set(job.id, job)
|
jobs.set(job.id, job)
|
||||||
void persistJob(job)
|
void persistJob(job)
|
||||||
// 같은 tick 에서 여러 startYoutubeDownload 가 연달아 호출될 수 있으니
|
return job
|
||||||
// pump 는 다음 tick 에 한 번만 돌아도 충분 (반복 호출이 안전하긴 함).
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실패한 다운로드를 같은 영상 ID·URL·자르기 설정으로 다시 시도한다.
|
||||||
|
*
|
||||||
|
* 핵심: 첫 시도에서 이미 만들어진 영상 레코드를 재사용해 같은 디렉터리로 다시 받는다.
|
||||||
|
* (createVideo 를 다시 부르면 UNIQUE(folder_id,id) 에 걸려 "이미 사용 중" 으로 막히므로,
|
||||||
|
* 레코드가 남아 있으면 건드리지 않고, 사용자가 지웠을 때만 같은 ID 로 복원한다.)
|
||||||
|
*/
|
||||||
|
export async function retryDownloadJob(jobId: string): Promise<DownloadJob> {
|
||||||
|
const prev = jobs.get(jobId)
|
||||||
|
if (!prev) throw new Error('원본 작업을 찾을 수 없습니다.')
|
||||||
|
if (prev.status !== 'error') {
|
||||||
|
throw new Error('실패한 작업만 다시 시도할 수 있습니다.')
|
||||||
|
}
|
||||||
|
// 한 잡당 재시도 1회만. 같은 실패 잡으로 retry 가 동시에 여러 번 들어오면
|
||||||
|
// 같은 디렉터리로 yt-dlp 가 여럿 떠 파일이 깨진다. 아래 sync 검증을 통과한
|
||||||
|
// 직후(첫 await 전에) 즉시 표시해 두 번째 호출이 곧바로 거절되게 한다.
|
||||||
|
if (prev.retriedBy) throw new Error('이미 재시도한 작업입니다. 새로 생성된 작업에서 다시 시도해 주세요.')
|
||||||
|
if (!getFolder(prev.folderId)) throw new Error('폴더를 찾을 수 없습니다.')
|
||||||
|
// yt-dlp 가 없으면 다시 시도해도 실패하므로 사전에 던진다.
|
||||||
|
getYtDlpPath()
|
||||||
|
const existing = getVideoInFolder(prev.folderId, prev.videoId)
|
||||||
|
// 사용자가 실패 카드를 지운 뒤 같은 ID 로 다른 영상을 추가했다면, 그 영상 디렉터리에
|
||||||
|
// 옛 URL 을 덮어쓰면 안 된다. 남아 있는 레코드가 이 잡과 같은 영상일 때만 재시도한다.
|
||||||
|
if (existing && (existing.sourceType !== 'youtube' || existing.sourceUrl !== prev.url)) {
|
||||||
|
throw new Error('이 영상 ID 는 다른 영상으로 교체되어 재시도할 수 없습니다.')
|
||||||
|
}
|
||||||
|
// 검증 통과 — 이 시점부터 같은 잡의 중복 재시도를 막는다 (createVideo await 이전).
|
||||||
|
prev.retriedBy = 'pending'
|
||||||
|
if (!existing) {
|
||||||
|
// 사용자가 실패 카드를 지웠다면 같은 ID 로 레코드를 다시 만든다.
|
||||||
|
await createVideo({
|
||||||
|
id: prev.videoId,
|
||||||
|
folderId: prev.folderId,
|
||||||
|
title: prev.title || '제목 없음',
|
||||||
|
originalFile: 'original.%(ext)s',
|
||||||
|
sourceType: 'youtube',
|
||||||
|
sourceUrl: prev.url
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const job = enqueueJob({
|
||||||
|
folderId: prev.folderId,
|
||||||
|
videoId: prev.videoId,
|
||||||
|
url: prev.url,
|
||||||
|
title: prev.title,
|
||||||
|
trim: prev.trim
|
||||||
|
})
|
||||||
|
prev.retriedBy = job.id
|
||||||
|
void persistJob(prev)
|
||||||
setImmediate(pump)
|
setImmediate(pump)
|
||||||
return job
|
return job
|
||||||
}
|
}
|
||||||
@@ -315,11 +438,12 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
|||||||
job.message = '다운로드 시작'
|
job.message = '다운로드 시작'
|
||||||
await persistJob(job)
|
await persistJob(job)
|
||||||
|
|
||||||
const dir = videoDiskDir(job.videoId)
|
const dir = videoDiskDir(job.folderId, job.videoId)
|
||||||
// yt-dlp 가 다운로드 후 결정한 실제 파일명을 알려주도록 --print after_move:filepath
|
// yt-dlp 가 다운로드 후 결정한 실제 파일명을 알려주도록 --print after_move:filepath
|
||||||
// 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱.
|
// 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱.
|
||||||
const args = [
|
const args = [
|
||||||
'--no-warnings',
|
'--no-warnings',
|
||||||
|
...getCookieArgs(),
|
||||||
'--no-playlist',
|
'--no-playlist',
|
||||||
'--newline',
|
'--newline',
|
||||||
// 해상도 캡: FHD(≤1080p) 안에서만 비디오를 선택. FHD 가 아예 없는 영상은
|
// 해상도 캡: FHD(≤1080p) 안에서만 비디오를 선택. FHD 가 아예 없는 영상은
|
||||||
@@ -398,7 +522,7 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
|||||||
child.on('error', (err) => reject(err))
|
child.on('error', (err) => reject(err))
|
||||||
child.on('close', async (code) => {
|
child.on('close', async (code) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
reject(new Error(stderrTail.trim() || `yt-dlp 실패 (code=${code})`))
|
reject(new Error(friendlyYtDlpError(stderrTail, `yt-dlp 실패 (code=${code})`)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 실제 파일명 결정
|
// 실제 파일명 결정
|
||||||
@@ -470,10 +594,25 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
|||||||
console.error('[youtube] 60fps 후처리 실패:', err)
|
console.error('[youtube] 60fps 후처리 실패:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = getVideo(job.videoId)
|
const meta = getVideoInFolder(job.folderId, job.videoId)
|
||||||
if (meta) {
|
if (meta) {
|
||||||
updateVideo(job.videoId, { originalFile: finalName })
|
updateVideo(job.folderId, job.videoId, { originalFile: finalName })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 사용자가 지정한 자르기 구간이 있으면 다운로드/60fps 후처리가 끝난 원본에
|
||||||
|
// ffmpeg 로 적용해 edited.<ext> 를 만든다. 실패해도 원본은 그대로 두고
|
||||||
|
// 다운로드 자체는 성공으로 본다 (썸네일 후처리와 동일한 정책).
|
||||||
|
if (job.trim) {
|
||||||
|
try {
|
||||||
|
job.message = '자르기 적용 중'
|
||||||
|
await persistJob(job)
|
||||||
|
await applyTrimToVideo(job.folderId, job.videoId, job.trim)
|
||||||
|
job.message = '자르기 완료'
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[youtube] 자르기 적용 실패:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
job.progress = 100
|
job.progress = 100
|
||||||
job.status = 'done'
|
job.status = 'done'
|
||||||
job.message = '완료'
|
job.message = '완료'
|
||||||
|
|||||||
@@ -51,7 +51,10 @@
|
|||||||
%>
|
%>
|
||||||
<a class="videoCard" href="<%= shareUrl %>"
|
<a class="videoCard" href="<%= shareUrl %>"
|
||||||
data-video-id="<%= v.id %>" data-share-url="<%= shareUrl %>">
|
data-video-id="<%= v.id %>" data-share-url="<%= shareUrl %>">
|
||||||
<div class="videoThumb">▶</div>
|
<div class="videoThumb">
|
||||||
|
<img class="videoThumbImg" src="/file/video/<%= folderPathEnc %>/<%= encodeURIComponent(v.id) %>/thumb" alt="" loading="lazy" onerror="this.style.display='none'" />
|
||||||
|
<span class="videoThumbPlay">▶</span>
|
||||||
|
</div>
|
||||||
<div class="videoTitle"><%= v.title %></div>
|
<div class="videoTitle"><%= v.title %></div>
|
||||||
</a>
|
</a>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
|
|||||||
@@ -30,6 +30,52 @@
|
|||||||
</div>
|
</div>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="toolsSection">
|
||||||
|
<h2>도구 관리</h2>
|
||||||
|
<p class="muted">영상 다운로드·변환에 쓰는 외부 도구입니다. 너무 예전 버전이라 오류가 나거나 다운로드가 안 되면 최신 버전으로 재설치하세요. (Linux 런타임 기준)</p>
|
||||||
|
<div class="toolGrid">
|
||||||
|
<div class="toolCard" data-tool="yt-dlp">
|
||||||
|
<div class="toolHead">
|
||||||
|
<span class="toolName">yt-dlp</span>
|
||||||
|
<span class="toolVersion" data-field="version">확인 중…</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolPath muted" data-field="path"></div>
|
||||||
|
<div class="toolRow">
|
||||||
|
<button type="button" class="secondaryButton" data-action="update-tool" data-name="yt-dlp">최신으로 재설치</button>
|
||||||
|
<span class="toolStatus muted" data-field="status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="toolCard" data-tool="ffmpeg">
|
||||||
|
<div class="toolHead">
|
||||||
|
<span class="toolName">ffmpeg</span>
|
||||||
|
<span class="toolVersion" data-field="version">확인 중…</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolPath muted" data-field="path"></div>
|
||||||
|
<div class="toolRow">
|
||||||
|
<button type="button" class="secondaryButton" data-action="update-tool" data-name="ffmpeg">최신으로 재설치</button>
|
||||||
|
<span class="toolStatus muted" data-field="status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="toolsSection" id="cookiesSection">
|
||||||
|
<h2>YouTube 쿠키</h2>
|
||||||
|
<p class="muted">연령 제한("Sign in to confirm your age") 영상은 로그인된 계정의 쿠키가 있어야 다운로드됩니다. 브라우저 확장(예: "Get cookies.txt LOCALLY")으로 내보낸 Netscape 형식 <code>cookies.txt</code> 를 등록하세요. 단일 영상·플레이리스트·실제 다운로드에 모두 적용됩니다.</p>
|
||||||
|
<div class="toolCard">
|
||||||
|
<div class="toolHead">
|
||||||
|
<span class="toolName">cookies.txt</span>
|
||||||
|
<span class="toolVersion" id="cookieStatus">확인 중…</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolRow">
|
||||||
|
<input type="file" id="cookieFile" accept=".txt,text/plain" />
|
||||||
|
<button type="button" class="secondaryButton" id="cookieUploadBtn">등록 / 교체</button>
|
||||||
|
<button type="button" class="dangerLink" id="cookieDeleteBtn" hidden>삭제</button>
|
||||||
|
<span class="toolStatus muted" id="cookieMsg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div class="ctxMenu" id="ctxMenu" hidden>
|
<div class="ctxMenu" id="ctxMenu" hidden>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<video id="editVideo" controls preload="metadata"
|
<video id="editVideo" controls preload="metadata"
|
||||||
<% if (video) { %>src="/api/video/<%= encodeURIComponent(video.id) %>/file?edited=0"<% } %>></video>
|
<% if (video) { %>src="/api/video/<%= folderPathEnc %>/<%= encodeURIComponent(video.id) %>?edited=0"<% } %>></video>
|
||||||
|
|
||||||
<div class="trimTimeline">
|
<div class="trimTimeline">
|
||||||
<div class="trimTimelineHeader">
|
<div class="trimTimelineHeader">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
var parentLabel = isSubFolder ? '← ' + breadcrumb[0] : '← 폴더 목록'
|
var parentLabel = isSubFolder ? '← ' + breadcrumb[0] : '← 폴더 목록'
|
||||||
var editorHref = '/op/folder/' + folderPathEnc + '/video/editor'
|
var editorHref = '/op/folder/' + folderPathEnc + '/video/editor'
|
||||||
var playlistHref = '/op/folder/' + folderPathEnc + '/playlist/new'
|
var playlistHref = '/op/folder/' + folderPathEnc + '/playlist/new'
|
||||||
|
var listTxtHref = '/op/folder/' + folderPathEnc + '/list.txt'
|
||||||
%>
|
%>
|
||||||
|
|
||||||
<main class="pageWrap">
|
<main class="pageWrap">
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
<a class="primaryButton" href="<%= editorHref %>">영상 추가</a>
|
<a class="primaryButton" href="<%= editorHref %>">영상 추가</a>
|
||||||
<% if (isSubFolder) { %>
|
<% if (isSubFolder) { %>
|
||||||
<a class="primaryButton" href="<%= playlistHref %>">플레이리스트 추가</a>
|
<a class="primaryButton" href="<%= playlistHref %>">플레이리스트 추가</a>
|
||||||
|
<a class="primaryButton" href="<%= listTxtHref %>" download>목록 뽑기</a>
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -62,7 +64,10 @@
|
|||||||
data-video-id="<%= v.id %>"
|
data-video-id="<%= v.id %>"
|
||||||
data-title="<%= v.title %>"
|
data-title="<%= v.title %>"
|
||||||
data-share-url="<%= shareUrl %>">
|
data-share-url="<%= shareUrl %>">
|
||||||
<div class="videoThumb">▶</div>
|
<div class="videoThumb">
|
||||||
|
<img class="videoThumbImg" src="/file/video/<%= folderPathEnc %>/<%= encodeURIComponent(v.id) %>/thumb" alt="" loading="lazy" onerror="this.style.display='none'" />
|
||||||
|
<span class="videoThumbPlay">▶</span>
|
||||||
|
</div>
|
||||||
<div class="videoTitle"><%= v.title %></div>
|
<div class="videoTitle"><%= v.title %></div>
|
||||||
<% if (v.sourceType === 'youtube') { %>
|
<% if (v.sourceType === 'youtube') { %>
|
||||||
<div class="muted">YouTube</div>
|
<div class="muted">YouTube</div>
|
||||||
|
|||||||
@@ -20,13 +20,19 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="editorMain">
|
<main class="editorMain">
|
||||||
<section class="playlistProbeSection">
|
<section class="playlistProbeSection" id="probeSection">
|
||||||
<p class="muted">유튜브 플레이리스트 주소를 입력하면 항목 목록을 불러옵니다.</p>
|
<p class="muted">유튜브 플레이리스트 주소를 입력하면 항목 목록을 불러옵니다.</p>
|
||||||
<div class="playlistProbeRow">
|
<div class="playlistProbeRow">
|
||||||
<input type="text" id="playlistUrl" placeholder="https://www.youtube.com/playlist?list=..." />
|
<input type="text" id="playlistUrl" placeholder="https://www.youtube.com/playlist?list=..." />
|
||||||
<button type="button" class="primaryButton" id="probeBtn">목록 불러오기</button>
|
<button type="button" class="primaryButton" id="probeBtn">목록 불러오기</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="probeStatus" class="muted"></p>
|
<p id="probeStatus" class="muted"></p>
|
||||||
|
<div class="playlistDraftRow">
|
||||||
|
<button type="button" class="secondaryButton" id="draftSaveBtn">임시저장</button>
|
||||||
|
<button type="button" class="secondaryButton" id="draftLoadBtn" hidden>불러오기</button>
|
||||||
|
<button type="button" class="secondaryButton" id="draftDeleteBtn" hidden>임시저장 삭제</button>
|
||||||
|
<span id="draftStatus" class="muted"></span>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="playlistEntriesSection" id="entriesSection" hidden>
|
<section class="playlistEntriesSection" id="entriesSection" hidden>
|
||||||
@@ -39,6 +45,13 @@
|
|||||||
<input type="checkbox" id="zeroPadToggle" /> 자릿수 채우기 (예: 001)
|
<input type="checkbox" id="zeroPadToggle" /> 자릿수 채우기 (예: 001)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="bulkTrimBox">
|
||||||
|
<span class="idModeLabel">일괄 구간</span>
|
||||||
|
<label>시작 <input type="text" id="bulkStart" placeholder="0:00" /></label>
|
||||||
|
<label>길이(초) <input type="text" id="bulkLength" placeholder="15" /></label>
|
||||||
|
<button type="button" class="secondaryButton" id="bulkApplyBtn">적용</button>
|
||||||
|
<button type="button" class="secondaryButton" id="bulkResetBtn">초기화</button>
|
||||||
|
</div>
|
||||||
<div class="playlistSummary">
|
<div class="playlistSummary">
|
||||||
<span id="entriesCount" class="muted">0 항목</span>
|
<span id="entriesCount" class="muted">0 항목</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,14 +64,77 @@
|
|||||||
<button type="button" class="primaryButton" id="registerBtn">등록 & 다운로드 시작</button>
|
<button type="button" class="primaryButton" id="registerBtn">등록 & 다운로드 시작</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="playlistProgressSection" id="progressSection" hidden>
|
||||||
|
<div class="progressHeader">
|
||||||
|
<span class="idModeLabel" id="progressSummary">다운로드 진행 중…</span>
|
||||||
|
<progress id="progressOverall" value="0" max="100"></progress>
|
||||||
|
</div>
|
||||||
|
<ol class="progressList" id="progressList"></ol>
|
||||||
|
<div class="playlistFooter">
|
||||||
|
<p id="progressStatus" class="muted">페이지를 닫아도 다운로드는 백그라운드에서 계속됩니다.</p>
|
||||||
|
<a class="primaryButton" id="progressDoneBtn" href="<%= folderHref %>" hidden>폴더로 이동</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- 항목 우클릭 메뉴 -->
|
||||||
|
<div class="ctxMenu" id="entryCtxMenu" hidden>
|
||||||
|
<button type="button" data-action="trim">자르기</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 미리보기 재생 모달 -->
|
||||||
|
<div class="playerOverlay" id="previewOverlay" hidden>
|
||||||
|
<div class="playerModal" role="dialog" aria-modal="true">
|
||||||
|
<button type="button" class="playerClose" id="previewClose" aria-label="닫기">✕</button>
|
||||||
|
<div class="playerTitle" id="previewTitle"></div>
|
||||||
|
<div class="ytEmbedWrap"><div id="previewPlayer"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 자르기(트림) 모달 — /video/editor 타임라인을 YouTube 임베드로 재현 -->
|
||||||
|
<div class="playerOverlay" id="trimOverlay" hidden>
|
||||||
|
<div class="playerModal trimModal" role="dialog" aria-modal="true">
|
||||||
|
<button type="button" class="playerClose" id="trimClose" aria-label="닫기">✕</button>
|
||||||
|
<div class="playerTitle" id="trimTitle"></div>
|
||||||
|
<div class="ytEmbedWrap"><div id="trimPlayer"></div></div>
|
||||||
|
<div class="trimTimeline">
|
||||||
|
<div class="trimTimelineHeader">
|
||||||
|
<span class="timeReadout" id="plTimeReadout">00:00.0 / 00:00.0</span>
|
||||||
|
<div class="trimTimelineActions">
|
||||||
|
<button type="button" data-pmark="start" title="현재 시점을 시작점으로">[ 시작점</button>
|
||||||
|
<button type="button" data-pmark="end" title="현재 시점을 끝점으로">끝점 ]</button>
|
||||||
|
<button type="button" data-paction="playSelection" title="선택 구간만 재생">▶ 선택 재생</button>
|
||||||
|
<button type="button" data-paction="reset" title="자르기 초기화">초기화</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="trimBar" id="plTrimBar">
|
||||||
|
<div class="trimRangeFill" id="plTrimRangeFill"></div>
|
||||||
|
<div class="trimHandle trimHandleStart" id="plTrimHandleStart" data-phandle="start" title="시작 핸들"></div>
|
||||||
|
<div class="trimHandle trimHandleEnd" id="plTrimHandleEnd" data-phandle="end" title="끝 핸들"></div>
|
||||||
|
<div class="trimPlayhead" id="plTrimPlayhead"></div>
|
||||||
|
</div>
|
||||||
|
<div class="trimNumericRow">
|
||||||
|
<label>시작(초) <input type="number" id="plStartSec" step="0.1" min="0" value="0" /></label>
|
||||||
|
<span class="trimDuration" id="plTrimDuration">선택: 0.0초</span>
|
||||||
|
<label>길이(초) <input type="number" id="plLengthSec" step="0.1" min="0.1" value="" /></label>
|
||||||
|
</div>
|
||||||
|
<p class="muted">재생바의 파란 핸들을 끌어 구간을 정합니다. 적용하면 항목의 시작/길이가 갱신되고, 다운로드 완료 후 이 구간으로 잘립니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modalActions">
|
||||||
|
<button type="button" class="secondaryButton" id="trimCancel">취소</button>
|
||||||
|
<button type="button" class="primaryButton" id="trimApply">적용</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
window.__PLAYLIST__ = {
|
window.__PLAYLIST__ = {
|
||||||
folderPath: <%- jsonForScript(breadcrumb) %>,
|
folderPath: <%- jsonForScript(breadcrumb) %>,
|
||||||
folderId: <%- jsonForScript(folder.id) %>
|
folderId: <%- jsonForScript(folder.id) %>
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script src="https://www.youtube.com/iframe_api"></script>
|
||||||
<script src="/static/playlist.js"></script>
|
<script src="/static/playlist.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="standalonePlayer">
|
<div class="standalonePlayer">
|
||||||
<video controls autoplay preload="metadata" src="/api/video/<%= encodeURIComponent(video.id) %>/file"></video>
|
<video controls autoplay preload="metadata" src="/api/video/<%= folderPathEnc %>/<%= encodeURIComponent(video.id) %>"></video>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user