Compare commits
8 Commits
042d9f7059
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
692a824e78 | ||
|
|
745b1db1cc | ||
|
|
0abd21533b | ||
|
|
584ec8b92b | ||
|
|
6850313c0b | ||
|
|
6ade552683 | ||
|
|
03e97d771d | ||
|
|
ff78b60a4b |
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
|
||||||
|
|||||||
@@ -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 에는 올라가지 않음). 등록된 쿠키로도 못 보는 영상이면 그 영상을 볼 수 있는 계정의 쿠키가 필요합니다.
|
||||||
|
|
||||||
## 데이터 위치
|
## 데이터 위치
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ data/
|
|||||||
- `/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.
|
||||||
- `/api/video/:topName[/:subName]/:videoId` — 실제 파일 스트림 (정규). 재생 페이지와 동일한 폴더 경로 규칙으로, 경로가 영상 위치와 일치할 때만 200. `?edited=0` 으로 원본 강제.
|
- `/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`):
|
||||||
- `/op` — 폴더 목록 (로그인 필요)
|
- `/op` — 폴더 목록 (로그인 필요)
|
||||||
|
|||||||
@@ -128,4 +128,74 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (document.querySelector('.toolsSection')) loadTools()
|
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()
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -542,19 +542,66 @@
|
|||||||
var msg = document.createElement('div')
|
var msg = document.createElement('div')
|
||||||
msg.className = 'progressMsg muted'
|
msg.className = 'progressMsg muted'
|
||||||
msg.textContent = it.message
|
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(name)
|
||||||
li.appendChild(bar)
|
li.appendChild(bar)
|
||||||
li.appendChild(msg)
|
li.appendChild(msg)
|
||||||
|
li.appendChild(retry)
|
||||||
progressList.appendChild(li)
|
progressList.appendChild(li)
|
||||||
it.barEl = bar
|
it.barEl = bar
|
||||||
it.msgEl = msg
|
it.msgEl = msg
|
||||||
it.rowEl = li
|
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) {
|
function pollAllJobs(items) {
|
||||||
var pending = items.filter(function (it) { return !it.done })
|
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) {
|
Promise.all(pending.map(function (it) {
|
||||||
return fetch('/op/job/' + encodeURIComponent(it.jobId), { cache: 'no-store' })
|
return fetch('/op/job/' + encodeURIComponent(it.jobId), { cache: 'no-store' })
|
||||||
.then(function (r) { return r.json() })
|
.then(function (r) { return r.json() })
|
||||||
@@ -580,6 +627,7 @@
|
|||||||
if (items.some(function (it) { return !it.done })) {
|
if (items.some(function (it) { return !it.done })) {
|
||||||
setTimeout(function () { pollAllJobs(items) }, 1500)
|
setTimeout(function () { pollAllJobs(items) }, 1500)
|
||||||
} else {
|
} else {
|
||||||
|
polling = false
|
||||||
finishProgress(items)
|
finishProgress(items)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -599,6 +647,7 @@
|
|||||||
it.rowEl.classList.toggle('progressRowDone', it.done && !it.error)
|
it.rowEl.classList.toggle('progressRowDone', it.done && !it.error)
|
||||||
it.rowEl.classList.toggle('progressRowError', it.error)
|
it.rowEl.classList.toggle('progressRowError', it.error)
|
||||||
}
|
}
|
||||||
|
if (it.retryEl && !it.retrying) it.retryEl.hidden = !it.error
|
||||||
if (it.done) doneCount += 1
|
if (it.done) doneCount += 1
|
||||||
sum += it.progress
|
sum += it.progress
|
||||||
})
|
})
|
||||||
@@ -617,7 +666,7 @@
|
|||||||
var statusEl = document.getElementById('progressStatus')
|
var statusEl = document.getElementById('progressStatus')
|
||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
statusEl.textContent = errCount
|
statusEl.textContent = errCount
|
||||||
? '실패한 항목은 폴더에서 개별로 다시 시도할 수 있습니다.'
|
? '실패한 항목은 아래 "재시도" 버튼으로 다시 받을 수 있습니다. (연령 제한 영상은 먼저 대시보드에서 YouTube 쿠키를 등록하세요.)'
|
||||||
: '모든 영상이 폴더에 추가되었습니다.'
|
: '모든 영상이 폴더에 추가되었습니다.'
|
||||||
}
|
}
|
||||||
if (progressDoneBtn) progressDoneBtn.hidden = false
|
if (progressDoneBtn) progressDoneBtn.hidden = false
|
||||||
|
|||||||
@@ -505,7 +505,7 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
|
|||||||
.progressList { list-style: none; margin: 0; padding: 0;
|
.progressList { list-style: none; margin: 0; padding: 0;
|
||||||
display: flex; flex-direction: column; gap: 8px; }
|
display: flex; flex-direction: column; gap: 8px; }
|
||||||
.progressRow {
|
.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);
|
gap: 12px; background: var(--bg-card); border: 1px solid var(--border);
|
||||||
border-radius: 10px; padding: 10px 14px;
|
border-radius: 10px; padding: 10px 14px;
|
||||||
}
|
}
|
||||||
@@ -516,9 +516,11 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
|
|||||||
.progressBar { width: 100%; height: 10px; }
|
.progressBar { width: 100%; height: 10px; }
|
||||||
.progressBar.progressBarError::-webkit-progress-value { background: #e5534b; }
|
.progressBar.progressBarError::-webkit-progress-value { background: #e5534b; }
|
||||||
.progressMsg { font-size: 12px; white-space: nowrap; text-align: right; }
|
.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; }
|
.progressRowDone { border-color: #2ea043; }
|
||||||
.progressRowError { border-color: #e5534b; }
|
.progressRowError { border-color: #e5534b; }
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.progressRow { grid-template-columns: 1fr; gap: 6px; }
|
.progressRow { grid-template-columns: 1fr; gap: 6px; }
|
||||||
.progressMsg { text-align: left; }
|
.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 })
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
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 { getVideoInFolder, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js'
|
import { getVideoInFolder, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js'
|
||||||
import { binDir } from './paths.js'
|
import { binDir } from './paths.js'
|
||||||
@@ -335,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 대신 단순 프레임 복제 (속도 우선).
|
||||||
@@ -422,7 +424,8 @@ export async function applyTrimToVideo(
|
|||||||
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))
|
||||||
@@ -522,7 +525,10 @@ export async function ensureThumbnail(folderId: number, videoId: string): Promis
|
|||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const tmpPath = thumbPath + '.tmp.jpg'
|
// 동시 호출이 같은 tmp 파일을 덮어쓰지 않도록 호출마다 고유한 임시 경로를 쓴다.
|
||||||
|
// (두 명이 같은 영상을 동시에 열면 thumb 가 아직 없을 때 generation 이 겹쳐
|
||||||
|
// 서로의 tmp 를 덮어써 일부 요청이 헛되이 실패하던 레이스를 막는다.)
|
||||||
|
const tmpPath = `${thumbPath}.${randomUUID()}.tmp.jpg`
|
||||||
const vf = "scale='min(480,iw)':-2"
|
const vf = "scale='min(480,iw)':-2"
|
||||||
// -ss 1 (1초 지점). 영상이 1초보다 짧으면 프레임이 안 나와 실패하므로 0초로 재시도.
|
// -ss 1 (1초 지점). 영상이 1초보다 짧으면 프레임이 안 나와 실패하므로 0초로 재시도.
|
||||||
let ok = await runFfmpeg(bin, [
|
let ok = await runFfmpeg(bin, [
|
||||||
|
|||||||
101
src/routes/op.ts
101
src/routes/op.ts
@@ -33,11 +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 { 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()
|
||||||
|
|
||||||
@@ -62,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 : ''
|
||||||
@@ -145,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 {
|
function folderViewHandler(req: Request, res: Response, next: NextFunction): void {
|
||||||
try {
|
try {
|
||||||
const segments = collectFolderSegments(req.params)
|
const segments = collectFolderSegments(req.params)
|
||||||
@@ -269,6 +306,54 @@ 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) ──────────────────────────────────────
|
// ─── 플레이리스트 임시저장(draft) ──────────────────────────────────────
|
||||||
// 다운로드 시작 전 수정해둔 값(URL + 항목별 제목/ID/구간)을 폴더당 1개 저장/복원.
|
// 다운로드 시작 전 수정해둔 값(URL + 항목별 제목/ID/구간)을 폴더당 1개 저장/복원.
|
||||||
|
|
||||||
@@ -549,6 +634,20 @@ opRouter.get('/op/job/:id', requireAuth, (req, res) => {
|
|||||||
res.json({ ok: true, job })
|
res.json({ ok: true, job })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 실패한 다운로드를 같은 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 는 폴더 안에서만 유일) ───────
|
// ─── 영상 mutation (folderId + id 기반 — ID 는 폴더 안에서만 유일) ───────
|
||||||
|
|
||||||
/** POST body 의 folderId 를 검증해 폴더를 돌려준다. 없거나 못 찾으면 throw. */
|
/** POST body 의 folderId 를 검증해 폴더를 돌려준다. 없거나 못 찾으면 throw. */
|
||||||
|
|||||||
@@ -100,8 +100,14 @@ function sendVideoFile(video: Video, req: Parameters<RequestHandler>[0], res: Pa
|
|||||||
/**
|
/**
|
||||||
* 영상 파일 스트리밍 (짧은 ID-only URL — 외부 공유 호환용 / 레거시).
|
* 영상 파일 스트리밍 (짧은 ID-only URL — 외부 공유 호환용 / 레거시).
|
||||||
* - GET /file/video/:videoId
|
* - GET /file/video/:videoId
|
||||||
* - GET /api/video/:videoId/file
|
* 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 {
|
||||||
@@ -116,7 +122,6 @@ const streamVideoHandler: RequestHandler = (req, res, next) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
publicRouter.get('/file/video/:videoId', streamVideoHandler)
|
publicRouter.get('/file/video/:videoId', streamVideoHandler)
|
||||||
publicRouter.get('/api/video/:videoId/file', streamVideoHandler)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 영상 파일 스트리밍 (폴더 경로 + ID — 정규 공유 URL).
|
* 영상 파일 스트리밍 (폴더 경로 + ID — 정규 공유 URL).
|
||||||
@@ -125,9 +130,8 @@ publicRouter.get('/api/video/:videoId/file', streamVideoHandler)
|
|||||||
*
|
*
|
||||||
* 재생 페이지(/video/...)와 동일한 경로 규칙. 마지막 세그먼트가 영상 ID 이고
|
* 재생 페이지(/video/...)와 동일한 경로 규칙. 마지막 세그먼트가 영상 ID 이고
|
||||||
* 앞쪽 세그먼트는 그 영상이 실제 속한 폴더 경로와 일치해야 한다.
|
* 앞쪽 세그먼트는 그 영상이 실제 속한 폴더 경로와 일치해야 한다.
|
||||||
*
|
* (예전 `/api/video/:videoId/file` 레거시 라우트와의 2세그먼트 충돌은 그 라우트를
|
||||||
* 라우트 등록 순서 주의: 위의 `/api/video/:videoId/file` (2세그·끝이 리터럴 "file")
|
* 제거해서 해소했다 — 이제 ID 가 "file" 인 영상도 정규 URL 로 정확히 찾는다.)
|
||||||
* 보다 뒤에 둬서, 레거시 URL 이 이 경로 라우트에 가로채이지 않게 한다.
|
|
||||||
*/
|
*/
|
||||||
const streamVideoByPathHandler: RequestHandler = (req, res, next) => {
|
const streamVideoByPathHandler: RequestHandler = (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -178,7 +182,7 @@ const thumbByPathHandler: RequestHandler = (req, res, next) => {
|
|||||||
publicRouter.get('/file/video/:topName/:videoId/thumb', thumbByPathHandler)
|
publicRouter.get('/file/video/:topName/:videoId/thumb', thumbByPathHandler)
|
||||||
publicRouter.get('/file/video/:topName/:subName/:videoId/thumb', thumbByPathHandler)
|
publicRouter.get('/file/video/:topName/:subName/:videoId/thumb', thumbByPathHandler)
|
||||||
|
|
||||||
/** ID-only 레거시 썸네일 fallback (전역 첫 매치). */
|
/** ID-only 레거시 썸네일 fallback (전역에서 유일한 경우만, 중복이면 404). */
|
||||||
const thumbHandler: RequestHandler = (req, res, next) => {
|
const thumbHandler: RequestHandler = (req, res, next) => {
|
||||||
const video = getVideoByIdGlobal(req.params.videoId)
|
const video = getVideoByIdGlobal(req.params.videoId)
|
||||||
if (!video) {
|
if (!video) {
|
||||||
|
|||||||
@@ -309,15 +309,18 @@ export function getVideoInFolder(folderId: number, id: string): Video | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ID 만으로 전역에서 첫 번째 영상을 찾는다 (폴더 경로 없는 레거시/외부공유 fallback).
|
* ID 만으로 전역에서 영상을 찾는다 (폴더 경로 없는 레거시/외부공유 fallback).
|
||||||
* 같은 ID 가 여러 폴더에 있으면 folder_id 가 작은(=먼저 만든) 쪽을 돌려준다.
|
* ID 는 이제 폴더별로만 유일하므로, 같은 ID 가 여러 폴더에 있으면 어느 쪽인지
|
||||||
|
* 모호하다. 그런 경우엔 아무 것도 돌려주지 않아 호출 측이 404 로 처리하게 한다.
|
||||||
|
* 전역에서 정확히 1건일 때만 그 영상을 돌려준다.
|
||||||
*/
|
*/
|
||||||
export function getVideoByIdGlobal(id: string): Video | null {
|
export function getVideoByIdGlobal(id: string): Video | null {
|
||||||
if (!isSafeVideoId(id)) return null
|
if (!isSafeVideoId(id)) return null
|
||||||
const r = getDb()
|
const rows = getDb()
|
||||||
.prepare(`SELECT * FROM videos WHERE id = ? ORDER BY folder_id LIMIT 1`)
|
.prepare(`SELECT * FROM videos WHERE id = ? ORDER BY folder_id LIMIT 2`)
|
||||||
.get(id) as VideoRow | undefined
|
.all(id) as VideoRow[]
|
||||||
return r ? rowToVideo(r) : null
|
if (rows.length !== 1) return null // 없음 또는 중복(모호) → 404
|
||||||
|
return rowToVideo(rows[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listVideosInFolder(folderId: number): Video[] {
|
export function listVideosInFolder(folderId: number): Video[] {
|
||||||
|
|||||||
114
src/youtube.ts
114
src/youtube.ts
@@ -2,6 +2,7 @@ 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 { binDir, jobsDir, projectRoot } from './paths.js'
|
import { binDir, jobsDir, projectRoot } from './paths.js'
|
||||||
|
import { getCookieArgs, hasCookies } from './cookies.js'
|
||||||
import { upscaleOriginalTo60Fps, applyTrimToVideo } from './editor.js'
|
import { upscaleOriginalTo60Fps, applyTrimToVideo } from './editor.js'
|
||||||
import {
|
import {
|
||||||
createVideo,
|
createVideo,
|
||||||
@@ -54,6 +55,22 @@ export function resetYtDlpResolution(): void {
|
|||||||
resolvedYtDlpPath = null
|
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
|
||||||
@@ -70,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'
|
||||||
@@ -82,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')
|
||||||
@@ -125,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
|
||||||
@@ -137,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[] = []
|
||||||
@@ -196,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
|
||||||
@@ -205,6 +226,8 @@ export interface DownloadJob {
|
|||||||
error: string | null
|
error: string | null
|
||||||
/** 다운로드 완료 후 적용할 자르기 구간. null 이면 자르기 없음(전체). */
|
/** 다운로드 완료 후 적용할 자르기 구간. null 이면 자르기 없음(전체). */
|
||||||
trim: VideoTrim | null
|
trim: VideoTrim | null
|
||||||
|
/** 이 (실패한) 잡을 재시도해 만든 새 잡 id. 한 잡당 재시도 1회만 허용하는 가드. */
|
||||||
|
retriedBy: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobs = new Map<string, DownloadJob>()
|
const jobs = new Map<string, DownloadJob>()
|
||||||
@@ -315,12 +338,34 @@ export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<Dow
|
|||||||
const trim: VideoTrim | null =
|
const trim: VideoTrim | null =
|
||||||
startSec > 0.01 || endSec != null ? { startSec, endSec } : null
|
startSec > 0.01 || endSec != null ? { startSec, endSec } : null
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
const job = enqueueJob({
|
||||||
const job: DownloadJob = {
|
|
||||||
id: 'job-' + Math.random().toString(36).slice(2, 14),
|
|
||||||
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: '대기 중',
|
||||||
@@ -328,12 +373,62 @@ export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<Dow
|
|||||||
finishedAt: null,
|
finishedAt: null,
|
||||||
outputFile: null,
|
outputFile: null,
|
||||||
error: null,
|
error: null,
|
||||||
trim
|
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
|
||||||
}
|
}
|
||||||
@@ -348,6 +443,7 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
|||||||
// 진행률은 --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 가 아예 없는 영상은
|
||||||
@@ -426,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
|
||||||
}
|
}
|
||||||
// 실제 파일명 결정
|
// 실제 파일명 결정
|
||||||
|
|||||||
@@ -59,6 +59,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user