diff --git a/public/playlist.js b/public/playlist.js
new file mode 100644
index 0000000..e4aa854
--- /dev/null
+++ b/public/playlist.js
@@ -0,0 +1,431 @@
+/* 플레이리스트 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 titleInput = li.querySelector('.entryTitleInput')
+ var idInput = li.querySelector('.entryIdInput')
+ var durSpan = li.querySelector('.entryDuration')
+ var removeBtn = li.querySelector('.entryRemove')
+
+ 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,
+ 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()
+})()
diff --git a/public/styles.css b/public/styles.css
index c92cea1..872db75 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -312,3 +312,90 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
}
.adminFolder { position: relative; }
.adminVideo { position: relative; }
+
+/* ─── 플레이리스트 import ────────────────────────────────────────────── */
+
+.playlistProbeSection {
+ background: var(--bg-card); border: 1px solid var(--border);
+ border-radius: 12px; padding: 18px 20px;
+}
+.playlistProbeRow {
+ display: flex; gap: 10px; margin-top: 10px; flex-wrap: wrap;
+}
+.playlistProbeRow input[type="text"] {
+ flex: 1; min-width: 320px;
+ background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
+ color: var(--text); padding: 8px 12px; font-size: 14px;
+}
+
+.playlistEntriesSection {
+ margin-top: 18px;
+ display: flex; flex-direction: column; gap: 14px;
+}
+
+.playlistControlsRow {
+ display: flex; align-items: center; justify-content: space-between;
+ flex-wrap: wrap; gap: 12px;
+}
+.idModeBox {
+ display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
+ background: var(--bg-card); border: 1px solid var(--border);
+ border-radius: 10px; padding: 10px 14px;
+}
+.idModeLabel { color: var(--text-muted); font-size: 13px; }
+.idModeBox label { color: var(--text); font-size: 14px; cursor: pointer; }
+.zeroPadWrap { margin-left: 8px; color: var(--text-muted) !important; font-size: 13px; }
+.playlistSummary { color: var(--text-muted); font-size: 13px; }
+
+.playlistEntryList {
+ list-style: none; margin: 0; padding: 0;
+ display: flex; flex-direction: column; gap: 6px;
+}
+.playlistEntryRow {
+ display: grid;
+ grid-template-columns: 26px 36px 1fr 70px 28px;
+ align-items: center; gap: 10px;
+ background: var(--bg-card); border: 1px solid var(--border);
+ border-radius: 10px; padding: 8px 10px;
+}
+.playlistEntryRow.dragGhost { opacity: 0.5; }
+.dragHandle {
+ cursor: grab; color: var(--text-muted); font-size: 18px;
+ user-select: none; text-align: center;
+}
+.entryNum {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ color: var(--text-muted); font-size: 13px; text-align: right;
+}
+.entryFields {
+ display: flex; flex-direction: column; gap: 4px; min-width: 0;
+}
+.entryTitleInput {
+ background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
+ color: var(--text); padding: 5px 8px; font-size: 14px; width: 100%;
+}
+.entryIdRow {
+ display: flex; align-items: center; gap: 8px;
+}
+.entryIdInput {
+ background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
+ color: var(--text); padding: 4px 8px; font-size: 13px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ width: 240px; max-width: 100%;
+}
+.entryIdStatus { font-size: 12px; }
+.entryDuration {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 12px; text-align: right;
+}
+.entryRemove {
+ background: transparent; border: 1px solid var(--border);
+ color: var(--text-muted); border-radius: 6px; cursor: pointer;
+ width: 28px; height: 28px; padding: 0; font-size: 14px;
+}
+.entryRemove:hover { color: #e57373; border-color: #e57373; }
+
+.playlistFooter {
+ display: flex; align-items: center; justify-content: space-between;
+ gap: 12px; flex-wrap: wrap; margin-top: 8px;
+}
diff --git a/src/routes/op.ts b/src/routes/op.ts
index e60013c..9535253 100644
--- a/src/routes/op.ts
+++ b/src/routes/op.ts
@@ -218,6 +218,34 @@ opRouter.get(
}
)
+// ─── 플레이리스트 import 페이지 ─────────────────────────────────────────
+
+opRouter.get(
+ '/op/folder/:topName/:subName/playlist/new',
+ requireAuth,
+ (req, res, next) => {
+ try {
+ const segments = collectFolderSegments(req.params)
+ const folder = getFolderByPath(segments)
+ if (!folder) {
+ res.status(404).send('폴더를 찾을 수 없습니다.')
+ return
+ }
+ if (folder.parentId === null) {
+ res.status(400).send('플레이리스트는 하위 폴더에서만 등록할 수 있습니다.')
+ return
+ }
+ res.render('op/playlist', {
+ userId: req.session.userId,
+ folder,
+ breadcrumb: folderPathNames(folder.id)
+ })
+ } catch (err) {
+ next(err)
+ }
+ }
+)
+
// ─── 영상 업로드 / 유튜브 ───────────────────────────────────────────────
function uploadSingle(fieldName: string) {
diff --git a/views/op/playlist.ejs b/views/op/playlist.ejs
new file mode 100644
index 0000000..c398871
--- /dev/null
+++ b/views/op/playlist.ejs
@@ -0,0 +1,64 @@
+
+
+
+
+
+ 플레이리스트 추가 · <%= breadcrumb.join(' / ') %>
+
+
+
+ <%
+ var folderPathEnc = breadcrumb.map(function (s) { return encodeURIComponent(s) }).join('/')
+ var folderHref = '/op/folder/' + folderPathEnc
+ var folderLabel = breadcrumb.join(' / ')
+ %>
+
+
+
+
+ 유튜브 플레이리스트 주소를 입력하면 항목 목록을 불러옵니다.
+
+
+
+
+
+
+
+
+
+
+
+
+
+