다운로드 시작 전, 수정해둔 값(플레이리스트 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>
1182 lines
45 KiB
JavaScript
1182 lines
45 KiB
JavaScript
/* 플레이리스트 import UI.
|
|
*
|
|
* 흐름:
|
|
* 1) URL 입력 → 목록 불러오기 (POST /playlist/probe)
|
|
* 2) 항목 목록 렌더 + 인라인 편집 (제목, ID) + 드래그 재정렬 + 항목 삭제
|
|
* 3) ID 모드 토글 (랜덤 / 시퀀셜) — 시퀀셜이면 zero-pad 토글 노출
|
|
* 4) 등록 → POST /playlist/start → 폴더 페이지로 돌아가서 다운로드 폴링
|
|
*/
|
|
(function () {
|
|
var ctx = window.__PLAYLIST__ || {}
|
|
var folderPath = ctx.folderPath || []
|
|
var folderPathEnc = folderPath
|
|
.map(function (s) { return encodeURIComponent(s) })
|
|
.join('/')
|
|
var folderBaseUrl = '/op/folder/' + folderPathEnc
|
|
var probeUrl = folderBaseUrl + '/playlist/probe'
|
|
var startUrl = folderBaseUrl + '/playlist/start'
|
|
|
|
var ID_RE = /^[a-zA-Z0-9_-]{1,64}$/
|
|
var DEFAULT_LENGTH = 15 // 기본 재생 길이(초)
|
|
|
|
// ─── state ────────────────────────────────────────────────────────────
|
|
// entries: [{ url, title, videoId, durationSec, thumbnailUrl, ytId,
|
|
// startSec:number(기본0), lengthSec:number(기본15, 시작부터 재생할 초),
|
|
// idStatus:'pending'|'ok'|'bad', idReason:string, idDebounce:number }]
|
|
var state = {
|
|
entries: [],
|
|
idMode: 'random', // 'random' | 'sequential'
|
|
zeroPad: false,
|
|
busy: false
|
|
}
|
|
|
|
// ─── DOM refs ─────────────────────────────────────────────────────────
|
|
var urlInput = document.getElementById('playlistUrl')
|
|
var probeBtn = document.getElementById('probeBtn')
|
|
var probeStatus = document.getElementById('probeStatus')
|
|
var entriesSection = document.getElementById('entriesSection')
|
|
var entryList = document.getElementById('entryList')
|
|
var entriesCount = document.getElementById('entriesCount')
|
|
var idModeRadios = document.getElementsByName('idMode')
|
|
var zeroPadWrap = document.getElementById('zeroPadWrap')
|
|
var zeroPadToggle = document.getElementById('zeroPadToggle')
|
|
var registerBtn = document.getElementById('registerBtn')
|
|
var registerStatus = document.getElementById('registerStatus')
|
|
var probeSection = document.getElementById('probeSection')
|
|
var progressSection = document.getElementById('progressSection')
|
|
var progressList = document.getElementById('progressList')
|
|
var progressSummary = document.getElementById('progressSummary')
|
|
var progressOverall = document.getElementById('progressOverall')
|
|
var progressDoneBtn = document.getElementById('progressDoneBtn')
|
|
|
|
// ─── 유틸 ─────────────────────────────────────────────────────────────
|
|
function randomId() {
|
|
// 24자 hex (UUID 압축). crypto.randomUUID 가 없는 옛 브라우저 폴백.
|
|
if (window.crypto && typeof crypto.randomUUID === 'function') {
|
|
return crypto.randomUUID().replace(/-/g, '').slice(0, 24)
|
|
}
|
|
var s = ''
|
|
for (var i = 0; i < 24; i++) {
|
|
s += Math.floor(Math.random() * 16).toString(16)
|
|
}
|
|
return s
|
|
}
|
|
|
|
function formatDuration(sec) {
|
|
if (sec == null || !isFinite(sec)) return ''
|
|
sec = Math.round(sec)
|
|
var h = Math.floor(sec / 3600)
|
|
var m = Math.floor((sec % 3600) / 60)
|
|
var s = sec % 60
|
|
if (h > 0) return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0')
|
|
return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0')
|
|
}
|
|
|
|
function setProbeStatus(text, cls) {
|
|
probeStatus.textContent = text || ''
|
|
probeStatus.className = cls || 'muted'
|
|
}
|
|
function setRegisterStatus(text, cls) {
|
|
registerStatus.textContent = text || ''
|
|
registerStatus.className = cls || 'muted'
|
|
}
|
|
|
|
// ─── ID 생성 / 재계산 ─────────────────────────────────────────────────
|
|
function regenerateIds() {
|
|
var n = state.entries.length
|
|
if (state.idMode === 'random') {
|
|
for (var i = 0; i < n; i++) {
|
|
state.entries[i].videoId = randomId()
|
|
state.entries[i].idStatus = 'ok'
|
|
state.entries[i].idReason = ''
|
|
}
|
|
} else {
|
|
// sequential: 1..n, optional zero-pad based on n
|
|
var width = state.zeroPad ? String(n).length : 0
|
|
for (var j = 0; j < n; j++) {
|
|
var num = String(j + 1)
|
|
if (width > 0) while (num.length < width) num = '0' + num
|
|
state.entries[j].videoId = num
|
|
state.entries[j].idStatus = 'pending'
|
|
state.entries[j].idReason = ''
|
|
}
|
|
// 시퀀셜은 짧은 숫자 ID 라 충돌 가능성이 상대적으로 크므로 일괄 가용성 확인.
|
|
for (var k = 0; k < n; k++) {
|
|
scheduleIdCheck(k, 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── ID 가용성 확인 (단건) ────────────────────────────────────────────
|
|
function scheduleIdCheck(idx, delay) {
|
|
var entry = state.entries[idx]
|
|
if (!entry) return
|
|
if (entry.idDebounce) { clearTimeout(entry.idDebounce); entry.idDebounce = 0 }
|
|
var v = entry.videoId
|
|
if (!v) {
|
|
entry.idStatus = 'bad'
|
|
entry.idReason = '비어 있음'
|
|
paintEntryStatus(idx)
|
|
return
|
|
}
|
|
if (!ID_RE.test(v)) {
|
|
entry.idStatus = 'bad'
|
|
entry.idReason = '형식: 영문/숫자/_/- (1~64자)'
|
|
paintEntryStatus(idx)
|
|
return
|
|
}
|
|
// 요청 내 중복 검사
|
|
for (var i = 0; i < state.entries.length; i++) {
|
|
if (i !== idx && state.entries[i].videoId === v) {
|
|
entry.idStatus = 'bad'
|
|
entry.idReason = '같은 ID 가 다른 항목에 있음'
|
|
paintEntryStatus(idx)
|
|
return
|
|
}
|
|
}
|
|
entry.idStatus = 'pending'
|
|
entry.idReason = '확인 중…'
|
|
paintEntryStatus(idx)
|
|
entry.idDebounce = setTimeout(function () {
|
|
var current = state.entries[idx]
|
|
if (!current || current.videoId !== v) return
|
|
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]
|
|
if (!c || c.videoId !== v) return
|
|
if (j && j.ok && j.available) {
|
|
c.idStatus = 'ok'
|
|
c.idReason = ''
|
|
} else {
|
|
c.idStatus = 'bad'
|
|
c.idReason = (j && j.reason) || '이미 사용 중'
|
|
}
|
|
paintEntryStatus(idx)
|
|
})
|
|
.catch(function () {
|
|
var c = state.entries[idx]
|
|
if (!c || c.videoId !== v) return
|
|
c.idStatus = 'pending'
|
|
c.idReason = '확인 실패 (재시도 자동 대기)'
|
|
paintEntryStatus(idx)
|
|
})
|
|
}, delay != null ? delay : 250)
|
|
}
|
|
|
|
// ─── 렌더 ─────────────────────────────────────────────────────────────
|
|
function render() {
|
|
entriesCount.textContent = state.entries.length + ' 항목'
|
|
entryList.innerHTML = ''
|
|
for (var i = 0; i < state.entries.length; i++) {
|
|
entryList.appendChild(buildRow(i))
|
|
}
|
|
// sequential 모드에서 아무 항목이라도 충돌 있으면 등록 버튼 비활성.
|
|
updateRegisterEnable()
|
|
}
|
|
|
|
function buildRow(idx) {
|
|
var entry = state.entries[idx]
|
|
var li = document.createElement('li')
|
|
li.className = 'playlistEntryRow'
|
|
li.draggable = true
|
|
li.dataset.idx = String(idx)
|
|
li.innerHTML = ''
|
|
+ '<span class="dragHandle" title="드래그로 순서 변경">≡</span>'
|
|
+ '<span class="entryNum">' + (idx + 1) + '</span>'
|
|
+ '<div class="entryThumb" title="클릭하면 재생"></div>'
|
|
+ '<div class="entryFields">'
|
|
+ '<input type="text" class="entryTitleInput" />'
|
|
+ '<div class="entryIdRow">'
|
|
+ '<input type="text" class="entryIdInput" autocomplete="off" spellcheck="false" />'
|
|
+ '<span class="entryIdStatus muted"></span>'
|
|
+ '</div>'
|
|
+ '</div>'
|
|
+ '<div class="entryTrim">'
|
|
+ '<label>시작 <input type="text" class="entryStartInput" autocomplete="off" /></label>'
|
|
+ '<label>길이(초) <input type="text" class="entryLengthInput" autocomplete="off" /></label>'
|
|
+ '</div>'
|
|
+ '<span class="entryDuration muted"></span>'
|
|
+ '<button type="button" class="entryRemove" title="제외">✕</button>'
|
|
|
|
var thumbBox = li.querySelector('.entryThumb')
|
|
var titleInput = li.querySelector('.entryTitleInput')
|
|
var idInput = li.querySelector('.entryIdInput')
|
|
var startInput = li.querySelector('.entryStartInput')
|
|
var lengthInput = li.querySelector('.entryLengthInput')
|
|
var durSpan = li.querySelector('.entryDuration')
|
|
var removeBtn = li.querySelector('.entryRemove')
|
|
|
|
if (entry.thumbnailUrl) {
|
|
var img = document.createElement('img')
|
|
img.src = entry.thumbnailUrl
|
|
img.alt = ''
|
|
img.loading = 'lazy'
|
|
// 이미지가 깨지면(예: 비공개/삭제 영상) 빈 박스로 둔다.
|
|
img.addEventListener('error', function () { thumbBox.classList.add('entryThumbEmpty') })
|
|
thumbBox.appendChild(img)
|
|
} else {
|
|
thumbBox.classList.add('entryThumbEmpty')
|
|
}
|
|
|
|
titleInput.value = entry.title || ''
|
|
idInput.value = entry.videoId || ''
|
|
durSpan.textContent = formatDuration(entry.durationSec)
|
|
paintTrimInputs(idx, startInput, lengthInput)
|
|
|
|
titleInput.addEventListener('input', function () {
|
|
state.entries[idx].title = titleInput.value
|
|
})
|
|
idInput.addEventListener('input', function () {
|
|
state.entries[idx].videoId = idInput.value.trim()
|
|
scheduleIdCheck(idx, 250)
|
|
// 요청 내 중복은 다른 행에도 영향 → 가시적으로 갱신
|
|
revalidateOtherIds(idx)
|
|
})
|
|
startInput.addEventListener('change', function () {
|
|
var e = state.entries[idx]
|
|
var sec = parseClock(startInput.value)
|
|
e.startSec = sec != null && sec > 0 ? sec : 0
|
|
paintTrimInputs(idx, startInput, lengthInput)
|
|
})
|
|
lengthInput.addEventListener('change', function () {
|
|
var e = state.entries[idx]
|
|
var sec = parseClock(lengthInput.value)
|
|
e.lengthSec = sec != null && sec > 0 ? sec : DEFAULT_LENGTH
|
|
paintTrimInputs(idx, startInput, lengthInput)
|
|
})
|
|
thumbBox.addEventListener('click', function () { openPreview(idx) })
|
|
li.addEventListener('contextmenu', function (ev) {
|
|
// input/버튼 위에서의 우클릭은 기본 메뉴를 살려 둔다 (텍스트 편집 편의).
|
|
var t = ev.target
|
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'BUTTON')) return
|
|
ev.preventDefault()
|
|
openEntryCtxMenu(idx, ev.clientX, ev.clientY)
|
|
})
|
|
removeBtn.addEventListener('click', function () {
|
|
state.entries.splice(idx, 1)
|
|
// sequential 이면 번호 재계산
|
|
if (state.idMode === 'sequential') regenerateIds()
|
|
render()
|
|
})
|
|
|
|
attachRowDnd(li, idx)
|
|
paintEntryStatusInto(idx, li)
|
|
return li
|
|
}
|
|
|
|
// 항목의 시작/길이 입력칸 값을 상태로부터 다시 그린다.
|
|
function paintTrimInputs(idx, startInput, lengthInput) {
|
|
var e = state.entries[idx]
|
|
if (!e) return
|
|
if (!startInput) {
|
|
var row = findRow(idx)
|
|
if (!row) return
|
|
startInput = row.querySelector('.entryStartInput')
|
|
lengthInput = row.querySelector('.entryLengthInput')
|
|
if (!startInput || !lengthInput) return
|
|
}
|
|
startInput.value = formatClock(e.startSec || 0)
|
|
lengthInput.value = formatLength(e.lengthSec)
|
|
}
|
|
|
|
function findRow(idx) {
|
|
var rows = entryList.children
|
|
for (var i = 0; i < rows.length; i++) {
|
|
if (Number(rows[i].dataset.idx) === idx) return rows[i]
|
|
}
|
|
return null
|
|
}
|
|
|
|
function paintEntryStatus(idx) {
|
|
var rows = entryList.children
|
|
for (var i = 0; i < rows.length; i++) {
|
|
var r = rows[i]
|
|
if (Number(r.dataset.idx) === idx) {
|
|
paintEntryStatusInto(idx, r)
|
|
break
|
|
}
|
|
}
|
|
updateRegisterEnable()
|
|
}
|
|
function paintEntryStatusInto(idx, rowEl) {
|
|
var entry = state.entries[idx]
|
|
if (!entry) return
|
|
var statusEl = rowEl.querySelector('.entryIdStatus')
|
|
if (!statusEl) return
|
|
if (entry.idStatus === 'ok') {
|
|
statusEl.textContent = '사용 가능'
|
|
statusEl.className = 'entryIdStatus idStatusOk'
|
|
} else if (entry.idStatus === 'bad') {
|
|
statusEl.textContent = entry.idReason || '오류'
|
|
statusEl.className = 'entryIdStatus idStatusBad'
|
|
} else {
|
|
statusEl.textContent = entry.idReason || '확인 중…'
|
|
statusEl.className = 'entryIdStatus muted'
|
|
}
|
|
}
|
|
|
|
// 한 항목의 ID 가 바뀌면 같은 ID 를 가진 다른 항목의 상태도 새로 평가해야 한다.
|
|
function revalidateOtherIds(changedIdx) {
|
|
for (var i = 0; i < state.entries.length; i++) {
|
|
if (i === changedIdx) continue
|
|
scheduleIdCheck(i, 0)
|
|
}
|
|
}
|
|
|
|
function updateRegisterEnable() {
|
|
var ok = state.entries.length > 0 && !state.busy
|
|
if (ok) {
|
|
for (var i = 0; i < state.entries.length; i++) {
|
|
if (state.entries[i].idStatus !== 'ok') { ok = false; break }
|
|
if (!state.entries[i].title || !state.entries[i].title.trim()) { ok = false; break }
|
|
}
|
|
}
|
|
registerBtn.disabled = !ok
|
|
}
|
|
|
|
// ─── 드래그 재정렬 ────────────────────────────────────────────────────
|
|
// 패턴: 원본 요소 자체를 dragover 동안 이동시켜 placeholder 없이 라이브 미리보기.
|
|
var drag = null // { srcEl }
|
|
function attachRowDnd(li) {
|
|
li.addEventListener('dragstart', function (e) {
|
|
// input 안에서 드래그 시작은 막는다 (텍스트 드래그와 충돌).
|
|
var t = e.target
|
|
if (t && t.tagName === 'INPUT') {
|
|
e.preventDefault()
|
|
return
|
|
}
|
|
drag = { srcEl: li }
|
|
try {
|
|
e.dataTransfer.setData('text/plain', li.dataset.idx)
|
|
e.dataTransfer.effectAllowed = 'move'
|
|
} catch (_) {}
|
|
setTimeout(function () {
|
|
if (drag && drag.srcEl) drag.srcEl.classList.add('dragGhost')
|
|
}, 0)
|
|
})
|
|
li.addEventListener('dragend', function () {
|
|
if (drag && drag.srcEl) drag.srcEl.classList.remove('dragGhost')
|
|
drag = null
|
|
})
|
|
}
|
|
entryList.addEventListener('dragover', function (e) {
|
|
if (!drag) return
|
|
e.preventDefault()
|
|
try { e.dataTransfer.dropEffect = 'move' } catch (_) {}
|
|
var target = null
|
|
for (var i = 0; i < entryList.children.length; i++) {
|
|
var c = entryList.children[i]
|
|
if (c === drag.srcEl) continue
|
|
var rect = c.getBoundingClientRect()
|
|
if (e.clientY < rect.top + rect.height / 2) { target = c; break }
|
|
}
|
|
if (target) {
|
|
if (drag.srcEl.nextSibling !== target) entryList.insertBefore(drag.srcEl, target)
|
|
} else {
|
|
if (drag.srcEl !== entryList.lastChild) entryList.appendChild(drag.srcEl)
|
|
}
|
|
})
|
|
entryList.addEventListener('drop', function (e) {
|
|
if (!drag) return
|
|
e.preventDefault()
|
|
// 새 순서 = DOM 순서대로 state 배열을 다시 만든다.
|
|
var newOrder = []
|
|
for (var i = 0; i < entryList.children.length; i++) {
|
|
var idx = Number(entryList.children[i].dataset.idx)
|
|
newOrder.push(state.entries[idx])
|
|
}
|
|
state.entries = newOrder
|
|
drag = null
|
|
if (state.idMode === 'sequential') regenerateIds()
|
|
render()
|
|
})
|
|
|
|
// ─── ID 모드 토글 ─────────────────────────────────────────────────────
|
|
for (var i = 0; i < idModeRadios.length; i++) {
|
|
idModeRadios[i].addEventListener('change', function (e) {
|
|
if (!e.target.checked) return
|
|
state.idMode = e.target.value
|
|
zeroPadWrap.hidden = state.idMode !== 'sequential'
|
|
regenerateIds()
|
|
render()
|
|
})
|
|
}
|
|
zeroPadToggle.addEventListener('change', function () {
|
|
state.zeroPad = zeroPadToggle.checked
|
|
if (state.idMode === 'sequential') {
|
|
regenerateIds()
|
|
render()
|
|
}
|
|
})
|
|
|
|
// ─── probe ────────────────────────────────────────────────────────────
|
|
probeBtn.addEventListener('click', function () {
|
|
var url = (urlInput.value || '').trim()
|
|
if (!url) {
|
|
setProbeStatus('플레이리스트 URL 을 입력해 주세요.', 'idStatusBad')
|
|
return
|
|
}
|
|
probeBtn.disabled = true
|
|
setProbeStatus('목록을 불러오는 중…', 'muted')
|
|
fetch(probeUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url: url })
|
|
})
|
|
.then(function (r) { return r.json().then(function (j) { return { status: r.status, body: j } }) })
|
|
.then(function (res) {
|
|
if (!res.body || !res.body.ok) {
|
|
var msg = (res.body && res.body.message) || ('HTTP ' + res.status)
|
|
setProbeStatus(msg, 'idStatusBad')
|
|
return
|
|
}
|
|
var raw = res.body.entries || []
|
|
state.entries = raw.map(function (e) {
|
|
return {
|
|
url: e.url,
|
|
title: e.title || '제목 없음',
|
|
durationSec: e.durationSec,
|
|
thumbnailUrl: e.thumbnailUrl || null,
|
|
ytId: extractYouTubeId(e.url),
|
|
videoId: '',
|
|
startSec: 0,
|
|
lengthSec: DEFAULT_LENGTH,
|
|
idStatus: 'pending',
|
|
idReason: ''
|
|
}
|
|
})
|
|
regenerateIds()
|
|
render()
|
|
entriesSection.hidden = state.entries.length === 0
|
|
setProbeStatus(state.entries.length + ' 개 항목을 불러왔습니다.', 'idStatusOk')
|
|
})
|
|
.catch(function (err) {
|
|
setProbeStatus('요청 실패: ' + (err && err.message ? err.message : err), 'idStatusBad')
|
|
})
|
|
.finally(function () {
|
|
probeBtn.disabled = false
|
|
})
|
|
})
|
|
|
|
// ─── register ────────────────────────────────────────────────────────
|
|
registerBtn.addEventListener('click', function () {
|
|
if (registerBtn.disabled) return
|
|
var payload = {
|
|
entries: state.entries.map(function (e) {
|
|
return {
|
|
url: e.url,
|
|
title: (e.title || '').trim(),
|
|
videoId: (e.videoId || '').trim(),
|
|
startSec: e.startSec || 0,
|
|
endSec: computeEndSec(e)
|
|
}
|
|
})
|
|
}
|
|
state.busy = true
|
|
registerBtn.disabled = true
|
|
setRegisterStatus('등록 중…', 'muted')
|
|
fetch(startUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
})
|
|
.then(function (r) { return r.json().then(function (j) { return { status: r.status, body: j } }) })
|
|
.then(function (res) {
|
|
if (!res.body || !res.body.ok) {
|
|
var msg = (res.body && res.body.message) || ('HTTP ' + res.status)
|
|
state.busy = false
|
|
setRegisterStatus(msg, 'idStatusBad')
|
|
updateRegisterEnable()
|
|
return
|
|
}
|
|
startDownloadProgress(res.body.jobs || [])
|
|
})
|
|
.catch(function (err) {
|
|
state.busy = false
|
|
setRegisterStatus('요청 실패: ' + (err && err.message ? err.message : err), 'idStatusBad')
|
|
updateRegisterEnable()
|
|
})
|
|
})
|
|
|
|
// ─── 다운로드 진행 화면 ────────────────────────────────────────────────
|
|
// 등록 후 폴더로 바로 나가지 않고, 각 잡을 폴링하며 진행률을 보여준다.
|
|
// (백엔드는 잡을 큐에 넣고 순차 처리 — queued/downloading/done/error.)
|
|
function startDownloadProgress(jobs) {
|
|
if (probeSection) probeSection.hidden = true
|
|
if (entriesSection) entriesSection.hidden = true
|
|
if (progressSection) progressSection.hidden = false
|
|
var items = jobs.map(function (j, i) {
|
|
var title = (state.entries[i] && state.entries[i].title) || j.videoId
|
|
return {
|
|
jobId: j.jobId,
|
|
videoId: j.videoId,
|
|
title: title,
|
|
status: 'queued',
|
|
progress: 0,
|
|
message: '대기 중',
|
|
done: false,
|
|
error: false,
|
|
barEl: null,
|
|
msgEl: null
|
|
}
|
|
})
|
|
renderProgressRows(items)
|
|
updateProgressUI(items)
|
|
if (items.length === 0) { finishProgress(items); return }
|
|
pollAllJobs(items)
|
|
}
|
|
|
|
function renderProgressRows(items) {
|
|
progressList.innerHTML = ''
|
|
items.forEach(function (it) {
|
|
var li = document.createElement('li')
|
|
li.className = 'progressRow'
|
|
var name = document.createElement('div')
|
|
name.className = 'progressName'
|
|
name.textContent = it.title
|
|
var bar = document.createElement('progress')
|
|
bar.className = 'progressBar'
|
|
bar.max = 100
|
|
bar.value = 0
|
|
var msg = document.createElement('div')
|
|
msg.className = 'progressMsg muted'
|
|
msg.textContent = it.message
|
|
li.appendChild(name)
|
|
li.appendChild(bar)
|
|
li.appendChild(msg)
|
|
progressList.appendChild(li)
|
|
it.barEl = bar
|
|
it.msgEl = msg
|
|
it.rowEl = li
|
|
})
|
|
}
|
|
|
|
function pollAllJobs(items) {
|
|
var pending = items.filter(function (it) { return !it.done })
|
|
if (pending.length === 0) { finishProgress(items); return }
|
|
Promise.all(pending.map(function (it) {
|
|
return fetch('/op/job/' + encodeURIComponent(it.jobId), { cache: 'no-store' })
|
|
.then(function (r) { return r.json() })
|
|
.then(function (j) {
|
|
if (j && j.ok && j.job) {
|
|
it.status = j.job.status
|
|
it.message = j.job.message || ''
|
|
it.progress = typeof j.job.progress === 'number' ? j.job.progress : it.progress
|
|
if (j.job.status === 'done') {
|
|
it.done = true
|
|
it.progress = 100
|
|
it.message = '완료'
|
|
} else if (j.job.status === 'error') {
|
|
it.done = true
|
|
it.error = true
|
|
it.message = '실패: ' + (j.job.error || j.job.message || '')
|
|
}
|
|
}
|
|
})
|
|
.catch(function () { /* 일시 오류는 다음 틱에 재시도 */ })
|
|
})).then(function () {
|
|
updateProgressUI(items)
|
|
if (items.some(function (it) { return !it.done })) {
|
|
setTimeout(function () { pollAllJobs(items) }, 1500)
|
|
} else {
|
|
finishProgress(items)
|
|
}
|
|
})
|
|
}
|
|
|
|
function updateProgressUI(items) {
|
|
var total = items.length
|
|
var doneCount = 0
|
|
var sum = 0
|
|
items.forEach(function (it) {
|
|
if (it.barEl) {
|
|
it.barEl.value = it.progress
|
|
if (it.error) it.barEl.classList.add('progressBarError')
|
|
}
|
|
if (it.msgEl) it.msgEl.textContent = it.message
|
|
if (it.rowEl) {
|
|
it.rowEl.classList.toggle('progressRowDone', it.done && !it.error)
|
|
it.rowEl.classList.toggle('progressRowError', it.error)
|
|
}
|
|
if (it.done) doneCount += 1
|
|
sum += it.progress
|
|
})
|
|
if (progressOverall) progressOverall.value = total ? Math.round(sum / total) : 0
|
|
if (progressSummary) progressSummary.textContent = '다운로드 ' + doneCount + ' / ' + total + ' 완료'
|
|
}
|
|
|
|
function finishProgress(items) {
|
|
var errCount = items.filter(function (it) { return it.error }).length
|
|
if (progressOverall) progressOverall.value = 100
|
|
if (progressSummary) {
|
|
progressSummary.textContent = errCount
|
|
? ('완료 — ' + items.length + ' 개 중 ' + errCount + ' 개 실패')
|
|
: ('전체 ' + items.length + ' 개 다운로드 완료')
|
|
}
|
|
var statusEl = document.getElementById('progressStatus')
|
|
if (statusEl) {
|
|
statusEl.textContent = errCount
|
|
? '실패한 항목은 폴더에서 개별로 다시 시도할 수 있습니다.'
|
|
: '모든 영상이 폴더에 추가되었습니다.'
|
|
}
|
|
if (progressDoneBtn) progressDoneBtn.hidden = false
|
|
}
|
|
|
|
// ─── 시간 파싱/표시 + YouTube id 추출 ──────────────────────────────────
|
|
// "ss" / "mm:ss" / "hh:mm:ss" / 소수 초 를 모두 받아 초(number)로. 빈칸/오류는 null.
|
|
function parseClock(str) {
|
|
if (str == null) return null
|
|
str = String(str).trim()
|
|
if (!str) return null
|
|
var total = 0
|
|
if (str.indexOf(':') >= 0) {
|
|
var parts = str.split(':')
|
|
for (var i = 0; i < parts.length; i++) {
|
|
var n = Number(parts[i])
|
|
if (!isFinite(n) || n < 0) return null
|
|
total = total * 60 + n
|
|
}
|
|
} else {
|
|
var v = Number(str)
|
|
if (!isFinite(v) || v < 0) return null
|
|
total = v
|
|
}
|
|
return total
|
|
}
|
|
// 입력칸 표시용. formatDuration 과 동일 포맷(mm:ss / h:mm:ss).
|
|
function formatClock(sec) {
|
|
if (sec == null || !isFinite(sec)) return ''
|
|
return formatDuration(sec)
|
|
}
|
|
function extractYouTubeId(url) {
|
|
if (!url) return null
|
|
var m = String(url).match(
|
|
/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|v\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/
|
|
)
|
|
return m ? m[1] : null
|
|
}
|
|
// 재생 길이(초) 표시용. 정수면 "15", 소수면 "12.3".
|
|
function formatLength(sec) {
|
|
if (sec == null || !isFinite(sec) || sec <= 0) return ''
|
|
var r = Math.round(sec * 10) / 10
|
|
return r % 1 === 0 ? String(r) : r.toFixed(1)
|
|
}
|
|
// 시작 + 길이 → 백엔드용 종료초. 영상 끝을 넘으면 null(끝까지)로.
|
|
function computeEndSec(e) {
|
|
var start = e.startSec || 0
|
|
var len = e.lengthSec != null && e.lengthSec > 0 ? e.lengthSec : DEFAULT_LENGTH
|
|
var end = start + len
|
|
if (e.durationSec != null && end >= e.durationSec - 0.01) return null
|
|
return end
|
|
}
|
|
function clampNum(v, lo, hi) { return Math.min(hi, Math.max(lo, v)) }
|
|
|
|
// ─── 일괄 시작/길이 적용 + 초기화 ──────────────────────────────────────
|
|
var bulkStart = document.getElementById('bulkStart')
|
|
var bulkLength = document.getElementById('bulkLength')
|
|
var bulkApplyBtn = document.getElementById('bulkApplyBtn')
|
|
var bulkResetBtn = document.getElementById('bulkResetBtn')
|
|
|
|
bulkApplyBtn.addEventListener('click', function () {
|
|
var s = parseClock(bulkStart.value)
|
|
var startSec = s != null && s > 0 ? s : 0
|
|
var len = parseClock(bulkLength.value)
|
|
var lengthSec = len != null && len > 0 ? len : DEFAULT_LENGTH
|
|
for (var i = 0; i < state.entries.length; i++) {
|
|
var e = state.entries[i]
|
|
e.startSec = startSec
|
|
e.lengthSec = lengthSec
|
|
}
|
|
render()
|
|
})
|
|
bulkResetBtn.addEventListener('click', function () {
|
|
for (var i = 0; i < state.entries.length; i++) {
|
|
state.entries[i].startSec = 0
|
|
state.entries[i].lengthSec = DEFAULT_LENGTH
|
|
}
|
|
bulkStart.value = ''
|
|
bulkLength.value = ''
|
|
render()
|
|
})
|
|
|
|
// ─── YouTube IFrame API 준비 ───────────────────────────────────────────
|
|
var ytReady = false
|
|
var ytReadyCbs = []
|
|
window.onYouTubeIframeAPIReady = function () {
|
|
ytReady = true
|
|
var cbs = ytReadyCbs; ytReadyCbs = []
|
|
cbs.forEach(function (cb) { cb() })
|
|
}
|
|
function whenYt(cb) {
|
|
if (ytReady && window.YT && window.YT.Player) cb()
|
|
else ytReadyCbs.push(cb)
|
|
}
|
|
function resetHost(wrap, id) {
|
|
if (!wrap) return
|
|
wrap.innerHTML = '<div id="' + id + '"></div>'
|
|
}
|
|
|
|
// ─── 미리보기 재생 모달 ────────────────────────────────────────────────
|
|
var previewOverlay = document.getElementById('previewOverlay')
|
|
var previewClose = document.getElementById('previewClose')
|
|
var previewTitle = document.getElementById('previewTitle')
|
|
var previewPlayer = null
|
|
|
|
function openPreview(idx) {
|
|
var e = state.entries[idx]
|
|
if (!e) return
|
|
if (!e.ytId) {
|
|
setRegisterStatus('이 항목은 YouTube 영상이 아니라 미리보기를 지원하지 않습니다.', 'idStatusBad')
|
|
return
|
|
}
|
|
previewTitle.textContent = e.title || ''
|
|
previewOverlay.hidden = false
|
|
whenYt(function () {
|
|
if (previewOverlay.hidden) return // API 준비 전에 닫혔으면 무시
|
|
destroyPreview()
|
|
resetHost(previewOverlay.querySelector('.ytEmbedWrap'), 'previewPlayer')
|
|
previewPlayer = new YT.Player('previewPlayer', {
|
|
videoId: e.ytId,
|
|
playerVars: { autoplay: 1, rel: 0, modestbranding: 1, start: Math.floor(e.startSec || 0) }
|
|
})
|
|
})
|
|
}
|
|
function destroyPreview() {
|
|
if (previewPlayer) { try { previewPlayer.destroy() } catch (_) {} previewPlayer = null }
|
|
}
|
|
function closePreview() {
|
|
previewOverlay.hidden = true
|
|
destroyPreview()
|
|
}
|
|
previewClose.addEventListener('click', closePreview)
|
|
previewOverlay.addEventListener('click', function (e) {
|
|
if (e.target === previewOverlay) closePreview()
|
|
})
|
|
|
|
// ─── 우클릭 컨텍스트 메뉴 (자르기) ─────────────────────────────────────
|
|
var entryCtxMenu = document.getElementById('entryCtxMenu')
|
|
var ctxIdx = -1
|
|
function openEntryCtxMenu(idx, x, y) {
|
|
ctxIdx = idx
|
|
entryCtxMenu.hidden = false
|
|
// 화면 밖으로 넘치지 않게 살짝 보정
|
|
var mw = 180, mh = 60
|
|
entryCtxMenu.style.left = Math.min(x, window.innerWidth - mw) + 'px'
|
|
entryCtxMenu.style.top = Math.min(y, window.innerHeight - mh) + 'px'
|
|
}
|
|
function closeCtxMenu() { entryCtxMenu.hidden = true }
|
|
document.addEventListener('click', closeCtxMenu)
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape') { closeCtxMenu(); closePreview(); closeTrim() }
|
|
})
|
|
entryCtxMenu.querySelector('[data-action="trim"]').addEventListener('click', function () {
|
|
var idx = ctxIdx
|
|
closeCtxMenu()
|
|
if (idx >= 0) openTrim(idx)
|
|
})
|
|
|
|
// ─── 자르기(트림) 모달 — YouTube 임베드 + 타임라인 ─────────────────────
|
|
var trimOverlay = document.getElementById('trimOverlay')
|
|
var trimClose = document.getElementById('trimClose')
|
|
var trimCancel = document.getElementById('trimCancel')
|
|
var trimApply = document.getElementById('trimApply')
|
|
var trimTitle = document.getElementById('trimTitle')
|
|
var plTrimBar = document.getElementById('plTrimBar')
|
|
var plTrimRangeFill = document.getElementById('plTrimRangeFill')
|
|
var plTrimHandleStart = document.getElementById('plTrimHandleStart')
|
|
var plTrimHandleEnd = document.getElementById('plTrimHandleEnd')
|
|
var plTrimPlayhead = document.getElementById('plTrimPlayhead')
|
|
var plTimeReadout = document.getElementById('plTimeReadout')
|
|
var plTrimDuration = document.getElementById('plTrimDuration')
|
|
var plStartSec = document.getElementById('plStartSec')
|
|
var plLengthSec = document.getElementById('plLengthSec')
|
|
|
|
var MIN_SEL = 0.05
|
|
var trimPlayer = null
|
|
var trimPoll = null
|
|
var selStopAt = null // 선택 재생 정지 지점(초). null 이면 비활성.
|
|
// 현재 편집 중 컨텍스트: { idx, duration, start, end(null=끝까지) }
|
|
var tctx = null
|
|
|
|
function tEnd() { return tctx.end != null ? tctx.end : tctx.duration }
|
|
|
|
function formatTimeF(sec) {
|
|
if (!isFinite(sec) || sec < 0) sec = 0
|
|
var m = Math.floor(sec / 60)
|
|
var s = sec - m * 60
|
|
var sStr = s.toFixed(1)
|
|
if (s < 10) sStr = '0' + sStr
|
|
return (m < 10 ? '0' + m : '' + m) + ':' + sStr
|
|
}
|
|
|
|
function renderTrimBar() {
|
|
if (!tctx) return
|
|
var d = tctx.duration || 0
|
|
var end = tEnd()
|
|
var startPct = d ? (tctx.start / d) * 100 : 0
|
|
var endPct = d ? (end / d) * 100 : 0
|
|
plTrimRangeFill.style.left = startPct + '%'
|
|
plTrimRangeFill.style.width = Math.max(0, endPct - startPct) + '%'
|
|
plTrimHandleStart.style.left = startPct + '%'
|
|
plTrimHandleEnd.style.left = endPct + '%'
|
|
plTrimDuration.textContent = '선택: ' + (end - tctx.start).toFixed(1) + '초'
|
|
plStartSec.value = (tctx.start || 0).toFixed(2)
|
|
// 끝 대신 재생 길이(초)를 보여준다 (끝까지면 영상 끝 - 시작).
|
|
plLengthSec.value = Math.max(0, end - tctx.start).toFixed(2)
|
|
}
|
|
function renderPlayhead() {
|
|
if (!tctx) return
|
|
var d = tctx.duration || 0
|
|
var cur = 0
|
|
if (trimPlayer && trimPlayer.getCurrentTime) {
|
|
try { cur = trimPlayer.getCurrentTime() || 0 } catch (_) { cur = 0 }
|
|
}
|
|
plTrimPlayhead.style.left = (d ? clampNum((cur / d) * 100, 0, 100) : 0) + '%'
|
|
plTimeReadout.textContent = formatTimeF(cur) + ' / ' + formatTimeF(d)
|
|
if (selStopAt != null && cur >= selStopAt - 0.05) {
|
|
try { trimPlayer.pauseVideo() } catch (_) {}
|
|
selStopAt = null
|
|
}
|
|
}
|
|
|
|
function openTrim(idx) {
|
|
var e = state.entries[idx]
|
|
if (!e) return
|
|
if (!e.ytId) {
|
|
setRegisterStatus('이 항목은 YouTube 영상이 아니라 자르기를 지원하지 않습니다.', 'idStatusBad')
|
|
return
|
|
}
|
|
trimTitle.textContent = e.title || ''
|
|
trimOverlay.hidden = false
|
|
// 항목의 시작 + 길이를 타임라인의 시작/끝으로 변환. 영상 길이를 넘으면 끝까지로.
|
|
var startSec = e.startSec || 0
|
|
var lenSec = e.lengthSec != null && e.lengthSec > 0 ? e.lengthSec : DEFAULT_LENGTH
|
|
var dur = e.durationSec || 0
|
|
var endSec = startSec + lenSec
|
|
if (dur && endSec >= dur - 0.01) endSec = null
|
|
tctx = {
|
|
idx: idx,
|
|
duration: dur,
|
|
start: startSec,
|
|
end: endSec
|
|
}
|
|
selStopAt = null
|
|
renderTrimBar()
|
|
renderPlayhead()
|
|
whenYt(function () {
|
|
if (trimOverlay.hidden) return // API 준비 전에 닫혔으면 무시
|
|
destroyTrimPlayer()
|
|
resetHost(trimOverlay.querySelector('.ytEmbedWrap'), 'trimPlayer')
|
|
trimPlayer = new YT.Player('trimPlayer', {
|
|
videoId: e.ytId,
|
|
playerVars: { rel: 0, modestbranding: 1, start: Math.floor(tctx.start || 0) },
|
|
events: {
|
|
onReady: function () {
|
|
var d = 0
|
|
try { d = trimPlayer.getDuration() } catch (_) {}
|
|
if (d && isFinite(d) && d > 0) {
|
|
tctx.duration = d
|
|
tctx.start = clampNum(tctx.start, 0, Math.max(0, d - MIN_SEL))
|
|
if (tctx.end != null) tctx.end = clampNum(tctx.end, tctx.start + MIN_SEL, d)
|
|
renderTrimBar()
|
|
}
|
|
startTrimPoll()
|
|
}
|
|
}
|
|
})
|
|
})
|
|
}
|
|
function startTrimPoll() {
|
|
if (trimPoll) clearInterval(trimPoll)
|
|
trimPoll = setInterval(renderPlayhead, 200)
|
|
}
|
|
function destroyTrimPlayer() {
|
|
if (trimPoll) { clearInterval(trimPoll); trimPoll = null }
|
|
if (trimPlayer) { try { trimPlayer.destroy() } catch (_) {} trimPlayer = null }
|
|
}
|
|
function closeTrim() {
|
|
trimOverlay.hidden = true
|
|
selStopAt = null
|
|
destroyTrimPlayer()
|
|
tctx = null
|
|
}
|
|
trimClose.addEventListener('click', closeTrim)
|
|
trimCancel.addEventListener('click', closeTrim)
|
|
trimOverlay.addEventListener('click', function (e) {
|
|
if (e.target === trimOverlay) closeTrim()
|
|
})
|
|
trimApply.addEventListener('click', function () {
|
|
if (!tctx) return
|
|
var e = state.entries[tctx.idx]
|
|
if (e) {
|
|
e.startSec = tctx.start > 0 ? tctx.start : 0
|
|
// 타임라인의 끝(없으면 영상 끝) - 시작 = 재생 길이.
|
|
var end = tctx.end != null ? tctx.end : tctx.duration
|
|
var len = end - e.startSec
|
|
e.lengthSec = len > 0 ? len : DEFAULT_LENGTH
|
|
if (tctx.duration && e.durationSec == null) e.durationSec = tctx.duration
|
|
paintTrimInputs(tctx.idx)
|
|
}
|
|
closeTrim()
|
|
})
|
|
|
|
// 타임라인 드래그/클릭/마크/숫자입력
|
|
function trimClientXtoTime(clientX) {
|
|
var rect = plTrimBar.getBoundingClientRect()
|
|
var ratio = clampNum((clientX - rect.left) / rect.width, 0, 1)
|
|
return ratio * (tctx ? tctx.duration : 0)
|
|
}
|
|
function attachTrimHandle(handle, which) {
|
|
handle.addEventListener('pointerdown', function (e) {
|
|
if (!tctx || !tctx.duration) return
|
|
e.preventDefault()
|
|
handle.setPointerCapture(e.pointerId)
|
|
handle.classList.add('dragging')
|
|
function onMove(ev) {
|
|
var t = trimClientXtoTime(ev.clientX)
|
|
if (which === 'start') tctx.start = clampNum(t, 0, tEnd() - MIN_SEL)
|
|
else tctx.end = clampNum(t, tctx.start + MIN_SEL, tctx.duration)
|
|
renderTrimBar()
|
|
}
|
|
function onUp() {
|
|
handle.classList.remove('dragging')
|
|
handle.removeEventListener('pointermove', onMove)
|
|
handle.removeEventListener('pointerup', onUp)
|
|
handle.removeEventListener('pointercancel', onUp)
|
|
}
|
|
handle.addEventListener('pointermove', onMove)
|
|
handle.addEventListener('pointerup', onUp)
|
|
handle.addEventListener('pointercancel', onUp)
|
|
})
|
|
}
|
|
attachTrimHandle(plTrimHandleStart, 'start')
|
|
attachTrimHandle(plTrimHandleEnd, 'end')
|
|
plTrimBar.addEventListener('pointerdown', function (e) {
|
|
if (!tctx || !tctx.duration) return
|
|
if (e.target === plTrimHandleStart || e.target === plTrimHandleEnd) return
|
|
var t = trimClientXtoTime(e.clientX)
|
|
if (trimPlayer && trimPlayer.seekTo) { try { trimPlayer.seekTo(t, true) } catch (_) {} }
|
|
renderPlayhead()
|
|
})
|
|
trimOverlay.querySelectorAll('[data-pmark]').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
if (!tctx || !tctx.duration || !trimPlayer) return
|
|
var t = 0
|
|
try { t = trimPlayer.getCurrentTime() || 0 } catch (_) {}
|
|
if (btn.getAttribute('data-pmark') === 'start') tctx.start = clampNum(t, 0, tEnd() - MIN_SEL)
|
|
else tctx.end = clampNum(t, tctx.start + MIN_SEL, tctx.duration)
|
|
renderTrimBar()
|
|
})
|
|
})
|
|
trimOverlay.querySelectorAll('[data-paction]').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
var action = btn.getAttribute('data-paction')
|
|
if (action === 'reset') {
|
|
if (!tctx) return
|
|
tctx.start = 0; tctx.end = null
|
|
renderTrimBar()
|
|
} else if (action === 'playSelection') {
|
|
if (!tctx || !trimPlayer) return
|
|
try {
|
|
trimPlayer.seekTo(tctx.start, true)
|
|
trimPlayer.playVideo()
|
|
} catch (_) {}
|
|
selStopAt = tEnd()
|
|
}
|
|
})
|
|
})
|
|
plStartSec.addEventListener('change', function () {
|
|
if (!tctx) return
|
|
var v = Number(plStartSec.value || 0)
|
|
if (!isFinite(v) || v < 0) v = 0
|
|
tctx.start = tctx.duration ? clampNum(v, 0, tEnd() - MIN_SEL) : v
|
|
renderTrimBar()
|
|
})
|
|
plLengthSec.addEventListener('change', function () {
|
|
if (!tctx) return
|
|
// 길이(초) 입력 → 끝 = 시작 + 길이.
|
|
var v = Number(plLengthSec.value)
|
|
if (!isFinite(v) || v <= 0) { renderTrimBar(); return }
|
|
var end = tctx.start + v
|
|
tctx.end = tctx.duration ? clampNum(end, tctx.start + MIN_SEL, tctx.duration) : end
|
|
renderTrimBar()
|
|
})
|
|
|
|
// ─── 임시저장(draft) ───────────────────────────────────────────────────
|
|
// 다운로드 시작 전, 수정해둔 값(URL + 항목별 제목/ID/시작/길이 등)을 폴더당 1개 저장/복원.
|
|
var draftUrl = folderBaseUrl + '/playlist/draft'
|
|
var draftDeleteUrl = folderBaseUrl + '/playlist/draft/delete'
|
|
var draftSaveBtn = document.getElementById('draftSaveBtn')
|
|
var draftLoadBtn = document.getElementById('draftLoadBtn')
|
|
var draftDeleteBtn = document.getElementById('draftDeleteBtn')
|
|
var draftStatus = document.getElementById('draftStatus')
|
|
|
|
function setDraftStatus(text, cls) {
|
|
if (!draftStatus) return
|
|
draftStatus.textContent = text || ''
|
|
draftStatus.className = cls || 'muted'
|
|
}
|
|
|
|
function formatSavedAt(iso) {
|
|
try {
|
|
var d = new Date(iso)
|
|
if (isNaN(d.getTime())) return ''
|
|
return d.toLocaleString()
|
|
} catch (_) { return '' }
|
|
}
|
|
|
|
// 저장 대상: 편집 가능한 값만 추린다 (idStatus/idDebounce 같은 휘발 상태 제외).
|
|
function serializeDraft() {
|
|
return {
|
|
url: (urlInput.value || '').trim(),
|
|
idMode: state.idMode,
|
|
zeroPad: state.zeroPad,
|
|
entries: state.entries.map(function (e) {
|
|
return {
|
|
url: e.url,
|
|
title: e.title || '',
|
|
videoId: e.videoId || '',
|
|
durationSec: e.durationSec != null ? e.durationSec : null,
|
|
thumbnailUrl: e.thumbnailUrl || null,
|
|
ytId: e.ytId || null,
|
|
startSec: e.startSec || 0,
|
|
lengthSec: e.lengthSec != null ? e.lengthSec : DEFAULT_LENGTH
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// idMode 라디오 / zeroPad 토글을 상태에 맞춰 반영.
|
|
function reflectIdModeControls() {
|
|
for (var i = 0; i < idModeRadios.length; i++) {
|
|
idModeRadios[i].checked = idModeRadios[i].value === state.idMode
|
|
}
|
|
zeroPadWrap.hidden = state.idMode !== 'sequential'
|
|
zeroPadToggle.checked = !!state.zeroPad
|
|
}
|
|
|
|
function restoreDraft(d) {
|
|
if (!d || typeof d !== 'object') return
|
|
urlInput.value = d.url || ''
|
|
state.idMode = d.idMode === 'sequential' ? 'sequential' : 'random'
|
|
state.zeroPad = !!d.zeroPad
|
|
var raw = Array.isArray(d.entries) ? d.entries : []
|
|
state.entries = raw.map(function (e) {
|
|
return {
|
|
url: e.url,
|
|
title: e.title || '',
|
|
videoId: (e.videoId || '').trim(),
|
|
durationSec: e.durationSec != null ? e.durationSec : null,
|
|
thumbnailUrl: e.thumbnailUrl || null,
|
|
ytId: e.ytId || extractYouTubeId(e.url),
|
|
startSec: e.startSec || 0,
|
|
lengthSec: e.lengthSec != null && e.lengthSec > 0 ? e.lengthSec : DEFAULT_LENGTH,
|
|
idStatus: 'pending',
|
|
idReason: ''
|
|
}
|
|
})
|
|
reflectIdModeControls()
|
|
render()
|
|
entriesSection.hidden = state.entries.length === 0
|
|
// 저장된 ID 는 그대로 두고(자동 재생성 금지), 가용성만 다시 확인.
|
|
for (var i = 0; i < state.entries.length; i++) scheduleIdCheck(i, 0)
|
|
}
|
|
|
|
function showDraftButtons(updatedAt) {
|
|
if (draftLoadBtn) draftLoadBtn.hidden = false
|
|
if (draftDeleteBtn) draftDeleteBtn.hidden = false
|
|
setDraftStatus('임시저장됨: ' + formatSavedAt(updatedAt), 'muted')
|
|
}
|
|
function hideDraftButtons() {
|
|
if (draftLoadBtn) draftLoadBtn.hidden = true
|
|
if (draftDeleteBtn) draftDeleteBtn.hidden = true
|
|
}
|
|
|
|
if (draftSaveBtn) {
|
|
draftSaveBtn.addEventListener('click', function () {
|
|
draftSaveBtn.disabled = true
|
|
setDraftStatus('저장 중…', 'muted')
|
|
fetch(draftUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ data: serializeDraft() })
|
|
})
|
|
.then(function (r) { return r.json() })
|
|
.then(function (j) {
|
|
if (j && j.ok) {
|
|
showDraftButtons(j.updatedAt)
|
|
setDraftStatus('임시저장 완료 (' + formatSavedAt(j.updatedAt) + ')', 'idStatusOk')
|
|
} else {
|
|
setDraftStatus((j && j.message) || '저장 실패', 'idStatusBad')
|
|
}
|
|
})
|
|
.catch(function (err) {
|
|
setDraftStatus('저장 실패: ' + (err && err.message ? err.message : err), 'idStatusBad')
|
|
})
|
|
.finally(function () { draftSaveBtn.disabled = false })
|
|
})
|
|
}
|
|
|
|
if (draftLoadBtn) {
|
|
draftLoadBtn.addEventListener('click', function () {
|
|
if (state.entries.length > 0 &&
|
|
!window.confirm('현재 편집 중인 목록을 임시저장 내용으로 덮어쓸까요?')) {
|
|
return
|
|
}
|
|
draftLoadBtn.disabled = true
|
|
setDraftStatus('불러오는 중…', 'muted')
|
|
fetch(draftUrl, { cache: 'no-store' })
|
|
.then(function (r) { return r.json() })
|
|
.then(function (j) {
|
|
if (j && j.ok && j.draft) {
|
|
var data
|
|
try { data = JSON.parse(j.draft.data) } catch (_) { data = null }
|
|
if (!data) { setDraftStatus('임시저장 데이터를 읽을 수 없습니다.', 'idStatusBad'); return }
|
|
restoreDraft(data)
|
|
setDraftStatus('임시저장 불러옴 (' + formatSavedAt(j.draft.updatedAt) + ')', 'idStatusOk')
|
|
} else {
|
|
hideDraftButtons()
|
|
setDraftStatus('저장된 임시저장이 없습니다.', 'muted')
|
|
}
|
|
})
|
|
.catch(function (err) {
|
|
setDraftStatus('불러오기 실패: ' + (err && err.message ? err.message : err), 'idStatusBad')
|
|
})
|
|
.finally(function () { draftLoadBtn.disabled = false })
|
|
})
|
|
}
|
|
|
|
if (draftDeleteBtn) {
|
|
draftDeleteBtn.addEventListener('click', function () {
|
|
if (!window.confirm('저장된 임시저장을 삭제할까요?')) return
|
|
draftDeleteBtn.disabled = true
|
|
fetch(draftDeleteUrl, { method: 'POST' })
|
|
.then(function (r) { return r.json() })
|
|
.then(function (j) {
|
|
if (j && j.ok) {
|
|
hideDraftButtons()
|
|
setDraftStatus('임시저장을 삭제했습니다.', 'muted')
|
|
} else {
|
|
setDraftStatus((j && j.message) || '삭제 실패', 'idStatusBad')
|
|
}
|
|
})
|
|
.catch(function (err) {
|
|
setDraftStatus('삭제 실패: ' + (err && err.message ? err.message : err), 'idStatusBad')
|
|
})
|
|
.finally(function () { draftDeleteBtn.disabled = false })
|
|
})
|
|
}
|
|
|
|
// 진입 시 기존 임시저장 여부 확인 → 있으면 불러오기/삭제 버튼 노출.
|
|
fetch(draftUrl, { cache: 'no-store' })
|
|
.then(function (r) { return r.json() })
|
|
.then(function (j) {
|
|
if (j && j.ok && j.draft) showDraftButtons(j.draft.updatedAt)
|
|
})
|
|
.catch(function () {})
|
|
|
|
// 초기 상태
|
|
updateRegisterEnable()
|
|
})()
|