diff --git a/public/playlist.js b/public/playlist.js index b12a4c0..1c7e662 100644 --- a/public/playlist.js +++ b/public/playlist.js @@ -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() })() diff --git a/public/styles.css b/public/styles.css index 04b3dc1..24540ab 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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; diff --git a/src/db.ts b/src/db.ts index 7ffa6cb..882694c 100644 --- a/src/db.ts +++ b/src/db.ts @@ -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 + ); `) } diff --git a/src/routes/op.ts b/src/routes/op.ts index ae6312d..589d080 100644 --- a/src/routes/op.ts +++ b/src/routes/op.ts @@ -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) { diff --git a/src/storeDb.ts b/src/storeDb.ts index 180d240..bacb0b2 100644 --- a/src/storeDb.ts +++ b/src/storeDb.ts @@ -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) +} diff --git a/views/op/playlist.ejs b/views/op/playlist.ejs index 5abc90d..6b512e5 100644 --- a/views/op/playlist.ejs +++ b/views/op/playlist.ejs @@ -27,6 +27,12 @@
+