/* 플레이리스트 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() })()