다운로드 시작 전, 수정해둔 값(플레이리스트 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>
581 lines
19 KiB
TypeScript
581 lines
19 KiB
TypeScript
/**
|
|
* DB(SQLite) 기반 폴더/영상 CRUD + 디스크 동기화 레이어.
|
|
*
|
|
* 디스크 레이아웃 (변경 없음):
|
|
* data/folders/{topName}/[{subName}/]{videoId}/...
|
|
*
|
|
* DB 가 source of truth. 변경 흐름:
|
|
* 1) 검증 (이름/ID/깊이/존재여부)
|
|
* 2) DB UPDATE/INSERT/DELETE
|
|
* 3) 디스크 동기화 (mkdir / rename / rm)
|
|
* 4) 실패 시 DB 롤백 (사용 가능한 경우)
|
|
*
|
|
* DB → 디스크 순서로 가는 이유: DB 가 UNIQUE 등 제약을 검사해 주므로 충돌을 일찍 잡고
|
|
* 디스크 작업으로 안 넘어간다. 디스크 작업이 실패하면 DB 변경을 되돌린다.
|
|
*/
|
|
import { promises as fsp } from 'node:fs'
|
|
import path from 'node:path'
|
|
import { randomUUID } from 'node:crypto'
|
|
import { getDb } from './db.js'
|
|
import { foldersDir } from './paths.js'
|
|
|
|
// ─── 검증 ──────────────────────────────────────────────────────────────
|
|
|
|
const SAFE_NAME = /^[\p{L}\p{N}_\- ]+$/u
|
|
const SAFE_VIDEO_ID = /^[a-zA-Z0-9_-]{1,64}$/
|
|
|
|
export function sanitizeFolderName(raw: string): string {
|
|
const trimmed = (raw || '').trim()
|
|
if (!trimmed || trimmed.length > 80) return ''
|
|
if (trimmed === '.' || trimmed === '..') return ''
|
|
if (!SAFE_NAME.test(trimmed)) return ''
|
|
return trimmed
|
|
}
|
|
|
|
export function isSafeVideoId(id: string): boolean {
|
|
return typeof id === 'string' && SAFE_VIDEO_ID.test(id)
|
|
}
|
|
|
|
export function newRandomVideoId(): string {
|
|
// URL-safe 24-char id (uuid 압축).
|
|
return randomUUID().replace(/-/g, '').slice(0, 24)
|
|
}
|
|
|
|
// ─── 타입 ──────────────────────────────────────────────────────────────
|
|
|
|
export interface Folder {
|
|
id: number
|
|
parentId: number | null
|
|
name: string
|
|
position: number
|
|
createdAt: string
|
|
updatedAt: string
|
|
}
|
|
|
|
export interface VideoTrim {
|
|
startSec: number
|
|
endSec: number | null
|
|
}
|
|
|
|
export interface Video {
|
|
id: string
|
|
folderId: number
|
|
title: string
|
|
originalFile: string
|
|
editedFile: string | null
|
|
durationSec: number | null
|
|
sourceType: 'upload' | 'youtube'
|
|
sourceUrl: string | null
|
|
trim: VideoTrim | null
|
|
position: number
|
|
createdAt: string
|
|
updatedAt: string
|
|
}
|
|
|
|
interface FolderRow {
|
|
id: number
|
|
parent_id: number | null
|
|
name: string
|
|
position: number
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
interface VideoRow {
|
|
id: string
|
|
folder_id: number
|
|
title: string
|
|
original_file: string
|
|
edited_file: string | null
|
|
duration_sec: number | null
|
|
source_type: 'upload' | 'youtube'
|
|
source_url: string | null
|
|
trim_start_sec: number | null
|
|
trim_end_sec: number | null
|
|
position: number
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
function rowToFolder(r: FolderRow): Folder {
|
|
return {
|
|
id: r.id,
|
|
parentId: r.parent_id,
|
|
name: r.name,
|
|
position: r.position,
|
|
createdAt: r.created_at,
|
|
updatedAt: r.updated_at
|
|
}
|
|
}
|
|
|
|
function rowToVideo(r: VideoRow): Video {
|
|
const hasTrim = r.trim_start_sec !== null || r.trim_end_sec !== null
|
|
return {
|
|
id: r.id,
|
|
folderId: r.folder_id,
|
|
title: r.title,
|
|
originalFile: r.original_file,
|
|
editedFile: r.edited_file,
|
|
durationSec: r.duration_sec,
|
|
sourceType: r.source_type,
|
|
sourceUrl: r.source_url,
|
|
trim: hasTrim
|
|
? { startSec: r.trim_start_sec ?? 0, endSec: r.trim_end_sec }
|
|
: null,
|
|
position: r.position,
|
|
createdAt: r.created_at,
|
|
updatedAt: r.updated_at
|
|
}
|
|
}
|
|
|
|
// ─── 폴더 조회 ─────────────────────────────────────────────────────────
|
|
|
|
export function listTopFolders(): Folder[] {
|
|
const rows = getDb()
|
|
.prepare(
|
|
`SELECT * FROM folders WHERE parent_id IS NULL ORDER BY position, name`
|
|
)
|
|
.all() as FolderRow[]
|
|
return rows.map(rowToFolder)
|
|
}
|
|
|
|
export function listChildFolders(parentId: number): Folder[] {
|
|
const rows = getDb()
|
|
.prepare(`SELECT * FROM folders WHERE parent_id = ? ORDER BY position, name`)
|
|
.all(parentId) as FolderRow[]
|
|
return rows.map(rowToFolder)
|
|
}
|
|
|
|
export function getFolder(id: number): Folder | null {
|
|
const r = getDb()
|
|
.prepare(`SELECT * FROM folders WHERE id = ?`)
|
|
.get(id) as FolderRow | undefined
|
|
return r ? rowToFolder(r) : null
|
|
}
|
|
|
|
export function getTopFolderByName(name: string): Folder | null {
|
|
const safe = sanitizeFolderName(name)
|
|
if (!safe) return null
|
|
const r = getDb()
|
|
.prepare(`SELECT * FROM folders WHERE parent_id IS NULL AND name = ?`)
|
|
.get(safe) as FolderRow | undefined
|
|
return r ? rowToFolder(r) : null
|
|
}
|
|
|
|
export function getChildFolderByName(parentId: number, name: string): Folder | null {
|
|
const safe = sanitizeFolderName(name)
|
|
if (!safe) return null
|
|
const r = getDb()
|
|
.prepare(`SELECT * FROM folders WHERE parent_id = ? AND name = ?`)
|
|
.get(parentId, safe) as FolderRow | undefined
|
|
return r ? rowToFolder(r) : null
|
|
}
|
|
|
|
/** URL 경로 [topName] 또는 [topName, subName] → 폴더 1건. 못 찾으면 null. */
|
|
export function getFolderByPath(names: string[]): Folder | null {
|
|
if (names.length === 0 || names.length > 2) return null
|
|
const top = getTopFolderByName(names[0])
|
|
if (!top) return null
|
|
if (names.length === 1) return top
|
|
return getChildFolderByName(top.id, names[1])
|
|
}
|
|
|
|
/** 폴더 → 디스크 경로 (parent 까지 거슬러 올라가 이름 join). */
|
|
export function folderDiskPath(folderId: number): string {
|
|
const names = folderPathNames(folderId)
|
|
return path.join(foldersDir, ...names)
|
|
}
|
|
|
|
/** 폴더 → URL 경로 세그먼트 배열 (인코딩 전). */
|
|
export function folderPathNames(folderId: number): string[] {
|
|
const names: string[] = []
|
|
let cur: Folder | null = getFolder(folderId)
|
|
while (cur) {
|
|
names.unshift(cur.name)
|
|
cur = cur.parentId !== null ? getFolder(cur.parentId) : null
|
|
}
|
|
return names
|
|
}
|
|
|
|
// ─── 폴더 변경 ─────────────────────────────────────────────────────────
|
|
|
|
export async function createFolder(input: {
|
|
name: string
|
|
parentId: number | null
|
|
}): Promise<Folder> {
|
|
const safe = sanitizeFolderName(input.name)
|
|
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
|
|
// 깊이 검사: 부모가 이미 서브폴더면 손자 폴더는 만들 수 없다 (최대 2단계).
|
|
if (input.parentId !== null) {
|
|
const parent = getFolder(input.parentId)
|
|
if (!parent) throw new Error('상위 폴더를 찾을 수 없습니다.')
|
|
if (parent.parentId !== null) {
|
|
throw new Error('서브폴더 안에는 폴더를 만들 수 없습니다.')
|
|
}
|
|
}
|
|
const now = new Date().toISOString()
|
|
const db = getDb()
|
|
// position = 현재 형제 개수 (끝에 추가)
|
|
const siblings = (db
|
|
.prepare(
|
|
input.parentId === null
|
|
? `SELECT COUNT(*) as c FROM folders WHERE parent_id IS NULL`
|
|
: `SELECT COUNT(*) as c FROM folders WHERE parent_id = ?`
|
|
)
|
|
.get(...(input.parentId === null ? [] : [input.parentId])) as { c: number }).c
|
|
|
|
let info
|
|
try {
|
|
info = db
|
|
.prepare(
|
|
`INSERT INTO folders (parent_id, name, position, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)`
|
|
)
|
|
.run(input.parentId, safe, siblings, now, now)
|
|
} catch (err) {
|
|
if ((err as Error).message.includes('UNIQUE')) {
|
|
throw new Error('이미 존재하는 폴더입니다.')
|
|
}
|
|
throw err
|
|
}
|
|
const folderId = Number(info.lastInsertRowid)
|
|
// 디스크 디렉토리 생성 (이미 있을 수 있음 — 마이그레이션 케이스).
|
|
const diskPath = folderDiskPath(folderId)
|
|
try {
|
|
await fsp.mkdir(diskPath, { recursive: true })
|
|
} catch (err) {
|
|
// 디스크 실패 시 DB 롤백.
|
|
db.prepare(`DELETE FROM folders WHERE id = ?`).run(folderId)
|
|
throw err
|
|
}
|
|
return getFolder(folderId)!
|
|
}
|
|
|
|
export async function renameFolder(folderId: number, newName: string): Promise<Folder> {
|
|
const folder = getFolder(folderId)
|
|
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
|
|
const safe = sanitizeFolderName(newName)
|
|
if (!safe) throw new Error('새 폴더 이름이 올바르지 않습니다.')
|
|
if (safe === folder.name) return folder
|
|
|
|
const oldDisk = folderDiskPath(folderId)
|
|
const db = getDb()
|
|
try {
|
|
db.prepare(`UPDATE folders SET name = ?, updated_at = ? WHERE id = ?`).run(
|
|
safe,
|
|
new Date().toISOString(),
|
|
folderId
|
|
)
|
|
} catch (err) {
|
|
if ((err as Error).message.includes('UNIQUE')) {
|
|
throw new Error('이미 존재하는 폴더 이름입니다.')
|
|
}
|
|
throw err
|
|
}
|
|
const newDisk = folderDiskPath(folderId)
|
|
try {
|
|
await fsp.rename(oldDisk, newDisk)
|
|
} catch (err) {
|
|
// ENOENT (디스크 디렉토리가 없는 경우) 는 무시하고 비어있는 새 디렉토리 생성.
|
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
await fsp.mkdir(newDisk, { recursive: true })
|
|
} else {
|
|
// 디스크 rename 실패 → DB 롤백
|
|
db.prepare(`UPDATE folders SET name = ? WHERE id = ?`).run(folder.name, folderId)
|
|
throw err
|
|
}
|
|
}
|
|
return getFolder(folderId)!
|
|
}
|
|
|
|
export async function deleteFolder(folderId: number): Promise<void> {
|
|
const folder = getFolder(folderId)
|
|
if (!folder) return // idempotent
|
|
const diskPath = folderDiskPath(folderId)
|
|
// DB CASCADE 가 자식 폴더/영상까지 모두 삭제.
|
|
getDb().prepare(`DELETE FROM folders WHERE id = ?`).run(folderId)
|
|
await fsp.rm(diskPath, { recursive: true, force: true })
|
|
}
|
|
|
|
// ─── 영상 조회 ─────────────────────────────────────────────────────────
|
|
|
|
/** 폴더 안에서 영상 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 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
|
|
}
|
|
|
|
export function listVideosInFolder(folderId: number): Video[] {
|
|
const rows = getDb()
|
|
.prepare(
|
|
`SELECT * FROM videos WHERE folder_id = ? ORDER BY position, created_at DESC`
|
|
)
|
|
.all(folderId) as VideoRow[]
|
|
return rows.map(rowToVideo)
|
|
}
|
|
|
|
export function videoExistsInFolder(folderId: number, id: string): boolean {
|
|
if (!isSafeVideoId(id)) return false
|
|
const r = getDb()
|
|
.prepare(`SELECT 1 FROM videos WHERE folder_id = ? AND id = ?`)
|
|
.get(folderId, id)
|
|
return !!r
|
|
}
|
|
|
|
/** 영상 → 디스크 디렉토리 (폴더 경로 + videoId). DB 조회 없이 폴더경로로 합성. */
|
|
export function videoDiskDir(folderId: number, id: string): string {
|
|
return path.join(folderDiskPath(folderId), id)
|
|
}
|
|
|
|
/** 영상 → 공유 URL 의 경로 부분 (인코딩 전 세그먼트). */
|
|
export function videoPathNames(folderId: number, id: string): string[] {
|
|
return [...folderPathNames(folderId), id]
|
|
}
|
|
|
|
/** 영상 디렉토리 내 파일의 절대 경로. 경로 탈출 방지. */
|
|
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(folderId, id), rel)
|
|
}
|
|
|
|
// ─── 영상 변경 ─────────────────────────────────────────────────────────
|
|
|
|
export interface CreateVideoInput {
|
|
id?: string // 미지정 시 랜덤
|
|
folderId: number
|
|
title: string
|
|
originalFile: string // 디렉토리 안 상대 경로 (또는 'original.%(ext)s' 같은 yt-dlp 임시 패턴)
|
|
sourceType: 'upload' | 'youtube'
|
|
sourceUrl?: string | null
|
|
}
|
|
|
|
export async function createVideo(input: CreateVideoInput): Promise<Video> {
|
|
const folder = getFolder(input.folderId)
|
|
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
|
|
let id = input.id ?? newRandomVideoId()
|
|
if (!isSafeVideoId(id)) throw new Error('영상 ID 가 올바르지 않습니다.')
|
|
if (videoExistsInFolder(input.folderId, id)) {
|
|
throw new Error('이미 사용 중인 영상 ID 입니다.')
|
|
}
|
|
|
|
const now = new Date().toISOString()
|
|
const db = getDb()
|
|
// position = 현재 폴더 안 영상 개수 (끝에 추가)
|
|
const count = (db
|
|
.prepare(`SELECT COUNT(*) as c FROM videos WHERE folder_id = ?`)
|
|
.get(input.folderId) as { c: number }).c
|
|
db.prepare(
|
|
`INSERT INTO videos
|
|
(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)
|
|
VALUES (?, ?, ?, ?, NULL, NULL, ?, ?, NULL, NULL, ?, ?, ?)`
|
|
).run(
|
|
id,
|
|
input.folderId,
|
|
input.title,
|
|
input.originalFile,
|
|
input.sourceType,
|
|
input.sourceUrl ?? null,
|
|
count,
|
|
now,
|
|
now
|
|
)
|
|
// 디스크 디렉토리 생성
|
|
const diskDir = path.join(folderDiskPath(input.folderId), id)
|
|
try {
|
|
await fsp.mkdir(diskDir, { recursive: true })
|
|
} catch (err) {
|
|
db.prepare(`DELETE FROM videos WHERE folder_id = ? AND id = ?`).run(input.folderId, id)
|
|
throw err
|
|
}
|
|
return getVideoInFolder(input.folderId, id)!
|
|
}
|
|
|
|
export async function changeVideoId(
|
|
folderId: number,
|
|
oldId: string,
|
|
newId: string
|
|
): Promise<Video> {
|
|
if (!isSafeVideoId(newId)) throw new Error('새 영상 ID 가 올바르지 않습니다.')
|
|
if (oldId === newId) {
|
|
const v = getVideoInFolder(folderId, oldId)
|
|
if (!v) throw new Error('영상을 찾을 수 없습니다.')
|
|
return v
|
|
}
|
|
const current = getVideoInFolder(folderId, oldId)
|
|
if (!current) throw new Error('영상을 찾을 수 없습니다.')
|
|
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 folder_id = ? AND id = ?`).run(
|
|
newId,
|
|
new Date().toISOString(),
|
|
folderId,
|
|
oldId
|
|
)
|
|
} catch (err) {
|
|
if ((err as Error).message.includes('UNIQUE')) {
|
|
throw new Error('이미 사용 중인 영상 ID 입니다.')
|
|
}
|
|
throw err
|
|
}
|
|
try {
|
|
await fsp.rename(oldDir, newDir)
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
// 디스크에 디렉토리가 없으면 빈 디렉토리 생성 (메타만 있는 케이스).
|
|
await fsp.mkdir(newDir, { recursive: true })
|
|
} else {
|
|
db.prepare(`UPDATE videos SET id = ? WHERE folder_id = ? AND id = ?`).run(
|
|
oldId,
|
|
folderId,
|
|
newId
|
|
)
|
|
throw err
|
|
}
|
|
}
|
|
return getVideoInFolder(folderId, newId)!
|
|
}
|
|
|
|
export interface UpdateVideoPatch {
|
|
title?: string
|
|
originalFile?: string
|
|
editedFile?: string | null
|
|
durationSec?: number | null
|
|
sourceUrl?: string | null
|
|
trim?: VideoTrim | null
|
|
}
|
|
|
|
export function updateVideo(folderId: number, id: string, patch: UpdateVideoPatch): Video {
|
|
const current = getVideoInFolder(folderId, id)
|
|
if (!current) throw new Error('영상을 찾을 수 없습니다.')
|
|
|
|
const fields: string[] = []
|
|
const params: unknown[] = []
|
|
if (patch.title !== undefined) {
|
|
fields.push('title = ?')
|
|
params.push(patch.title)
|
|
}
|
|
if (patch.originalFile !== undefined) {
|
|
fields.push('original_file = ?')
|
|
params.push(patch.originalFile)
|
|
}
|
|
if (patch.editedFile !== undefined) {
|
|
fields.push('edited_file = ?')
|
|
params.push(patch.editedFile)
|
|
}
|
|
if (patch.durationSec !== undefined) {
|
|
fields.push('duration_sec = ?')
|
|
params.push(patch.durationSec)
|
|
}
|
|
if (patch.sourceUrl !== undefined) {
|
|
fields.push('source_url = ?')
|
|
params.push(patch.sourceUrl)
|
|
}
|
|
if (patch.trim !== undefined) {
|
|
if (patch.trim === null) {
|
|
fields.push('trim_start_sec = NULL', 'trim_end_sec = NULL')
|
|
} else {
|
|
fields.push('trim_start_sec = ?', 'trim_end_sec = ?')
|
|
params.push(patch.trim.startSec, patch.trim.endSec)
|
|
}
|
|
}
|
|
if (fields.length === 0) return current
|
|
fields.push('updated_at = ?')
|
|
params.push(new Date().toISOString())
|
|
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(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 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
|
|
): Promise<void> {
|
|
if (!destName || destName.includes('/') || destName.includes('\\') || destName.includes('..')) {
|
|
throw new Error('잘못된 파일명입니다.')
|
|
}
|
|
const dir = videoDiskDir(folderId, videoId)
|
|
await fsp.mkdir(dir, { recursive: true })
|
|
const target = path.join(dir, destName)
|
|
try {
|
|
await fsp.rename(tmpFile, target)
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
|
|
// 다른 디바이스면 copyFile + unlink
|
|
await fsp.copyFile(tmpFile, target)
|
|
await fsp.unlink(tmpFile).catch(() => undefined)
|
|
} else {
|
|
throw err
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── 플레이리스트 임시저장(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)
|
|
}
|