Compare commits

10 Commits

Author SHA1 Message Date
Claude (owner)
692a824e78 fix: 목록 뽑기 — 주소 없는 영상 제외 + nocookie 도메인 인식
업로드 영상처럼 외부 source_url 이 없는 항목은 `[ID] [URL]` 두 칸
형식이 깨지므로 목록에서 제외한다. youtube-nocookie.com/embed/... 도
YouTube 로 인식해 https://youtu.be/ID 로 간결화한다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-14 01:55:33 +09:00
Claude (owner)
745b1db1cc feat: 하위 폴더에 "목록 뽑기" 추가 (영상 ID + 실제 주소 txt 다운로드)
플레이리스트 추가 버튼 옆에 "목록 뽑기"를 두어, 누르면
`<영상ID> <실제 영상 주소>` 형식의 txt 를 내려받는다. YouTube 는
watch?v=/embed/shorts/live/youtu.be 등 어떤 형식이든 11자 영상 ID 를
뽑아 https://youtu.be/ID 로 간결화하고, 그 외(네이버 등) source_url 은
원본 그대로 출력한다. 업로드 영상처럼 주소가 없으면 ID 만 남긴다.

GET /op/folder/:topName/:subName/list.txt (3세그먼트라 폴더 라우트와
충돌 없음). 파일명은 폴더 경로 기반, RFC5987 UTF-8 + ASCII 폴백.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-14 01:50:54 +09:00
Claude (owner)
0abd21533b fix: 동시 영상 처리 시 공유 임시파일 레이스 제거 (고유 tmp 경로)
ensureThumbnail / upscaleOriginalTo60Fps / applyTrimToVideo 가 영상당
고정된 tmp 파일명(thumb.jpg.tmp.jpg, original.bump.tmp.mp4, edited.<ext>.tmp.<ext>)
을 써서, 같은 영상에 대한 요청이 동시에 들어오면 ffmpeg 프로세스들이 서로의
tmp 를 덮어쓰고 rename 이 경합해 일부 요청이 헛되이 실패했다.

증상: 같은 썸네일을 동시 10회 요청하면 6건만 성공하고 4건이 404.
원인: 호출마다 같은 tmp 경로 공유. 해결: randomUUID 로 호출별 고유 tmp 경로.
검증: 수정 후 동일 동시요청 10/10 성공, tmp 잔여물 없음.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 02:53:36 +09:00
Claude (owner)
584ec8b92b fix: harden youtube retry against data corruption
Add a one-shot retry guard (retriedBy) so concurrent retries of the
same failed job can't spawn multiple yt-dlp processes into the same
directory, and a sourceType/sourceUrl match check so retrying a job
whose video ID was repurposed for a different video is rejected
instead of overwriting it.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-06 18:18:02 +09:00
Claude (owner)
6850313c0b feat: 플레이리스트 실패 항목 재시도 버튼 + API
실패한 다운로드는 영상 레코드가 남아 같은 ID 재등록이 막혔고, 진행 화면의
"개별로 다시 시도" 안내에 실제 버튼이 없었다. 실패 행에 "재시도" 버튼을 추가하고
POST /op/job/:id/retry 로 같은 ID·URL·자르기 설정으로 새 잡을 시작한다.
retryDownloadJob 은 기존 영상 레코드를 재사용(레코드가 지워졌으면 같은 ID로 복원)
하므로 UNIQUE 충돌 없이 재시도된다. 연령 제한 영상은 쿠키 등록 후 재시도하면 받힌다.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-06 18:11:59 +09:00
Claude (owner)
6ade552683 feat: 연령제한 영상용 YouTube 쿠키(cookies.txt) 등록 + yt-dlp --cookies 적용
연령 제한("Sign in to confirm your age") 영상이 플레이리스트 임포트에서
실패하던 문제 해결. 관리자 대시보드에서 Netscape cookies.txt 를 등록하면
단일/플레이리스트 probe 와 실제 다운로드에 모두 --cookies 로 적용된다.
쿠키는 data/cookies.txt 에 0600 으로 저장(git 제외). 연령/로그인 실패 시
yt-dlp 원문 대신 쿠키 등록을 안내하는 메시지를 표시.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-06 18:04:25 +09:00
Claude (owner)
03e97d771d docs: 레거시 ID-only URL 문서/주석을 실제 동작에 맞게 정정
- README: 제거된 /api/video/:videoId/file 안내 삭제, /file/video/:videoId 만
  레거시 주소로 남기고 전역 유일할 때만 200 임을 명시
- public.ts 썸네일 fallback 주석을 "전역 첫 매치" → "전역에서 유일한 경우만"

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-06 16:37:59 +09:00
Claude (owner)
ff78b60a4b fix: 레거시 ID-only URL 은 전역 유일할 때만 제공하고 정규 경로 충돌 제거
폴더별 중복 ID 허용 이후, ID 만으로 영상을 찾는 레거시/공유 fallback
(getVideoByIdGlobal) 이 임의의 폴더(작은 folder_id) 를 돌려줘서 엉뚱한
영상이 나올 수 있었다. 전역에서 정확히 1건일 때만 반환하고, 중복이면
null → 호출 측 404 로 거절한다. 영향: /file/video/:id,
/file/video/:id/thumb, /api/video/:id(meta).

또한 죽은 레거시 라우트 /api/video/:videoId/file 를 제거했다. 이 라우트는
앱이 더는 만들지 않으면서 정규 경로 라우트 /api/video/:topName/:videoId 와
2세그먼트에서 충돌해, ID 가 리터럴 "file" 인 영상의 정규 URL 을 가로챘다.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-06 16:33:54 +09:00
Claude (owner)
042d9f7059 feat: 플레이리스트 추가 화면 임시저장(draft) 기능
다운로드 시작 전, 수정해둔 값(플레이리스트 URL + 항목별 제목/ID/시작/길이/
ID모드/자릿수채우기)을 폴더당 1개 저장하고 나중에 불러올 수 있게 함.

- db: playlist_drafts 테이블 (folder_id PK, JSON data, updated_at)
- storeDb: savePlaylistDraft/getPlaylistDraft/deletePlaylistDraft
- op: GET/POST/POST(delete) /playlist/draft (하위 폴더 한정)
- UI: 임시저장 / 불러오기 / 삭제 버튼 + 진입 시 기존 draft 자동 감지

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-06 16:22:42 +09:00
Claude (owner)
0ec6b06b57 feat: 영상 ID 를 폴더 안에서만 유일하게 (UNIQUE(folder_id, id))
폴더가 다르면 같은 영상 ID(예: 1,2,3,4)를 재사용할 수 있도록 변경.
ID 가 더 이상 전역 PK 가 아니므로 모든 영상 조회를 폴더 스코프로 전환:
- db: videos PK→UNIQUE(folder_id,id) + 기존 DB 자동 마이그레이션
- storeDb: getVideo→getVideoInFolder(folderId,id) + getVideoByIdGlobal 폴백
- public/op/editor/youtube: 재생·썸네일·편집·삭제·rename 모두 폴더 스코프
- 썸네일을 경로기반 URL(/file/video/<폴더>/<id>/thumb)로 전환
- 클라이언트 mutation 요청에 folderId 동봉

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-06 16:19:02 +09:00
18 changed files with 986 additions and 129 deletions

2
.gitignore vendored
View File

@@ -12,6 +12,8 @@ data/app.db
data/app.db-journal
data/app.db-shm
data/app.db-wal
data/bin/
data/cookies.txt
.env
!.env.example
*.log

View File

@@ -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 은 경고만 출력하고 빌드는 계속 진행됩니다.)
- `ffmpeg` / `ffprobe` — 영상 트림 저장, 60fps/FHD 변환, 메타 조회 (`PATH` 에 설치). 없으면 다운로드는 되지만 후처리가 건너뛰어집니다.
- 하드웨어 인코더(`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 에는 올라가지 않음). 등록된 쿠키로도 못 보는 영상이면 그 영상을 볼 수 있는 계정의 쿠키가 필요합니다.
## 데이터 위치
@@ -58,7 +59,7 @@ data/
- `/folder/:topName/:subName` — 2단계 폴더 (영상만 표시, 더 깊이 들어갈 수 없음)
- `/video/:topName/:videoId` 또는 `/video/:topName/:subName/:videoId` — 외부 공유용 짧은 URL. 경로의 폴더 부분이 영상 위치와 일치할 때만 200.
- `/api/video/:topName[/:subName]/:videoId` — 실제 파일 스트림 (정규). 재생 페이지와 동일한 폴더 경로 규칙으로, 경로가 영상 위치와 일치할 때만 200. `?edited=0` 으로 원본 강제.
- `/file/video/:videoId`, `/api/video/:videoId/file` — ID 기반 파일 스트림 (레거시·외부 공유 호환).
- `/file/video/:videoId` — ID 기반 파일 스트림 (레거시·외부 공유 호환). ID 가 전역에서 유일할 때만 200, 여러 폴더에 같은 ID 가 있으면 404.
관리자(`/op`):
- `/op` — 폴더 목록 (로그인 필요)

View File

@@ -128,4 +128,74 @@
})
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()
}
})()

View File

@@ -56,7 +56,7 @@
changeIdBtn.disabled = true
clearTimeout(idDebounce)
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 (j) {
// stale 응답 (사용자가 그 사이에 입력을 더 바꾼 경우) 무시
@@ -87,7 +87,7 @@
fetch('/op/videos/' + encodeURIComponent(video.id) + '/changeId', {
method: 'POST',
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) {
if (!j.ok) {
setIdStatus(j.message || 'ID 변경 실패', 'idStatusBad')
@@ -464,7 +464,8 @@
var payload = {
title: titleInput.value,
startSec: trimStart,
endSec: trimEnd
endSec: trimEnd,
folderId: ctx.folderId
}
saveBtn.disabled = true
saveBtn.textContent = '저장 중...'

View File

@@ -39,7 +39,7 @@
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/rename', {
method: 'POST',
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) {
if (j.ok) location.reload()
else alert(j.message || '이름 변경 실패')
@@ -51,7 +51,7 @@
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/changeId', {
method: 'POST',
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) {
if (j.ok) location.reload()
else alert(j.message || 'ID 변경 실패')
@@ -60,7 +60,9 @@
} else if (action === 'delete') {
if (window.confirm('"' + videoTargetTitle + '" 영상을 정말 삭제할까요?')) {
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) {
if (j.ok) location.reload()
else alert(j.message || '삭제 실패')

View File

@@ -140,7 +140,7 @@
entry.idDebounce = setTimeout(function () {
var current = state.entries[idx]
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 (j) {
var c = state.entries[idx]
@@ -542,19 +542,66 @@
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) { finishProgress(items); return }
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() })
@@ -580,6 +627,7 @@
if (items.some(function (it) { return !it.done })) {
setTimeout(function () { pollAllJobs(items) }, 1500)
} else {
polling = false
finishProgress(items)
}
})
@@ -599,6 +647,7 @@
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
})
@@ -617,7 +666,7 @@
var statusEl = document.getElementById('progressStatus')
if (statusEl) {
statusEl.textContent = errCount
? '실패한 항목은 폴더에서 개별로 다시 시도할 수 있습니다.'
? '실패한 항목은 아래 "재시도" 버튼으로 다시 받을 수 있습니다. (연령 제한 영상은 먼저 대시보드에서 YouTube 쿠키를 등록하세요.)'
: '모든 영상이 폴더에 추가되었습니다.'
}
if (progressDoneBtn) progressDoneBtn.hidden = false
@@ -1003,6 +1052,179 @@
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()
})()

View File

@@ -356,6 +356,9 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
color: var(--text); padding: 8px 12px; font-size: 14px;
}
.playlistDraftRow {
display: flex; gap: 10px; margin-top: 12px; align-items: center; flex-wrap: wrap;
}
.playlistEntriesSection {
margin-top: 18px;
@@ -502,7 +505,7 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
.progressList { list-style: none; margin: 0; padding: 0;
display: flex; flex-direction: column; gap: 8px; }
.progressRow {
display: grid; grid-template-columns: 1fr 180px auto; align-items: center;
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;
}
@@ -513,9 +516,11 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
.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
View 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 })
}

View File

@@ -14,7 +14,8 @@
* 폴더 깊이 제약: 최상위(parent_id IS NULL) 또는 그 자식(서브폴더) 까지만.
* 서브폴더는 자식 폴더를 가질 수 없다. 코드 레벨에서 검사.
*
* 영상 ID: 사용자 편집 가능. 전역 유일 (PRIMARY KEY). 변경 시 디렉토리 이름도 같이 바뀐다.
* 영상 ID: 사용자 편집 가능. 폴더 안에서만 유일 (UNIQUE(folder_id, id)).
* 서로 다른 폴더면 같은 ID(예: 1,2,3,4)를 써도 된다. 변경 시 디렉토리 이름도 같이 바뀐다.
*/
import Database from 'better-sqlite3'
import { promises as fsp } from 'node:fs'
@@ -43,6 +44,7 @@ export async function initDatabase(): Promise<void> {
db.pragma('synchronous = NORMAL')
createSchema(db)
migrateVideosSchema(db)
_db = db
// 비어 있으면 디스크 스캔 → 마이그레이션.
@@ -70,7 +72,7 @@ function createSchema(db: Database.Database): void {
ON folders(name) WHERE parent_id IS NULL;
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,
title TEXT NOT NULL,
original_file TEXT NOT NULL,
@@ -82,12 +84,80 @@ function createSchema(db: Database.Database): void {
trim_end_sec REAL,
position INTEGER NOT NULL DEFAULT 0,
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);
-- 플레이리스트 추가 화면의 "임시저장": 폴더당 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 에 적재.
* 기존은 1단계(폴더→영상) 만 있으므로 서브폴더는 생기지 않는다.

View File

@@ -1,7 +1,8 @@
import { spawn, spawnSync } from 'node:child_process'
import { promises as fs, existsSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
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 {
@@ -335,7 +336,8 @@ export async function upscaleOriginalTo60Fps(
// 재인코딩 결과는 항상 mp4 로 통일 (소스가 webm/mkv 여도).
const outName = 'original.mp4'
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 조립:
// - fps=60 : motion interpolation 대신 단순 프레임 복제 (속도 우선).
@@ -408,20 +410,22 @@ export async function upscaleOriginalTo60Fps(
* stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩.
*/
export async function applyTrimToVideo(
folderId: number,
videoId: string,
trim: VideoTrim
): Promise<string> {
const bin = getFfmpegPath()
const meta = getVideo(videoId)
const meta = getVideoInFolder(folderId, videoId)
if (!meta) throw new Error('비디오를 찾을 수 없습니다.')
const dir = videoDiskDir(videoId)
const dir = videoDiskDir(folderId, videoId)
const inputPath = path.join(dir, meta.originalFile)
await fs.access(inputPath)
const ext = path.extname(meta.originalFile) || '.mp4'
const outName = `edited${ext}`
const outPath = path.join(dir, outName)
const tmpPath = outPath + '.tmp' + ext
// 동시 호출이 같은 tmp 파일을 덮어쓰지 않도록 호출마다 고유한 임시 경로를 쓴다.
const tmpPath = `${outPath}.${randomUUID()}.tmp${ext}`
const startSec = Math.max(0, Number(trim.startSec) || 0)
const endSec = trim.endSec == null ? null : Math.max(startSec, Number(trim.endSec))
@@ -476,7 +480,7 @@ export async function applyTrimToVideo(
}
await fs.rename(tmpPath, outPath)
updateVideo(videoId, {
updateVideo(folderId, videoId, {
editedFile: outName,
trim: { startSec, endSec }
})
@@ -496,10 +500,10 @@ const THUMB_NAME = 'thumb.jpg'
* 카드에서는 ▶ placeholder 로 폴백.
* 한 번 만들면 파일로 캐시되어 다음 요청부터는 즉시 응답.
*/
export async function ensureThumbnail(videoId: string): Promise<string | null> {
const meta = getVideo(videoId)
export async function ensureThumbnail(folderId: number, videoId: string): Promise<string | null> {
const meta = getVideoInFolder(folderId, videoId)
if (!meta) return null
const dir = videoDiskDir(videoId)
const dir = videoDiskDir(folderId, videoId)
const thumbPath = path.join(dir, THUMB_NAME)
try {
await fs.access(thumbPath)
@@ -521,7 +525,10 @@ export async function ensureThumbnail(videoId: string): Promise<string | null> {
} catch {
return null
}
const tmpPath = thumbPath + '.tmp.jpg'
// 동시 호출이 같은 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, [

View File

@@ -13,7 +13,8 @@ import {
folderPathNames,
getFolder,
getFolderByPath,
getVideo,
getVideoInFolder,
getVideoByIdGlobal,
isSafeVideoId,
listChildFolders,
listTopFolders,
@@ -21,7 +22,10 @@ import {
moveUploadIntoVideoDir,
renameFolder,
updateVideo,
videoIdExists
videoExistsInFolder,
savePlaylistDraft,
getPlaylistDraft,
deletePlaylistDraft
} from '../storeDb.js'
import { tmpDir } from '../paths.js'
import {
@@ -29,11 +33,14 @@ import {
getJob,
probeYoutube,
probeYoutubePlaylist,
startYoutubeDownload
startYoutubeDownload,
retryDownloadJob
} from '../youtube.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 { promises as fs } from 'node:fs'
export const opRouter = Router()
@@ -58,6 +65,12 @@ const upload = multer({
limits: { fileSize: uploadMaxBytes }
})
// 쿠키 파일은 작다 — 별도 multer 로 1 MiB 로 제한해 대형 파일 오용을 막는다.
const cookieUpload = multer({
dest: tmpDir,
limits: { fileSize: 1024 * 1024 }
})
function pickStr(v: unknown): string {
if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : ''
return typeof v === 'string' ? v : ''
@@ -141,6 +154,34 @@ opRouter.post('/op/tools/:name/update', requireAuth, async (req, res) => {
}
})
// ─── 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 {
try {
const segments = collectFolderSegments(req.params)
@@ -224,7 +265,7 @@ opRouter.get(
}
// 영상은 1단계·2단계 폴더 어디서나 추가할 수 있다. (플레이리스트만 2단계 한정)
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', {
userId: req.session.userId,
folder,
@@ -265,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) {
@@ -304,7 +459,7 @@ opRouter.post(
originalFile: destName,
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 })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
@@ -430,7 +585,7 @@ opRouter.post(
if (seen.has(videoId)) {
throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 요청 내에서 중복됩니다.`)
}
if (videoIdExists(videoId)) {
if (videoExistsInFolder(folder.id, videoId)) {
throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 이미 사용 중입니다.`)
}
// 자르기 구간(선택). 잘못된 값은 무시하고 전체로 처리.
@@ -479,15 +634,39 @@ opRouter.get('/op/job/:id', requireAuth, (req, res) => {
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) => {
try {
const folder = folderFromBody(req)
const id = req.params.id
const title = pickStr(req.body.title).trim()
if (!title) throw new Error('제목을 입력해 주세요.')
if (!getVideo(id)) throw new Error('영상을 찾을 수 없습니다.')
updateVideo(id, { title })
if (!getVideoInFolder(folder.id, id)) throw new Error('영상을 찾을 수 없습니다.')
updateVideo(folder.id, id, { title })
res.json({ ok: true })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
@@ -496,19 +675,21 @@ opRouter.post('/op/videos/:id/rename', requireAuth, (req, res) => {
opRouter.post('/op/videos/:id/changeId', requireAuth, async (req, res) => {
try {
const folder = folderFromBody(req)
const oldId = req.params.id
const newId = pickStr(req.body.newId).trim()
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 })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
/** 새 ID 가 사용 가능한지 미리 확인 (인풋 옆 실시간 표시용). */
/** 새 ID 가 (해당 폴더 안에서) 사용 가능한지 미리 확인 (인풋 옆 실시간 표시용). */
opRouter.get('/op/videos/idAvailable', requireAuth, (req, res) => {
const id = typeof req.query.id === 'string' ? req.query.id.trim() : ''
const folderId = pickIntId(req.query.folderId)
if (!id) {
res.json({ ok: true, available: false, reason: '비어 있음' })
return
@@ -521,13 +702,18 @@ opRouter.get('/op/videos/idAvailable', requireAuth, (req, res) => {
})
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 })
})
opRouter.post('/op/videos/:id/delete', requireAuth, async (req, res) => {
try {
await deleteVideo(req.params.id)
const folder = folderFromBody(req)
await deleteVideo(folder.id, req.params.id)
res.json({ ok: true })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
@@ -536,8 +722,9 @@ opRouter.post('/op/videos/:id/delete', requireAuth, async (req, res) => {
opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => {
try {
const folder = folderFromBody(req)
const id = req.params.id
const video = getVideo(id)
const video = getVideoInFolder(folder.id, id)
if (!video) throw new Error('영상을 찾을 수 없습니다.')
const title = pickStr(req.body.title).trim()
const startSec = Number(req.body.startSec ?? 0) || 0
@@ -550,10 +737,10 @@ opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => {
startSec,
endSec: endSec == null || Number.isNaN(endSec) ? null : endSec
}
updateVideo(id, { trim, ...(title ? { title } : {}) })
updateVideo(folder.id, id, { trim, ...(title ? { title } : {}) })
// ffmpeg 가 없으면 trim 정보만 저장하고 안내.
try {
const outName = await applyTrimToVideo(id, trim)
const outName = await applyTrimToVideo(folder.id, id, trim)
res.json({ ok: true, editedFile: outName, note: '편집본 저장 완료' })
} catch (err) {
if (err instanceof FfmpegUnavailableError) {
@@ -581,7 +768,7 @@ export function adminFolderUrl(folderId: number): string {
/** 비디오 ID → 공개 공유 URL (/video/topName/[subName/]videoId). */
export function videoShareUrl(videoId: string): string | null {
const v = getVideo(videoId)
const v = getVideoByIdGlobal(videoId)
if (!v) return null
const names = folderPathNames(v.folderId)
return '/video/' + [...names, videoId].map((s) => encodeURIComponent(s)).join('/')

View File

@@ -1,7 +1,8 @@
import { Router, type RequestHandler } from 'express'
import {
getFolderByPath,
getVideo,
getVideoInFolder,
getVideoByIdGlobal,
listChildFolders,
listTopFolders,
listVideosInFolder,
@@ -65,17 +66,17 @@ publicRouter.get('/folder/:topName/:subName', folderViewHandler)
const videoViewHandler: RequestHandler = (req, res, next) => {
try {
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) {
res.status(404).send('영상을 찾을 수 없습니다.')
return
}
const folderPath = folderPathNames(video.folderId)
if (!segmentsEqual(folderPath, folderSegments)) {
res.status(404).send('영상 경로가 일치하지 않습니다.')
return
}
res.render('player', { video, breadcrumb: folderPath })
res.render('player', { video, breadcrumb: folderPathNames(folder.id) })
} catch (err) {
next(err)
}
@@ -93,18 +94,24 @@ function sendVideoFile(video: Video, req: Parameters<RequestHandler>[0], res: Pa
res.status(404).end()
return
}
res.sendFile(videoFileFsPath(video.id, fileName))
res.sendFile(videoFileFsPath(video.folderId, video.id, fileName))
}
/**
* 영상 파일 스트리밍 (짧은 ID-only URL — 외부 공유 호환용 / 레거시).
* - GET /file/video/:videoId
* - GET /api/video/:videoId/file
* ID 가 전역에서 정확히 1건일 때만 제공한다. 같은 ID 가 여러 폴더에 있으면
* getVideoByIdGlobal 이 null 을 돌려주므로 404 (어느 폴더인지 모호).
* 폴더가 확실한 경우엔 정규 경로 URL `/api/video/폴더/[폴더/]ID` 를 쓸 것.
* ?edited=0 이면 원본을 강제, 기본은 편집본 있으면 편집본.
*
* 주의: 예전 `/api/video/:videoId/file` 레거시 라우트는 제거했다. 정규 경로
* 라우트 `/api/video/:topName/:videoId` 와 2세그먼트에서 충돌해서, ID 가
* 리터럴 "file" 인 영상의 정규 URL `/api/video/폴더/file` 을 가로챘기 때문.
*/
const streamVideoHandler: RequestHandler = (req, res, next) => {
try {
const video = getVideo(req.params.videoId)
const video = getVideoByIdGlobal(req.params.videoId)
if (!video) {
res.status(404).end()
return
@@ -115,7 +122,6 @@ const streamVideoHandler: RequestHandler = (req, res, next) => {
}
}
publicRouter.get('/file/video/:videoId', streamVideoHandler)
publicRouter.get('/api/video/:videoId/file', streamVideoHandler)
/**
* 영상 파일 스트리밍 (폴더 경로 + ID — 정규 공유 URL).
@@ -124,19 +130,19 @@ publicRouter.get('/api/video/:videoId/file', streamVideoHandler)
*
* 재생 페이지(/video/...)와 동일한 경로 규칙. 마지막 세그먼트가 영상 ID 이고
* 앞쪽 세그먼트는 그 영상이 실제 속한 폴더 경로와 일치해야 한다.
*
* 라우트 등록 순서 주의: 위의 `/api/video/:videoId/file` (2세그·끝이 리터럴 "file")
* 보다 뒤에 둬서, 레거시 URL 이 이 경로 라우트에 가로채이지 않게 한다.
* (예전 `/api/video/:videoId/file` 레거시 라우트와의 2세그먼트 충돌은 그 라우트를
* 제거해서 해소했다 — 이제 ID 가 "file" 인 영상도 정규 URL 로 정확히 찾는다.)
*/
const streamVideoByPathHandler: RequestHandler = (req, res, next) => {
try {
const { folderSegments, videoId } = collectVideoSegments(req.params)
const video = getVideo(videoId)
if (!video) {
const folder = getFolderByPath(folderSegments)
if (!folder) {
res.status(404).end()
return
}
if (!segmentsEqual(folderPathNames(video.folderId), folderSegments)) {
const video = getVideoInFolder(folder.id, videoId)
if (!video) {
res.status(404).end()
return
}
@@ -152,16 +158,39 @@ publicRouter.get('/api/video/:topName/:subName/:videoId', streamVideoByPathHandl
* 영상 썸네일 (카드용). 없으면 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) => {
ensureThumbnail(req.params.videoId)
.then((thumbPath) => {
if (!thumbPath) {
res.status(404).end()
return
}
res.set('Cache-Control', 'public, max-age=3600')
res.sendFile(thumbPath)
})
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)
@@ -169,7 +198,7 @@ publicRouter.get('/file/video/:videoId/thumb', thumbHandler)
/** 비디오 메타 조회 (플레이어/관리자 양쪽에서 사용). */
publicRouter.get('/api/video/:videoId', (req, res, next) => {
try {
const video = getVideo(req.params.videoId)
const video = getVideoByIdGlobal(req.params.videoId)
if (!video) {
res.status(404).json({ ok: false, message: '영상을 찾을 수 없습니다.' })
return
@@ -196,12 +225,4 @@ function collectVideoSegments(params: Record<string, string | undefined>): {
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 }

View File

@@ -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
const r = getDb()
.prepare(`SELECT * FROM videos WHERE id = ?`)
.get(id) as VideoRow | undefined
.prepare(`SELECT * FROM videos WHERE folder_id = ? AND id = ?`)
.get(folderId, id) as VideoRow | undefined
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[] {
const rows = getDb()
.prepare(
@@ -316,34 +332,30 @@ export function listVideosInFolder(folderId: number): Video[] {
return rows.map(rowToVideo)
}
export function videoIdExists(id: string): boolean {
export function videoExistsInFolder(folderId: number, id: string): boolean {
if (!isSafeVideoId(id)) return false
const r = getDb()
.prepare(`SELECT 1 FROM videos WHERE id = ?`)
.get(id)
.prepare(`SELECT 1 FROM videos WHERE folder_id = ? AND id = ?`)
.get(folderId, id)
return !!r
}
/** 영상 → 디스크 디렉토리 (폴더 경로 + videoId). */
export function videoDiskDir(videoId: string): string {
const v = getVideo(videoId)
if (!v) throw new Error('영상을 찾을 수 없습니다.')
return path.join(folderDiskPath(v.folderId), v.id)
/** 영상 → 디스크 디렉토리 (폴더 경로 + videoId). DB 조회 없이 폴더경로로 합성. */
export function videoDiskDir(folderId: number, id: string): string {
return path.join(folderDiskPath(folderId), id)
}
/** 영상 → 공유 URL 의 경로 부분 (인코딩 전 세그먼트). */
export function videoPathNames(videoId: string): string[] | null {
const v = getVideo(videoId)
if (!v) return null
return [...folderPathNames(v.folderId), v.id]
export function videoPathNames(folderId: number, id: string): string[] {
return [...folderPathNames(folderId), 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('..')) {
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('폴더를 찾을 수 없습니다.')
let id = input.id ?? newRandomVideoId()
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 db = getDb()
@@ -392,30 +406,37 @@ export async function createVideo(input: CreateVideoInput): Promise<Video> {
try {
await fsp.mkdir(diskDir, { recursive: true })
} 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
}
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 (oldId === newId) {
const v = getVideo(oldId)
const v = getVideoInFolder(folderId, oldId)
if (!v) throw new Error('영상을 찾을 수 없습니다.')
return v
}
const current = getVideo(oldId)
const current = getVideoInFolder(folderId, oldId)
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 newDir = path.join(folderDiskPath(current.folderId), newId)
const db = getDb()
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,
new Date().toISOString(),
folderId,
oldId
)
} catch (err) {
@@ -431,11 +452,15 @@ export async function changeVideoId(oldId: string, newId: string): Promise<Video
// 디스크에 디렉토리가 없으면 빈 디렉토리 생성 (메타만 있는 케이스).
await fsp.mkdir(newDir, { recursive: true })
} 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
}
}
return getVideo(newId)!
return getVideoInFolder(folderId, newId)!
}
export interface UpdateVideoPatch {
@@ -447,8 +472,8 @@ export interface UpdateVideoPatch {
trim?: VideoTrim | null
}
export function updateVideo(id: string, patch: UpdateVideoPatch): Video {
const current = getVideo(id)
export function updateVideo(folderId: number, id: string, patch: UpdateVideoPatch): Video {
const current = getVideoInFolder(folderId, id)
if (!current) throw new Error('영상을 찾을 수 없습니다.')
const fields: string[] = []
@@ -484,22 +509,25 @@ export function updateVideo(id: string, patch: UpdateVideoPatch): Video {
if (fields.length === 0) return current
fields.push('updated_at = ?')
params.push(new Date().toISOString())
params.push(id)
getDb().prepare(`UPDATE videos SET ${fields.join(', ')} WHERE id = ?`).run(...params)
return getVideo(id)!
params.push(folderId, id)
getDb()
.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> {
const v = getVideo(id)
export async function deleteVideo(folderId: number, id: string): Promise<void> {
const v = getVideoInFolder(folderId, id)
if (!v) return
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 })
}
// ─── 업로드 임시 파일 → 영상 디렉토리 이동 ─────────────────────────────
export async function moveUploadIntoVideoDir(
folderId: number,
videoId: string,
tmpFile: string,
destName: string
@@ -507,7 +535,7 @@ export async function moveUploadIntoVideoDir(
if (!destName || destName.includes('/') || destName.includes('\\') || destName.includes('..')) {
throw new Error('잘못된 파일명입니다.')
}
const dir = videoDiskDir(videoId)
const dir = videoDiskDir(folderId, videoId)
await fsp.mkdir(dir, { recursive: true })
const target = path.join(dir, destName)
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)
}

View File

@@ -2,11 +2,12 @@ import { spawn, spawnSync } from 'node:child_process'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { binDir, jobsDir, projectRoot } from './paths.js'
import { getCookieArgs, hasCookies } from './cookies.js'
import { upscaleOriginalTo60Fps, applyTrimToVideo } from './editor.js'
import {
createVideo,
getFolder,
getVideo,
getVideoInFolder,
updateVideo,
videoDiskDir,
type VideoTrim
@@ -54,6 +55,22 @@ 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 {
title: string
durationSec: number
@@ -70,6 +87,7 @@ export async function probeYoutube(url: string): Promise<ProbeResult> {
return new Promise<ProbeResult>((resolve, reject) => {
const child = spawn(bin, [
'--no-warnings',
...getCookieArgs(),
'--skip-download',
'--print',
'%(title)s\n%(duration)s\n%(filesize_approx)s'
@@ -82,7 +100,7 @@ export async function probeYoutube(url: string): Promise<ProbeResult> {
child.on('error', (err) => reject(err))
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(stderr.trim() || `yt-dlp probe 실패 (code=${code})`))
reject(new Error(friendlyYtDlpError(stderr, `yt-dlp probe 실패 (code=${code})`)))
return
}
const lines = stdout.trim().split('\n')
@@ -125,6 +143,7 @@ export async function probeYoutubePlaylist(url: string): Promise<PlaylistProbeRe
// --flat-playlist + --dump-json 한 줄당 한 JSON 항목.
const child = spawn(bin, [
'--no-warnings',
...getCookieArgs(),
'--flat-playlist',
'--dump-json',
url
@@ -137,7 +156,7 @@ export async function probeYoutubePlaylist(url: string): Promise<PlaylistProbeRe
child.on('error', (err) => reject(err))
child.on('close', (code) => {
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
}
const entries: PlaylistEntry[] = []
@@ -196,6 +215,8 @@ export interface DownloadJob {
folderId: number
videoId: string
url: string
/** 영상 제목. 재시도 시 레코드가 지워졌을 때 복원용으로도 쓴다. */
title: string
status: JobStatus
progress: number // 0..100
message: string
@@ -205,6 +226,8 @@ export interface DownloadJob {
error: string | null
/** 다운로드 완료 후 적용할 자르기 구간. null 이면 자르기 없음(전체). */
trim: VideoTrim | null
/** 이 (실패한) 잡을 재시도해 만든 새 잡 id. 한 잡당 재시도 1회만 허용하는 가드. */
retriedBy: string | null
}
const jobs = new Map<string, DownloadJob>()
@@ -315,12 +338,34 @@ export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<Dow
const trim: VideoTrim | null =
startSec > 0.01 || endSec != null ? { startSec, endSec } : null
const now = new Date().toISOString()
const job: DownloadJob = {
id: 'job-' + Math.random().toString(36).slice(2, 14),
const job = enqueueJob({
folderId: opts.folderId,
videoId: video.id,
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',
progress: 0,
message: '대기 중',
@@ -328,12 +373,62 @@ export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<Dow
finishedAt: null,
outputFile: null,
error: null,
trim
trim: p.trim,
retriedBy: null
}
jobs.set(job.id, job)
void persistJob(job)
// 같은 tick 에서 여러 startYoutubeDownload 가 연달아 호출될 수 있으니
// pump 는 다음 tick 에 한 번만 돌아도 충분 (반복 호출이 안전하긴 함).
return job
}
/**
* 실패한 다운로드를 같은 영상 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)
return job
}
@@ -343,11 +438,12 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
job.message = '다운로드 시작'
await persistJob(job)
const dir = videoDiskDir(job.videoId)
const dir = videoDiskDir(job.folderId, job.videoId)
// yt-dlp 가 다운로드 후 결정한 실제 파일명을 알려주도록 --print after_move:filepath
// 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱.
const args = [
'--no-warnings',
...getCookieArgs(),
'--no-playlist',
'--newline',
// 해상도 캡: FHD(≤1080p) 안에서만 비디오를 선택. FHD 가 아예 없는 영상은
@@ -426,7 +522,7 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
child.on('error', (err) => reject(err))
child.on('close', async (code) => {
if (code !== 0) {
reject(new Error(stderrTail.trim() || `yt-dlp 실패 (code=${code})`))
reject(new Error(friendlyYtDlpError(stderrTail, `yt-dlp 실패 (code=${code})`)))
return
}
// 실제 파일명 결정
@@ -498,9 +594,9 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
console.error('[youtube] 60fps 후처리 실패:', err)
}
const meta = getVideo(job.videoId)
const meta = getVideoInFolder(job.folderId, job.videoId)
if (meta) {
updateVideo(job.videoId, { originalFile: finalName })
updateVideo(job.folderId, job.videoId, { originalFile: finalName })
}
// 사용자가 지정한 자르기 구간이 있으면 다운로드/60fps 후처리가 끝난 원본에
@@ -510,7 +606,7 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
try {
job.message = '자르기 적용 중'
await persistJob(job)
await applyTrimToVideo(job.videoId, job.trim)
await applyTrimToVideo(job.folderId, job.videoId, job.trim)
job.message = '자르기 완료'
} catch (err) {
console.error('[youtube] 자르기 적용 실패:', err)

View File

@@ -52,7 +52,7 @@
<a class="videoCard" href="<%= shareUrl %>"
data-video-id="<%= v.id %>" data-share-url="<%= shareUrl %>">
<div class="videoThumb">
<img class="videoThumbImg" src="/file/video/<%= encodeURIComponent(v.id) %>/thumb" alt="" loading="lazy" onerror="this.style.display='none'" />
<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>

View File

@@ -59,6 +59,23 @@
</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>
<div class="ctxMenu" id="ctxMenu" hidden>

View File

@@ -15,6 +15,7 @@
var parentLabel = isSubFolder ? '← ' + breadcrumb[0] : '← 폴더 목록'
var editorHref = '/op/folder/' + folderPathEnc + '/video/editor'
var playlistHref = '/op/folder/' + folderPathEnc + '/playlist/new'
var listTxtHref = '/op/folder/' + folderPathEnc + '/list.txt'
%>
<main class="pageWrap">
@@ -30,6 +31,7 @@
<a class="primaryButton" href="<%= editorHref %>">영상 추가</a>
<% if (isSubFolder) { %>
<a class="primaryButton" href="<%= playlistHref %>">플레이리스트 추가</a>
<a class="primaryButton" href="<%= listTxtHref %>" download>목록 뽑기</a>
<% } %>
</div>
</section>
@@ -63,7 +65,7 @@
data-title="<%= v.title %>"
data-share-url="<%= shareUrl %>">
<div class="videoThumb">
<img class="videoThumbImg" src="/file/video/<%= encodeURIComponent(v.id) %>/thumb" alt="" loading="lazy" onerror="this.style.display='none'" />
<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>

View File

@@ -27,6 +27,12 @@
<button type="button" class="primaryButton" id="probeBtn">목록 불러오기</button>
</div>
<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 class="playlistEntriesSection" id="entriesSection" hidden>