Compare commits
2 Commits
66469ca418
...
042d9f7059
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
042d9f7059 | ||
|
|
0ec6b06b57 |
@@ -56,7 +56,7 @@
|
||||
changeIdBtn.disabled = true
|
||||
clearTimeout(idDebounce)
|
||||
idDebounce = setTimeout(function () {
|
||||
fetch('/op/videos/idAvailable?id=' + encodeURIComponent(v), { cache: 'no-store' })
|
||||
fetch('/op/videos/idAvailable?id=' + encodeURIComponent(v) + '&folderId=' + encodeURIComponent(ctx.folderId), { cache: 'no-store' })
|
||||
.then(function (r) { return r.json() })
|
||||
.then(function (j) {
|
||||
// stale 응답 (사용자가 그 사이에 입력을 더 바꾼 경우) 무시
|
||||
@@ -87,7 +87,7 @@
|
||||
fetch('/op/videos/' + encodeURIComponent(video.id) + '/changeId', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ newId: newId })
|
||||
body: JSON.stringify({ newId: newId, folderId: ctx.folderId })
|
||||
}).then(function (r) { return r.json() }).then(function (j) {
|
||||
if (!j.ok) {
|
||||
setIdStatus(j.message || 'ID 변경 실패', 'idStatusBad')
|
||||
@@ -464,7 +464,8 @@
|
||||
var payload = {
|
||||
title: titleInput.value,
|
||||
startSec: trimStart,
|
||||
endSec: trimEnd
|
||||
endSec: trimEnd,
|
||||
folderId: ctx.folderId
|
||||
}
|
||||
saveBtn.disabled = true
|
||||
saveBtn.textContent = '저장 중...'
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/rename', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ title: t })
|
||||
body: JSON.stringify({ title: t, folderId: op.folderId })
|
||||
}).then(function (r) { return r.json() }).then(function (j) {
|
||||
if (j.ok) location.reload()
|
||||
else alert(j.message || '이름 변경 실패')
|
||||
@@ -51,7 +51,7 @@
|
||||
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/changeId', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ newId: newId })
|
||||
body: JSON.stringify({ newId: newId, folderId: op.folderId })
|
||||
}).then(function (r) { return r.json() }).then(function (j) {
|
||||
if (j.ok) location.reload()
|
||||
else alert(j.message || 'ID 변경 실패')
|
||||
@@ -60,7 +60,9 @@
|
||||
} else if (action === 'delete') {
|
||||
if (window.confirm('"' + videoTargetTitle + '" 영상을 정말 삭제할까요?')) {
|
||||
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/delete', {
|
||||
method: 'POST'
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ folderId: op.folderId })
|
||||
}).then(function (r) { return r.json() }).then(function (j) {
|
||||
if (j.ok) location.reload()
|
||||
else alert(j.message || '삭제 실패')
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
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' })
|
||||
fetch('/op/videos/idAvailable?id=' + encodeURIComponent(v) + '&folderId=' + encodeURIComponent(ctx.folderId), { cache: 'no-store' })
|
||||
.then(function (r) { return r.json() })
|
||||
.then(function (j) {
|
||||
var c = state.entries[idx]
|
||||
@@ -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;
|
||||
|
||||
76
src/db.ts
76
src/db.ts
@@ -14,7 +14,8 @@
|
||||
* 폴더 깊이 제약: 최상위(parent_id IS NULL) 또는 그 자식(서브폴더) 까지만.
|
||||
* 서브폴더는 자식 폴더를 가질 수 없다. 코드 레벨에서 검사.
|
||||
*
|
||||
* 영상 ID: 사용자 편집 가능. 전역 유일 (PRIMARY KEY). 변경 시 디렉토리 이름도 같이 바뀐다.
|
||||
* 영상 ID: 사용자 편집 가능. 폴더 안에서만 유일 (UNIQUE(folder_id, id)).
|
||||
* 서로 다른 폴더면 같은 ID(예: 1,2,3,4)를 써도 된다. 변경 시 디렉토리 이름도 같이 바뀐다.
|
||||
*/
|
||||
import Database from 'better-sqlite3'
|
||||
import { promises as fsp } from 'node:fs'
|
||||
@@ -43,6 +44,7 @@ export async function initDatabase(): Promise<void> {
|
||||
db.pragma('synchronous = NORMAL')
|
||||
|
||||
createSchema(db)
|
||||
migrateVideosSchema(db)
|
||||
_db = db
|
||||
|
||||
// 비어 있으면 디스크 스캔 → 마이그레이션.
|
||||
@@ -70,7 +72,7 @@ function createSchema(db: Database.Database): void {
|
||||
ON folders(name) WHERE parent_id IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS videos (
|
||||
id TEXT PRIMARY KEY,
|
||||
id TEXT NOT NULL,
|
||||
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
original_file TEXT NOT NULL,
|
||||
@@ -82,12 +84,80 @@ function createSchema(db: Database.Database): void {
|
||||
trim_end_sec REAL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
updated_at TEXT NOT NULL,
|
||||
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
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 구(舊) 스키마(videos.id 가 전역 PRIMARY KEY)를 신(新) 스키마(UNIQUE(folder_id, id))로
|
||||
* 마이그레이션한다. createSchema 의 CREATE TABLE IF NOT EXISTS 는 이미 존재하는 테이블을
|
||||
* 건드리지 않으므로, 운영 DB 의 기존 videos 테이블은 이 함수로만 바뀐다.
|
||||
*
|
||||
* 판별: PRAGMA table_info(videos) 에서 id 컬럼의 pk 가 1 이면 구 스키마.
|
||||
* 기존 id 는 전역 유일이므로 (folder_id, id) 로 옮겨도 충돌이 없다.
|
||||
*/
|
||||
function migrateVideosSchema(db: Database.Database): void {
|
||||
const cols = db.prepare(`PRAGMA table_info(videos)`).all() as Array<{
|
||||
name: string
|
||||
pk: number
|
||||
}>
|
||||
const idCol = cols.find((c) => c.name === 'id')
|
||||
// 테이블이 없거나(첫 생성), id 가 PK 가 아니면(이미 신 스키마) 할 일 없음.
|
||||
if (!idCol || idCol.pk === 0) return
|
||||
|
||||
db.pragma('foreign_keys = OFF')
|
||||
try {
|
||||
const rebuild = db.transaction(() => {
|
||||
db.exec(`
|
||||
CREATE TABLE videos_new (
|
||||
id TEXT NOT NULL,
|
||||
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
original_file TEXT NOT NULL,
|
||||
edited_file TEXT,
|
||||
duration_sec REAL,
|
||||
source_type TEXT NOT NULL CHECK (source_type IN ('upload', 'youtube')),
|
||||
source_url TEXT,
|
||||
trim_start_sec REAL,
|
||||
trim_end_sec REAL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(folder_id, id)
|
||||
);
|
||||
INSERT INTO videos_new
|
||||
(id, folder_id, title, original_file, edited_file, duration_sec,
|
||||
source_type, source_url, trim_start_sec, trim_end_sec,
|
||||
position, created_at, updated_at)
|
||||
SELECT
|
||||
id, folder_id, title, original_file, edited_file, duration_sec,
|
||||
source_type, source_url, trim_start_sec, trim_end_sec,
|
||||
position, created_at, updated_at
|
||||
FROM videos;
|
||||
DROP TABLE videos;
|
||||
ALTER TABLE videos_new RENAME TO videos;
|
||||
CREATE INDEX IF NOT EXISTS videos_folder_idx ON videos(folder_id);
|
||||
`)
|
||||
})
|
||||
rebuild()
|
||||
console.log('[db] videos 스키마 마이그레이션 완료: UNIQUE(folder_id, id)')
|
||||
} finally {
|
||||
db.pragma('foreign_keys = ON')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 디스크 트리 (data/folders/{name}/{videoId}/meta.json) 를 스캔해서 DB 에 적재.
|
||||
* 기존은 1단계(폴더→영상) 만 있으므로 서브폴더는 생기지 않는다.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { promises as fs, existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { getVideo, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js'
|
||||
import { getVideoInFolder, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js'
|
||||
import { binDir } from './paths.js'
|
||||
|
||||
export class FfmpegUnavailableError extends Error {
|
||||
@@ -408,13 +408,14 @@ export async function upscaleOriginalTo60Fps(
|
||||
* stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩.
|
||||
*/
|
||||
export async function applyTrimToVideo(
|
||||
folderId: number,
|
||||
videoId: string,
|
||||
trim: VideoTrim
|
||||
): Promise<string> {
|
||||
const bin = getFfmpegPath()
|
||||
const meta = getVideo(videoId)
|
||||
const meta = getVideoInFolder(folderId, videoId)
|
||||
if (!meta) throw new Error('비디오를 찾을 수 없습니다.')
|
||||
const dir = videoDiskDir(videoId)
|
||||
const dir = videoDiskDir(folderId, videoId)
|
||||
const inputPath = path.join(dir, meta.originalFile)
|
||||
await fs.access(inputPath)
|
||||
|
||||
@@ -476,7 +477,7 @@ export async function applyTrimToVideo(
|
||||
}
|
||||
await fs.rename(tmpPath, outPath)
|
||||
|
||||
updateVideo(videoId, {
|
||||
updateVideo(folderId, videoId, {
|
||||
editedFile: outName,
|
||||
trim: { startSec, endSec }
|
||||
})
|
||||
@@ -496,10 +497,10 @@ const THUMB_NAME = 'thumb.jpg'
|
||||
* 카드에서는 ▶ placeholder 로 폴백.
|
||||
* 한 번 만들면 파일로 캐시되어 다음 요청부터는 즉시 응답.
|
||||
*/
|
||||
export async function ensureThumbnail(videoId: string): Promise<string | null> {
|
||||
const meta = getVideo(videoId)
|
||||
export async function ensureThumbnail(folderId: number, videoId: string): Promise<string | null> {
|
||||
const meta = getVideoInFolder(folderId, videoId)
|
||||
if (!meta) return null
|
||||
const dir = videoDiskDir(videoId)
|
||||
const dir = videoDiskDir(folderId, videoId)
|
||||
const thumbPath = path.join(dir, THUMB_NAME)
|
||||
try {
|
||||
await fs.access(thumbPath)
|
||||
|
||||
120
src/routes/op.ts
120
src/routes/op.ts
@@ -13,7 +13,8 @@ import {
|
||||
folderPathNames,
|
||||
getFolder,
|
||||
getFolderByPath,
|
||||
getVideo,
|
||||
getVideoInFolder,
|
||||
getVideoByIdGlobal,
|
||||
isSafeVideoId,
|
||||
listChildFolders,
|
||||
listTopFolders,
|
||||
@@ -21,7 +22,10 @@ import {
|
||||
moveUploadIntoVideoDir,
|
||||
renameFolder,
|
||||
updateVideo,
|
||||
videoIdExists
|
||||
videoExistsInFolder,
|
||||
savePlaylistDraft,
|
||||
getPlaylistDraft,
|
||||
deletePlaylistDraft
|
||||
} from '../storeDb.js'
|
||||
import { tmpDir } from '../paths.js'
|
||||
import {
|
||||
@@ -224,7 +228,7 @@ opRouter.get(
|
||||
}
|
||||
// 영상은 1단계·2단계 폴더 어디서나 추가할 수 있다. (플레이리스트만 2단계 한정)
|
||||
const videoId = typeof req.query.id === 'string' ? req.query.id : null
|
||||
const video = videoId ? getVideo(videoId) : null
|
||||
const video = videoId ? getVideoInFolder(folder.id, videoId) : null
|
||||
res.render('op/editor', {
|
||||
userId: req.session.userId,
|
||||
folder,
|
||||
@@ -265,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) {
|
||||
@@ -304,7 +374,7 @@ opRouter.post(
|
||||
originalFile: destName,
|
||||
sourceType: 'upload'
|
||||
})
|
||||
await moveUploadIntoVideoDir(video.id, file.path, destName)
|
||||
await moveUploadIntoVideoDir(folder.id, video.id, file.path, destName)
|
||||
res.json({ ok: true, videoId: video.id })
|
||||
} catch (err) {
|
||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||
@@ -430,7 +500,7 @@ opRouter.post(
|
||||
if (seen.has(videoId)) {
|
||||
throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 요청 내에서 중복됩니다.`)
|
||||
}
|
||||
if (videoIdExists(videoId)) {
|
||||
if (videoExistsInFolder(folder.id, videoId)) {
|
||||
throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 이미 사용 중입니다.`)
|
||||
}
|
||||
// 자르기 구간(선택). 잘못된 값은 무시하고 전체로 처리.
|
||||
@@ -479,15 +549,25 @@ opRouter.get('/op/job/:id', requireAuth, (req, res) => {
|
||||
res.json({ ok: true, job })
|
||||
})
|
||||
|
||||
// ─── 영상 mutation (id 기반 — 전역 유일 ID) ─────────────────────────────
|
||||
// ─── 영상 mutation (folderId + id 기반 — ID 는 폴더 안에서만 유일) ───────
|
||||
|
||||
/** POST body 의 folderId 를 검증해 폴더를 돌려준다. 없거나 못 찾으면 throw. */
|
||||
function folderFromBody(req: Request): { id: number } {
|
||||
const fid = pickIntId(req.body?.folderId)
|
||||
if (fid === null) throw new Error('폴더 ID 가 올바르지 않습니다.')
|
||||
const folder = getFolder(fid)
|
||||
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
|
||||
return folder
|
||||
}
|
||||
|
||||
opRouter.post('/op/videos/:id/rename', requireAuth, (req, res) => {
|
||||
try {
|
||||
const folder = folderFromBody(req)
|
||||
const id = req.params.id
|
||||
const title = pickStr(req.body.title).trim()
|
||||
if (!title) throw new Error('제목을 입력해 주세요.')
|
||||
if (!getVideo(id)) throw new Error('영상을 찾을 수 없습니다.')
|
||||
updateVideo(id, { title })
|
||||
if (!getVideoInFolder(folder.id, id)) throw new Error('영상을 찾을 수 없습니다.')
|
||||
updateVideo(folder.id, id, { title })
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||
@@ -496,19 +576,21 @@ opRouter.post('/op/videos/:id/rename', requireAuth, (req, res) => {
|
||||
|
||||
opRouter.post('/op/videos/:id/changeId', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const folder = folderFromBody(req)
|
||||
const oldId = req.params.id
|
||||
const newId = pickStr(req.body.newId).trim()
|
||||
if (!newId) throw new Error('새 ID 를 입력해 주세요.')
|
||||
const video = await changeVideoId(oldId, newId)
|
||||
const video = await changeVideoId(folder.id, oldId, newId)
|
||||
res.json({ ok: true, video })
|
||||
} catch (err) {
|
||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||
}
|
||||
})
|
||||
|
||||
/** 새 ID 가 사용 가능한지 미리 확인 (인풋 옆 실시간 표시용). */
|
||||
/** 새 ID 가 (해당 폴더 안에서) 사용 가능한지 미리 확인 (인풋 옆 실시간 표시용). */
|
||||
opRouter.get('/op/videos/idAvailable', requireAuth, (req, res) => {
|
||||
const id = typeof req.query.id === 'string' ? req.query.id.trim() : ''
|
||||
const folderId = pickIntId(req.query.folderId)
|
||||
if (!id) {
|
||||
res.json({ ok: true, available: false, reason: '비어 있음' })
|
||||
return
|
||||
@@ -521,13 +603,18 @@ opRouter.get('/op/videos/idAvailable', requireAuth, (req, res) => {
|
||||
})
|
||||
return
|
||||
}
|
||||
const available = !videoIdExists(id)
|
||||
if (folderId === null) {
|
||||
res.json({ ok: true, available: false, reason: '폴더 정보 없음' })
|
||||
return
|
||||
}
|
||||
const available = !videoExistsInFolder(folderId, id)
|
||||
res.json({ ok: true, available })
|
||||
})
|
||||
|
||||
opRouter.post('/op/videos/:id/delete', requireAuth, async (req, res) => {
|
||||
try {
|
||||
await deleteVideo(req.params.id)
|
||||
const folder = folderFromBody(req)
|
||||
await deleteVideo(folder.id, req.params.id)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||
@@ -536,8 +623,9 @@ opRouter.post('/op/videos/:id/delete', requireAuth, async (req, res) => {
|
||||
|
||||
opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const folder = folderFromBody(req)
|
||||
const id = req.params.id
|
||||
const video = getVideo(id)
|
||||
const video = getVideoInFolder(folder.id, id)
|
||||
if (!video) throw new Error('영상을 찾을 수 없습니다.')
|
||||
const title = pickStr(req.body.title).trim()
|
||||
const startSec = Number(req.body.startSec ?? 0) || 0
|
||||
@@ -550,10 +638,10 @@ opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => {
|
||||
startSec,
|
||||
endSec: endSec == null || Number.isNaN(endSec) ? null : endSec
|
||||
}
|
||||
updateVideo(id, { trim, ...(title ? { title } : {}) })
|
||||
updateVideo(folder.id, id, { trim, ...(title ? { title } : {}) })
|
||||
// ffmpeg 가 없으면 trim 정보만 저장하고 안내.
|
||||
try {
|
||||
const outName = await applyTrimToVideo(id, trim)
|
||||
const outName = await applyTrimToVideo(folder.id, id, trim)
|
||||
res.json({ ok: true, editedFile: outName, note: '편집본 저장 완료' })
|
||||
} catch (err) {
|
||||
if (err instanceof FfmpegUnavailableError) {
|
||||
@@ -581,7 +669,7 @@ export function adminFolderUrl(folderId: number): string {
|
||||
|
||||
/** 비디오 ID → 공개 공유 URL (/video/topName/[subName/]videoId). */
|
||||
export function videoShareUrl(videoId: string): string | null {
|
||||
const v = getVideo(videoId)
|
||||
const v = getVideoByIdGlobal(videoId)
|
||||
if (!v) return null
|
||||
const names = folderPathNames(v.folderId)
|
||||
return '/video/' + [...names, videoId].map((s) => encodeURIComponent(s)).join('/')
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Router, type RequestHandler } from 'express'
|
||||
import {
|
||||
getFolderByPath,
|
||||
getVideo,
|
||||
getVideoInFolder,
|
||||
getVideoByIdGlobal,
|
||||
listChildFolders,
|
||||
listTopFolders,
|
||||
listVideosInFolder,
|
||||
@@ -65,17 +66,17 @@ publicRouter.get('/folder/:topName/:subName', folderViewHandler)
|
||||
const videoViewHandler: RequestHandler = (req, res, next) => {
|
||||
try {
|
||||
const { folderSegments, videoId } = collectVideoSegments(req.params)
|
||||
const video = getVideo(videoId)
|
||||
const folder = getFolderByPath(folderSegments)
|
||||
if (!folder) {
|
||||
res.status(404).send('영상을 찾을 수 없습니다.')
|
||||
return
|
||||
}
|
||||
const video = getVideoInFolder(folder.id, videoId)
|
||||
if (!video) {
|
||||
res.status(404).send('영상을 찾을 수 없습니다.')
|
||||
return
|
||||
}
|
||||
const folderPath = folderPathNames(video.folderId)
|
||||
if (!segmentsEqual(folderPath, folderSegments)) {
|
||||
res.status(404).send('영상 경로가 일치하지 않습니다.')
|
||||
return
|
||||
}
|
||||
res.render('player', { video, breadcrumb: folderPath })
|
||||
res.render('player', { video, breadcrumb: folderPathNames(folder.id) })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
@@ -93,7 +94,7 @@ function sendVideoFile(video: Video, req: Parameters<RequestHandler>[0], res: Pa
|
||||
res.status(404).end()
|
||||
return
|
||||
}
|
||||
res.sendFile(videoFileFsPath(video.id, fileName))
|
||||
res.sendFile(videoFileFsPath(video.folderId, video.id, fileName))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,7 +105,7 @@ function sendVideoFile(video: Video, req: Parameters<RequestHandler>[0], res: Pa
|
||||
*/
|
||||
const streamVideoHandler: RequestHandler = (req, res, next) => {
|
||||
try {
|
||||
const video = getVideo(req.params.videoId)
|
||||
const video = getVideoByIdGlobal(req.params.videoId)
|
||||
if (!video) {
|
||||
res.status(404).end()
|
||||
return
|
||||
@@ -131,12 +132,13 @@ publicRouter.get('/api/video/:videoId/file', streamVideoHandler)
|
||||
const streamVideoByPathHandler: RequestHandler = (req, res, next) => {
|
||||
try {
|
||||
const { folderSegments, videoId } = collectVideoSegments(req.params)
|
||||
const video = getVideo(videoId)
|
||||
if (!video) {
|
||||
const folder = getFolderByPath(folderSegments)
|
||||
if (!folder) {
|
||||
res.status(404).end()
|
||||
return
|
||||
}
|
||||
if (!segmentsEqual(folderPathNames(video.folderId), folderSegments)) {
|
||||
const video = getVideoInFolder(folder.id, videoId)
|
||||
if (!video) {
|
||||
res.status(404).end()
|
||||
return
|
||||
}
|
||||
@@ -152,16 +154,39 @@ publicRouter.get('/api/video/:topName/:subName/:videoId', streamVideoByPathHandl
|
||||
* 영상 썸네일 (카드용). 없으면 ffmpeg 로 즉석 생성 후 thumb.jpg 로 캐시.
|
||||
* 생성 불가(ffmpeg 없음/다운로드 미완 등)면 404 → 카드에서 ▶ placeholder 로 폴백.
|
||||
*/
|
||||
function sendThumb(res: Parameters<RequestHandler>[1], thumbPath: string | null): void {
|
||||
if (!thumbPath) {
|
||||
res.status(404).end()
|
||||
return
|
||||
}
|
||||
res.set('Cache-Control', 'public, max-age=3600')
|
||||
res.sendFile(thumbPath)
|
||||
}
|
||||
|
||||
/** 폴더 경로 + ID — 정규 썸네일 URL. 같은 ID 가 여러 폴더에 있어도 정확히 찾는다. */
|
||||
const thumbByPathHandler: RequestHandler = (req, res, next) => {
|
||||
const { folderSegments, videoId } = collectVideoSegments(req.params)
|
||||
const folder = getFolderByPath(folderSegments)
|
||||
if (!folder) {
|
||||
res.status(404).end()
|
||||
return
|
||||
}
|
||||
ensureThumbnail(folder.id, videoId)
|
||||
.then((thumbPath) => sendThumb(res, thumbPath))
|
||||
.catch(next)
|
||||
}
|
||||
publicRouter.get('/file/video/:topName/:videoId/thumb', thumbByPathHandler)
|
||||
publicRouter.get('/file/video/:topName/:subName/:videoId/thumb', thumbByPathHandler)
|
||||
|
||||
/** ID-only 레거시 썸네일 fallback (전역 첫 매치). */
|
||||
const thumbHandler: RequestHandler = (req, res, next) => {
|
||||
ensureThumbnail(req.params.videoId)
|
||||
.then((thumbPath) => {
|
||||
if (!thumbPath) {
|
||||
res.status(404).end()
|
||||
return
|
||||
}
|
||||
res.set('Cache-Control', 'public, max-age=3600')
|
||||
res.sendFile(thumbPath)
|
||||
})
|
||||
const video = getVideoByIdGlobal(req.params.videoId)
|
||||
if (!video) {
|
||||
res.status(404).end()
|
||||
return
|
||||
}
|
||||
ensureThumbnail(video.folderId, video.id)
|
||||
.then((thumbPath) => sendThumb(res, thumbPath))
|
||||
.catch(next)
|
||||
}
|
||||
publicRouter.get('/file/video/:videoId/thumb', thumbHandler)
|
||||
@@ -169,7 +194,7 @@ publicRouter.get('/file/video/:videoId/thumb', thumbHandler)
|
||||
/** 비디오 메타 조회 (플레이어/관리자 양쪽에서 사용). */
|
||||
publicRouter.get('/api/video/:videoId', (req, res, next) => {
|
||||
try {
|
||||
const video = getVideo(req.params.videoId)
|
||||
const video = getVideoByIdGlobal(req.params.videoId)
|
||||
if (!video) {
|
||||
res.status(404).json({ ok: false, message: '영상을 찾을 수 없습니다.' })
|
||||
return
|
||||
@@ -196,12 +221,4 @@ function collectVideoSegments(params: Record<string, string | undefined>): {
|
||||
return { folderSegments, videoId: params.videoId ?? '' }
|
||||
}
|
||||
|
||||
function segmentsEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export type { Folder, Video }
|
||||
|
||||
126
src/storeDb.ts
126
src/storeDb.ts
@@ -299,10 +299,23 @@ export async function deleteFolder(folderId: number): Promise<void> {
|
||||
|
||||
// ─── 영상 조회 ─────────────────────────────────────────────────────────
|
||||
|
||||
export function getVideo(id: string): Video | null {
|
||||
/** 폴더 안에서 영상 1건. ID 는 폴더 안에서만 유일하므로 folderId 가 반드시 필요. */
|
||||
export function getVideoInFolder(folderId: number, id: string): Video | null {
|
||||
if (!isSafeVideoId(id)) return null
|
||||
const r = getDb()
|
||||
.prepare(`SELECT * FROM videos WHERE id = ?`)
|
||||
.prepare(`SELECT * FROM videos WHERE folder_id = ? AND id = ?`)
|
||||
.get(folderId, id) as VideoRow | undefined
|
||||
return r ? rowToVideo(r) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* ID 만으로 전역에서 첫 번째 영상을 찾는다 (폴더 경로 없는 레거시/외부공유 fallback).
|
||||
* 같은 ID 가 여러 폴더에 있으면 folder_id 가 작은(=먼저 만든) 쪽을 돌려준다.
|
||||
*/
|
||||
export function getVideoByIdGlobal(id: string): Video | null {
|
||||
if (!isSafeVideoId(id)) return null
|
||||
const r = getDb()
|
||||
.prepare(`SELECT * FROM videos WHERE id = ? ORDER BY folder_id LIMIT 1`)
|
||||
.get(id) as VideoRow | undefined
|
||||
return r ? rowToVideo(r) : null
|
||||
}
|
||||
@@ -316,34 +329,30 @@ export function listVideosInFolder(folderId: number): Video[] {
|
||||
return rows.map(rowToVideo)
|
||||
}
|
||||
|
||||
export function videoIdExists(id: string): boolean {
|
||||
export function videoExistsInFolder(folderId: number, id: string): boolean {
|
||||
if (!isSafeVideoId(id)) return false
|
||||
const r = getDb()
|
||||
.prepare(`SELECT 1 FROM videos WHERE id = ?`)
|
||||
.get(id)
|
||||
.prepare(`SELECT 1 FROM videos WHERE folder_id = ? AND id = ?`)
|
||||
.get(folderId, id)
|
||||
return !!r
|
||||
}
|
||||
|
||||
/** 영상 → 디스크 디렉토리 (폴더 경로 + videoId). */
|
||||
export function videoDiskDir(videoId: string): string {
|
||||
const v = getVideo(videoId)
|
||||
if (!v) throw new Error('영상을 찾을 수 없습니다.')
|
||||
return path.join(folderDiskPath(v.folderId), v.id)
|
||||
/** 영상 → 디스크 디렉토리 (폴더 경로 + videoId). DB 조회 없이 폴더경로로 합성. */
|
||||
export function videoDiskDir(folderId: number, id: string): string {
|
||||
return path.join(folderDiskPath(folderId), id)
|
||||
}
|
||||
|
||||
/** 영상 → 공유 URL 의 경로 부분 (인코딩 전 세그먼트). */
|
||||
export function videoPathNames(videoId: string): string[] | null {
|
||||
const v = getVideo(videoId)
|
||||
if (!v) return null
|
||||
return [...folderPathNames(v.folderId), v.id]
|
||||
export function videoPathNames(folderId: number, id: string): string[] {
|
||||
return [...folderPathNames(folderId), id]
|
||||
}
|
||||
|
||||
/** 영상 디렉토리 내 파일의 절대 경로. 경로 탈출 방지. */
|
||||
export function videoFileFsPath(videoId: string, rel: string): string {
|
||||
export function videoFileFsPath(folderId: number, id: string, rel: string): string {
|
||||
if (!rel || rel.includes('/') || rel.includes('\\') || rel.includes('..')) {
|
||||
throw new Error('잘못된 파일 경로입니다.')
|
||||
}
|
||||
return path.join(videoDiskDir(videoId), rel)
|
||||
return path.join(videoDiskDir(folderId, id), rel)
|
||||
}
|
||||
|
||||
// ─── 영상 변경 ─────────────────────────────────────────────────────────
|
||||
@@ -362,7 +371,9 @@ export async function createVideo(input: CreateVideoInput): Promise<Video> {
|
||||
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
|
||||
let id = input.id ?? newRandomVideoId()
|
||||
if (!isSafeVideoId(id)) throw new Error('영상 ID 가 올바르지 않습니다.')
|
||||
if (videoIdExists(id)) throw new Error('이미 사용 중인 영상 ID 입니다.')
|
||||
if (videoExistsInFolder(input.folderId, id)) {
|
||||
throw new Error('이미 사용 중인 영상 ID 입니다.')
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const db = getDb()
|
||||
@@ -392,30 +403,37 @@ export async function createVideo(input: CreateVideoInput): Promise<Video> {
|
||||
try {
|
||||
await fsp.mkdir(diskDir, { recursive: true })
|
||||
} catch (err) {
|
||||
db.prepare(`DELETE FROM videos WHERE id = ?`).run(id)
|
||||
db.prepare(`DELETE FROM videos WHERE folder_id = ? AND id = ?`).run(input.folderId, id)
|
||||
throw err
|
||||
}
|
||||
return getVideo(id)!
|
||||
return getVideoInFolder(input.folderId, id)!
|
||||
}
|
||||
|
||||
export async function changeVideoId(oldId: string, newId: string): Promise<Video> {
|
||||
export async function changeVideoId(
|
||||
folderId: number,
|
||||
oldId: string,
|
||||
newId: string
|
||||
): Promise<Video> {
|
||||
if (!isSafeVideoId(newId)) throw new Error('새 영상 ID 가 올바르지 않습니다.')
|
||||
if (oldId === newId) {
|
||||
const v = getVideo(oldId)
|
||||
const v = getVideoInFolder(folderId, oldId)
|
||||
if (!v) throw new Error('영상을 찾을 수 없습니다.')
|
||||
return v
|
||||
}
|
||||
const current = getVideo(oldId)
|
||||
const current = getVideoInFolder(folderId, oldId)
|
||||
if (!current) throw new Error('영상을 찾을 수 없습니다.')
|
||||
if (videoIdExists(newId)) throw new Error('이미 사용 중인 영상 ID 입니다.')
|
||||
if (videoExistsInFolder(folderId, newId)) {
|
||||
throw new Error('이미 사용 중인 영상 ID 입니다.')
|
||||
}
|
||||
|
||||
const oldDir = path.join(folderDiskPath(current.folderId), current.id)
|
||||
const newDir = path.join(folderDiskPath(current.folderId), newId)
|
||||
const db = getDb()
|
||||
try {
|
||||
db.prepare(`UPDATE videos SET id = ?, updated_at = ? WHERE id = ?`).run(
|
||||
db.prepare(`UPDATE videos SET id = ?, updated_at = ? WHERE folder_id = ? AND id = ?`).run(
|
||||
newId,
|
||||
new Date().toISOString(),
|
||||
folderId,
|
||||
oldId
|
||||
)
|
||||
} catch (err) {
|
||||
@@ -431,11 +449,15 @@ export async function changeVideoId(oldId: string, newId: string): Promise<Video
|
||||
// 디스크에 디렉토리가 없으면 빈 디렉토리 생성 (메타만 있는 케이스).
|
||||
await fsp.mkdir(newDir, { recursive: true })
|
||||
} else {
|
||||
db.prepare(`UPDATE videos SET id = ? WHERE id = ?`).run(oldId, newId)
|
||||
db.prepare(`UPDATE videos SET id = ? WHERE folder_id = ? AND id = ?`).run(
|
||||
oldId,
|
||||
folderId,
|
||||
newId
|
||||
)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return getVideo(newId)!
|
||||
return getVideoInFolder(folderId, newId)!
|
||||
}
|
||||
|
||||
export interface UpdateVideoPatch {
|
||||
@@ -447,8 +469,8 @@ export interface UpdateVideoPatch {
|
||||
trim?: VideoTrim | null
|
||||
}
|
||||
|
||||
export function updateVideo(id: string, patch: UpdateVideoPatch): Video {
|
||||
const current = getVideo(id)
|
||||
export function updateVideo(folderId: number, id: string, patch: UpdateVideoPatch): Video {
|
||||
const current = getVideoInFolder(folderId, id)
|
||||
if (!current) throw new Error('영상을 찾을 수 없습니다.')
|
||||
|
||||
const fields: string[] = []
|
||||
@@ -484,22 +506,25 @@ export function updateVideo(id: string, patch: UpdateVideoPatch): Video {
|
||||
if (fields.length === 0) return current
|
||||
fields.push('updated_at = ?')
|
||||
params.push(new Date().toISOString())
|
||||
params.push(id)
|
||||
getDb().prepare(`UPDATE videos SET ${fields.join(', ')} WHERE id = ?`).run(...params)
|
||||
return getVideo(id)!
|
||||
params.push(folderId, id)
|
||||
getDb()
|
||||
.prepare(`UPDATE videos SET ${fields.join(', ')} WHERE folder_id = ? AND id = ?`)
|
||||
.run(...params)
|
||||
return getVideoInFolder(folderId, id)!
|
||||
}
|
||||
|
||||
export async function deleteVideo(id: string): Promise<void> {
|
||||
const v = getVideo(id)
|
||||
export async function deleteVideo(folderId: number, id: string): Promise<void> {
|
||||
const v = getVideoInFolder(folderId, id)
|
||||
if (!v) return
|
||||
const diskDir = path.join(folderDiskPath(v.folderId), v.id)
|
||||
getDb().prepare(`DELETE FROM videos WHERE id = ?`).run(id)
|
||||
getDb().prepare(`DELETE FROM videos WHERE folder_id = ? AND id = ?`).run(folderId, id)
|
||||
await fsp.rm(diskDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// ─── 업로드 임시 파일 → 영상 디렉토리 이동 ─────────────────────────────
|
||||
|
||||
export async function moveUploadIntoVideoDir(
|
||||
folderId: number,
|
||||
videoId: string,
|
||||
tmpFile: string,
|
||||
destName: string
|
||||
@@ -507,7 +532,7 @@ export async function moveUploadIntoVideoDir(
|
||||
if (!destName || destName.includes('/') || destName.includes('\\') || destName.includes('..')) {
|
||||
throw new Error('잘못된 파일명입니다.')
|
||||
}
|
||||
const dir = videoDiskDir(videoId)
|
||||
const dir = videoDiskDir(folderId, videoId)
|
||||
await fsp.mkdir(dir, { recursive: true })
|
||||
const target = path.join(dir, destName)
|
||||
try {
|
||||
@@ -522,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)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { upscaleOriginalTo60Fps, applyTrimToVideo } from './editor.js'
|
||||
import {
|
||||
createVideo,
|
||||
getFolder,
|
||||
getVideo,
|
||||
getVideoInFolder,
|
||||
updateVideo,
|
||||
videoDiskDir,
|
||||
type VideoTrim
|
||||
@@ -343,7 +343,7 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
||||
job.message = '다운로드 시작'
|
||||
await persistJob(job)
|
||||
|
||||
const dir = videoDiskDir(job.videoId)
|
||||
const dir = videoDiskDir(job.folderId, job.videoId)
|
||||
// yt-dlp 가 다운로드 후 결정한 실제 파일명을 알려주도록 --print after_move:filepath
|
||||
// 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱.
|
||||
const args = [
|
||||
@@ -498,9 +498,9 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
||||
console.error('[youtube] 60fps 후처리 실패:', err)
|
||||
}
|
||||
|
||||
const meta = getVideo(job.videoId)
|
||||
const meta = getVideoInFolder(job.folderId, job.videoId)
|
||||
if (meta) {
|
||||
updateVideo(job.videoId, { originalFile: finalName })
|
||||
updateVideo(job.folderId, job.videoId, { originalFile: finalName })
|
||||
}
|
||||
|
||||
// 사용자가 지정한 자르기 구간이 있으면 다운로드/60fps 후처리가 끝난 원본에
|
||||
@@ -510,7 +510,7 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
||||
try {
|
||||
job.message = '자르기 적용 중'
|
||||
await persistJob(job)
|
||||
await applyTrimToVideo(job.videoId, job.trim)
|
||||
await applyTrimToVideo(job.folderId, job.videoId, job.trim)
|
||||
job.message = '자르기 완료'
|
||||
} catch (err) {
|
||||
console.error('[youtube] 자르기 적용 실패:', err)
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<a class="videoCard" href="<%= shareUrl %>"
|
||||
data-video-id="<%= v.id %>" data-share-url="<%= shareUrl %>">
|
||||
<div class="videoThumb">
|
||||
<img class="videoThumbImg" src="/file/video/<%= encodeURIComponent(v.id) %>/thumb" alt="" loading="lazy" onerror="this.style.display='none'" />
|
||||
<img class="videoThumbImg" src="/file/video/<%= folderPathEnc %>/<%= encodeURIComponent(v.id) %>/thumb" alt="" loading="lazy" onerror="this.style.display='none'" />
|
||||
<span class="videoThumbPlay">▶</span>
|
||||
</div>
|
||||
<div class="videoTitle"><%= v.title %></div>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
data-title="<%= v.title %>"
|
||||
data-share-url="<%= shareUrl %>">
|
||||
<div class="videoThumb">
|
||||
<img class="videoThumbImg" src="/file/video/<%= encodeURIComponent(v.id) %>/thumb" alt="" loading="lazy" onerror="this.style.display='none'" />
|
||||
<img class="videoThumbImg" src="/file/video/<%= folderPathEnc %>/<%= encodeURIComponent(v.id) %>/thumb" alt="" loading="lazy" onerror="this.style.display='none'" />
|
||||
<span class="videoThumbPlay">▶</span>
|
||||
</div>
|
||||
<div class="videoTitle"><%= v.title %></div>
|
||||
|
||||
@@ -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