/* 플레이리스트 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}$/
// ─── state ────────────────────────────────────────────────────────────
// entries: [{ url, title, videoId, durationSec, 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')
// ─── 유틸 ─────────────────────────────────────────────────────────────
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), { 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 = ''
+ '≡'
+ '' + (idx + 1) + ''
+ '
'
+ ''
+ ''
+ ''
var thumbBox = li.querySelector('.entryThumb')
var titleInput = li.querySelector('.entryTitleInput')
var idInput = li.querySelector('.entryIdInput')
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)
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)
})
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 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,
videoId: '',
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()
}
})
}
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
}
setRegisterStatus(
(res.body.jobs ? res.body.jobs.length : 0) + ' 개 다운로드가 큐에 등록되었습니다. 폴더 페이지로 이동합니다…',
'idStatusOk'
)
setTimeout(function () {
location.href = folderBaseUrl
}, 800)
})
.catch(function (err) {
state.busy = false
setRegisterStatus('요청 실패: ' + (err && err.message ? err.message : err), 'idStatusBad')
updateRegisterEnable()
})
})
// 초기 상태
updateRegisterEnable()
})()