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:
@@ -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()
|
||||
})()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user