feat: 플레이리스트 추가 화면 임시저장(draft) 기능

다운로드 시작 전, 수정해둔 값(플레이리스트 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>
This commit is contained in:
Claude (owner)
2026-06-06 16:22:42 +09:00
parent 0ec6b06b57
commit 042d9f7059
6 changed files with 292 additions and 1 deletions

View File

@@ -1003,6 +1003,179 @@
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()
})()

View File

@@ -356,6 +356,9 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
color: var(--text); padding: 8px 12px; font-size: 14px;
}
.playlistDraftRow {
display: flex; gap: 10px; margin-top: 12px; align-items: center; flex-wrap: wrap;
}
.playlistEntriesSection {
margin-top: 18px;

View File

@@ -88,6 +88,15 @@ function createSchema(db: Database.Database): void {
UNIQUE(folder_id, id)
);
CREATE INDEX IF NOT EXISTS videos_folder_idx ON videos(folder_id);
-- 플레이리스트 추가 화면의 "임시저장": 폴더당 1개의 작업중 상태(JSON)를 보관한다.
-- 다운로드 시작 전에 수정해둔 값(플레이리스트 URL + 항목별 제목/ID/구간 등)을
-- 저장해 두었다가 나중에 불러온다.
CREATE TABLE IF NOT EXISTS playlist_drafts (
folder_id INTEGER PRIMARY KEY REFERENCES folders(id) ON DELETE CASCADE,
data TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`)
}

View File

@@ -22,7 +22,10 @@ import {
moveUploadIntoVideoDir,
renameFolder,
updateVideo,
videoExistsInFolder
videoExistsInFolder,
savePlaylistDraft,
getPlaylistDraft,
deletePlaylistDraft
} from '../storeDb.js'
import { tmpDir } from '../paths.js'
import {
@@ -266,6 +269,72 @@ opRouter.get(
}
)
// ─── 플레이리스트 임시저장(draft) ──────────────────────────────────────
// 다운로드 시작 전 수정해둔 값(URL + 항목별 제목/ID/구간)을 폴더당 1개 저장/복원.
/** 하위 폴더를 경로로 해석. 못 찾거나 최상위면 throw. */
function requireSubFolder(req: Request): { id: number } {
const folder = getFolderByPath(collectFolderSegments(req.params))
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
if (folder.parentId === null) {
throw new Error('플레이리스트는 하위 폴더에서만 사용할 수 있습니다.')
}
return folder
}
// 임시저장 JSON 크기 상한 (과도한 payload 방지). 2 MiB 면 수백 항목도 충분.
const DRAFT_MAX_CHARS = 2 * 1024 * 1024
opRouter.get(
'/op/folder/:topName/:subName/playlist/draft',
requireAuth,
(req, res) => {
try {
const folder = requireSubFolder(req)
const draft = getPlaylistDraft(folder.id)
res.json({ ok: true, draft })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
}
)
opRouter.post(
'/op/folder/:topName/:subName/playlist/draft',
requireAuth,
(req, res) => {
try {
const folder = requireSubFolder(req)
const data = req.body?.data
if (typeof data !== 'object' || data === null) {
throw new Error('저장할 데이터가 올바르지 않습니다.')
}
const json = JSON.stringify(data)
if (json.length > DRAFT_MAX_CHARS) {
throw new Error('임시저장 데이터가 너무 큽니다.')
}
const saved = savePlaylistDraft(folder.id, json)
res.json({ ok: true, updatedAt: saved.updatedAt })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
}
)
opRouter.post(
'/op/folder/:topName/:subName/playlist/draft/delete',
requireAuth,
(req, res) => {
try {
const folder = requireSubFolder(req)
deletePlaylistDraft(folder.id)
res.json({ ok: true })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
}
)
// ─── 영상 업로드 / 유튜브 ───────────────────────────────────────────────
function uploadSingle(fieldName: string) {

View File

@@ -547,3 +547,34 @@ export async function moveUploadIntoVideoDir(
}
}
}
// ─── 플레이리스트 임시저장(draft) ──────────────────────────────────────
// 폴더당 1개의 작업중 상태(JSON 문자열)를 보관. 다운로드 시작 전 수정값을 저장/복원.
export interface PlaylistDraft {
data: string
updatedAt: string
}
export function savePlaylistDraft(folderId: number, data: string): PlaylistDraft {
const now = new Date().toISOString()
getDb()
.prepare(
`INSERT INTO playlist_drafts (folder_id, data, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(folder_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
)
.run(folderId, data, now)
return { data, updatedAt: now }
}
export function getPlaylistDraft(folderId: number): PlaylistDraft | null {
const r = getDb()
.prepare(`SELECT data, updated_at FROM playlist_drafts WHERE folder_id = ?`)
.get(folderId) as { data: string; updated_at: string } | undefined
return r ? { data: r.data, updatedAt: r.updated_at } : null
}
export function deletePlaylistDraft(folderId: number): void {
getDb().prepare(`DELETE FROM playlist_drafts WHERE folder_id = ?`).run(folderId)
}

View File

@@ -27,6 +27,12 @@
<button type="button" class="primaryButton" id="probeBtn">목록 불러오기</button>
</div>
<p id="probeStatus" class="muted"></p>
<div class="playlistDraftRow">
<button type="button" class="secondaryButton" id="draftSaveBtn">임시저장</button>
<button type="button" class="secondaryButton" id="draftLoadBtn" hidden>불러오기</button>
<button type="button" class="secondaryButton" id="draftDeleteBtn" hidden>임시저장 삭제</button>
<span id="draftStatus" class="muted"></span>
</div>
</section>
<section class="playlistEntriesSection" id="entriesSection" hidden>