Inline edit, URL-driven meta refresh, drag gap animation, copy-from-music
Music list tab:
- Title + artist are now contenteditable in-place. Typing updates state on
every input event; Enter blurs (no embedded newlines), and on Save the
current DOM text is re-synced into state.music[i].title/.artist before
the JSON POST so a quick-save right after typing doesn't drop the last
keystroke. While focused, the field is highlighted with the accent
outline and unclamped so long titles wrap. Empty cells show a muted
placeholder via :empty::before { content: attr(data-placeholder) }.
- Edit modal "save" now POSTs the new URL to /op/list/<pack>/video-meta
(new endpoint backed by yt-dlp --dump-json --no-playlist) and patches
state.music[i] with the returned title/channel/durationSec. If yt-dlp
is unavailable or the lookup fails, the modal asks the user whether to
apply just the URL change without metadata.
- Drag-and-drop UX: replaced the simple "highlight target" feedback with a
"gap opens above the target" animation. The hovered row gets a
.dropAbove class that animates margin-top to 64px via CSS transition,
visually carving out the slot the dragged item will land in. Insertion
math is now strictly "drop before the hover target" (with the
srcIdx<dstIdx → dstIdx-1 adjustment after the splice removal), matching
what the gap implies. Contenteditable spans no longer hijack drag start
— on focus the parent <li>.draggable is flipped off so the user can
freely select text, and back on at blur.
Image list tab:
- New "음악목록에서 가져오기" button. Copies state.music[*].url into
state.images, which (via the existing thumbUrl() helper) renders each
song's YouTube thumbnail as the image. Behind a confirm prompt because
it replaces the entire image list.
- Same drag gap animation (.dropAbove → margin-left 80px) applied to the
grid cards for consistency.
Server:
- youtube.ts: add fetchVideoMeta(url) using the same ensureYtDlp() path
(auto-installed binary under %appdata%/.mc_custom). Returns one
YtPlaylistEntry or null.
- routes/op.ts: POST /op/list/:packName/video-meta. 400 on missing URL,
503 NO_YTDLP if the auto-install failed, 500 on other yt-dlp errors,
200 { ok: true, entry } otherwise.
Smoke test (Rick Astley URL) returns
title=Rick Astley - Never Gonna..., channel=Rick Astley, durationSec=213.
This commit is contained in:
@@ -44,6 +44,11 @@
|
|||||||
el.textContent = text || ''
|
el.textContent = text || ''
|
||||||
el.classList.toggle('error', !!isError)
|
el.classList.toggle('error', !!isError)
|
||||||
}
|
}
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── 렌더 ──────────────────────────────────────────
|
// ── 렌더 ──────────────────────────────────────────
|
||||||
function renderMusic() {
|
function renderMusic() {
|
||||||
@@ -58,11 +63,16 @@
|
|||||||
'<span class="rowNum">' + (idx + 1) + '</span>' +
|
'<span class="rowNum">' + (idx + 1) + '</span>' +
|
||||||
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy"/>' +
|
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy"/>' +
|
||||||
'<div class="rowMeta">' +
|
'<div class="rowMeta">' +
|
||||||
'<div class="rowTitle">' + escapeHtml(entry.title || '(제목 없음)') + '</div>' +
|
'<div class="rowTitle" contenteditable="true" spellcheck="false" data-field="title" data-placeholder="(제목 없음)">' +
|
||||||
'<div class="rowSub">' + escapeHtml(entry.artist || '') + '</div>' +
|
escapeHtml(entry.title || '') +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="rowSub" contenteditable="true" spellcheck="false" data-field="artist" data-placeholder="(가수 미상)">' +
|
||||||
|
escapeHtml(entry.artist || '') +
|
||||||
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<span class="rowDur">' + fmtTime(entry.durationSec) + '</span>'
|
'<span class="rowDur">' + fmtTime(entry.durationSec) + '</span>'
|
||||||
attachRowEvents(li, 'music', idx)
|
attachRowEvents(li, 'music', idx)
|
||||||
|
attachInlineEdit(li, idx)
|
||||||
ol.appendChild(li)
|
ol.appendChild(li)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -83,42 +93,85 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
// ── 인라인 편집 (제목/가수) ─────────────────────────
|
||||||
return String(s).replace(/[&<>"']/g, function (c) {
|
function attachInlineEdit(li, idx) {
|
||||||
return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c]
|
li.querySelectorAll('[contenteditable="true"]').forEach(function (el) {
|
||||||
|
// contenteditable 요소 위에서 드래그가 시작되면 텍스트 선택이 의도였을 가능성이 크다.
|
||||||
|
// 편집 중에는 부모 li 가 드래그되지 않도록 한다.
|
||||||
|
el.addEventListener('focus', function () { li.draggable = false })
|
||||||
|
el.addEventListener('blur', function () { li.draggable = true })
|
||||||
|
el.addEventListener('input', function () {
|
||||||
|
var field = el.getAttribute('data-field')
|
||||||
|
var value = (el.textContent || '').replace(/\r?\n/g, ' ').trim()
|
||||||
|
if (field === 'title') state.music[idx].title = value
|
||||||
|
else if (field === 'artist') state.music[idx].artist = value
|
||||||
|
})
|
||||||
|
el.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); el.blur() }
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 행/카드 이벤트 (드래그·우클릭) ─────────────────
|
// ── 행/카드 이벤트 (드래그·우클릭) ─────────────────
|
||||||
var dragSrc = null
|
var dragSrc = null
|
||||||
|
var lastDropTarget = null
|
||||||
|
|
||||||
|
function clearDropTargets(container) {
|
||||||
|
container.querySelectorAll('.dropAbove').forEach(function (el) {
|
||||||
|
el.classList.remove('dropAbove')
|
||||||
|
})
|
||||||
|
lastDropTarget = null
|
||||||
|
}
|
||||||
|
|
||||||
function attachRowEvents(el, type, idx) {
|
function attachRowEvents(el, type, idx) {
|
||||||
el.addEventListener('dragstart', function (e) {
|
el.addEventListener('dragstart', function (e) {
|
||||||
dragSrc = { type: type, index: idx }
|
// contenteditable 안에서 시작된 드래그(텍스트 선택)는 무시.
|
||||||
|
if (!el.draggable) { e.preventDefault(); return }
|
||||||
|
dragSrc = { type: type, index: idx, el: el }
|
||||||
el.classList.add('dragging')
|
el.classList.add('dragging')
|
||||||
try { e.dataTransfer.setData('text/plain', String(idx)) } catch (_) {}
|
try { e.dataTransfer.setData('text/plain', String(idx)) } catch (_) {}
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
})
|
})
|
||||||
el.addEventListener('dragend', function () {
|
el.addEventListener('dragend', function () {
|
||||||
el.classList.remove('dragging')
|
el.classList.remove('dragging')
|
||||||
|
var parent = (type === 'music')
|
||||||
|
? document.getElementById('music-list')
|
||||||
|
: document.getElementById('image-list')
|
||||||
|
clearDropTargets(parent)
|
||||||
dragSrc = null
|
dragSrc = null
|
||||||
})
|
})
|
||||||
el.addEventListener('dragover', function (e) {
|
el.addEventListener('dragover', function (e) {
|
||||||
if (!dragSrc || dragSrc.type !== type) return
|
if (!dragSrc || dragSrc.type !== type) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.dataTransfer.dropEffect = 'move'
|
e.dataTransfer.dropEffect = 'move'
|
||||||
el.classList.add('dragOver')
|
if (lastDropTarget !== el) {
|
||||||
|
if (lastDropTarget) lastDropTarget.classList.remove('dropAbove')
|
||||||
|
// 자기 자신 위에서는 표식 없음(이동 효과 없음).
|
||||||
|
if (el !== dragSrc.el) el.classList.add('dropAbove')
|
||||||
|
lastDropTarget = el
|
||||||
|
}
|
||||||
|
})
|
||||||
|
el.addEventListener('dragleave', function (e) {
|
||||||
|
// 자식으로 이동한 경우는 leave 가 아님 — pointer 가 진짜로 벗어났을 때만 해제.
|
||||||
|
if (e.relatedTarget && el.contains(e.relatedTarget)) return
|
||||||
|
el.classList.remove('dropAbove')
|
||||||
|
if (lastDropTarget === el) lastDropTarget = null
|
||||||
})
|
})
|
||||||
el.addEventListener('dragleave', function () { el.classList.remove('dragOver') })
|
|
||||||
el.addEventListener('drop', function (e) {
|
el.addEventListener('drop', function (e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
el.classList.remove('dragOver')
|
var parent = (type === 'music')
|
||||||
|
? document.getElementById('music-list')
|
||||||
|
: document.getElementById('image-list')
|
||||||
|
clearDropTargets(parent)
|
||||||
if (!dragSrc || dragSrc.type !== type) return
|
if (!dragSrc || dragSrc.type !== type) return
|
||||||
var srcIdx = dragSrc.index
|
var srcIdx = dragSrc.index
|
||||||
var dstIdx = idx
|
var dstIdx = idx
|
||||||
if (srcIdx === dstIdx) return
|
if (srcIdx === dstIdx) return
|
||||||
var arr = (type === 'music') ? state.music : state.images
|
var arr = (type === 'music') ? state.music : state.images
|
||||||
var moved = arr.splice(srcIdx, 1)[0]
|
var moved = arr.splice(srcIdx, 1)[0]
|
||||||
arr.splice(dstIdx, 0, moved)
|
// "dropAbove" 의미를 그대로 → 항상 target 행의 자리에 끼워 넣는다.
|
||||||
|
var insertAt = (srcIdx < dstIdx) ? dstIdx - 1 : dstIdx
|
||||||
|
arr.splice(insertAt, 0, moved)
|
||||||
if (type === 'music') renderMusic(); else renderImage()
|
if (type === 'music') renderMusic(); else renderImage()
|
||||||
})
|
})
|
||||||
el.addEventListener('contextmenu', function (e) {
|
el.addEventListener('contextmenu', function (e) {
|
||||||
@@ -170,6 +223,7 @@
|
|||||||
editingIdx = idx
|
editingIdx = idx
|
||||||
if (type === 'music') {
|
if (type === 'music') {
|
||||||
document.getElementById('edit-music-url').value = state.music[idx].url || ''
|
document.getElementById('edit-music-url').value = state.music[idx].url || ''
|
||||||
|
setStatus('edit-music-status', '')
|
||||||
editMusic.hidden = false
|
editMusic.hidden = false
|
||||||
} else {
|
} else {
|
||||||
var url = state.images[idx].url || ''
|
var url = state.images[idx].url || ''
|
||||||
@@ -191,15 +245,45 @@
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 음악 수정 저장: URL 변경 시 yt-dlp 로 제목/가수/시간 자동 갱신.
|
||||||
document.getElementById('edit-music-save').addEventListener('click', function () {
|
document.getElementById('edit-music-save').addEventListener('click', function () {
|
||||||
var url = document.getElementById('edit-music-url').value.trim()
|
var url = document.getElementById('edit-music-url').value.trim()
|
||||||
if (!url) { return }
|
if (!url) return
|
||||||
|
var prev = state.music[editingIdx] || { url: '', title: '', artist: '', durationSec: 0 }
|
||||||
|
if (url === prev.url) { closeAllModals(); return }
|
||||||
|
setStatus('edit-music-status', '메타데이터 가져오는 중…')
|
||||||
|
fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/video-meta', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: url })
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.json().then(function (body) { return { ok: r.ok, status: r.status, body: body } })
|
||||||
|
}).then(function (result) {
|
||||||
|
if (!result.ok || !result.body || !result.body.ok) {
|
||||||
|
// yt-dlp 가 없거나 메타 실패면, 사용자가 명시적으로 적용할지 결정.
|
||||||
|
var msg = (result.body && result.body.message) ? result.body.message : '메타 조회 실패'
|
||||||
|
ask('메타데이터 조회 실패', msg + '\n주소만 변경하고 제목/가수/시간은 그대로 둘까요?', function () {
|
||||||
state.music[editingIdx].url = url
|
state.music[editingIdx].url = url
|
||||||
closeAllModals()
|
closeAllModals()
|
||||||
renderMusic()
|
renderMusic()
|
||||||
})
|
})
|
||||||
|
setStatus('edit-music-status', msg, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var meta = result.body.entry
|
||||||
|
state.music[editingIdx] = {
|
||||||
|
url: meta.url || url,
|
||||||
|
title: meta.title || prev.title || '',
|
||||||
|
artist: meta.channel || prev.artist || '',
|
||||||
|
durationSec: typeof meta.durationSec === 'number' ? meta.durationSec : (prev.durationSec || 0)
|
||||||
|
}
|
||||||
|
closeAllModals()
|
||||||
|
renderMusic()
|
||||||
|
}).catch(function (err) {
|
||||||
|
setStatus('edit-music-status', '실패: ' + err.message, true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// 이미지 수정 팝업의 토글 (유튜브 주소 / 이미지 주소)
|
|
||||||
function updateSegButtons() {
|
function updateSegButtons() {
|
||||||
document.querySelectorAll('#editImageModal .segBtn').forEach(function (b) {
|
document.querySelectorAll('#editImageModal .segBtn').forEach(function (b) {
|
||||||
b.classList.toggle('active', b.getAttribute('data-seg') === editingImageMode)
|
b.classList.toggle('active', b.getAttribute('data-seg') === editingImageMode)
|
||||||
@@ -219,6 +303,22 @@
|
|||||||
renderImage()
|
renderImage()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 사진목록: 음악목록 그대로 복사 (각 곡의 유튜브 썸네일이 자동으로 사용됨) ──
|
||||||
|
document.getElementById('image-from-music').addEventListener('click', function () {
|
||||||
|
if (state.music.length === 0) {
|
||||||
|
setStatus('status-image', '음악목록이 비어 있어 가져올 수 없습니다.', true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ask('사진목록 가져오기',
|
||||||
|
'저장된 음악목록의 영상 ' + state.music.length + '개를 그대로 사진목록으로 가져옵니다.\n'
|
||||||
|
+ '현재 사진목록은 모두 사라집니다. 진행할까요?',
|
||||||
|
function () {
|
||||||
|
state.images = state.music.map(function (m) { return { url: m.url } })
|
||||||
|
renderImage()
|
||||||
|
setStatus('status-image', state.images.length + '개 항목을 불러왔습니다.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// ── 액션 (save/clear/fetch) ───────────────────────
|
// ── 액션 (save/clear/fetch) ───────────────────────
|
||||||
var confirmModal = document.getElementById('confirmModal')
|
var confirmModal = document.getElementById('confirmModal')
|
||||||
function ask(title, message, onOk) {
|
function ask(title, message, onOk) {
|
||||||
@@ -254,6 +354,16 @@
|
|||||||
function doSave(target) {
|
function doSave(target) {
|
||||||
state.musicPlaylistUrl = document.getElementById('music-playlist-url').value.trim()
|
state.musicPlaylistUrl = document.getElementById('music-playlist-url').value.trim()
|
||||||
state.imagePlaylistUrl = document.getElementById('image-playlist-url').value.trim()
|
state.imagePlaylistUrl = document.getElementById('image-playlist-url').value.trim()
|
||||||
|
// 인라인 contenteditable 의 최신 값을 한 번 더 동기화 (focus 가 input 이벤트 없이 끝나는 케이스 보호).
|
||||||
|
document.querySelectorAll('#music-list .trackRow').forEach(function (li) {
|
||||||
|
var idx = Number(li.dataset.index)
|
||||||
|
var t = li.querySelector('[data-field="title"]')
|
||||||
|
var a = li.querySelector('[data-field="artist"]')
|
||||||
|
if (state.music[idx]) {
|
||||||
|
if (t) state.music[idx].title = (t.textContent || '').replace(/\r?\n/g, ' ').trim()
|
||||||
|
if (a) state.music[idx].artist = (a.textContent || '').replace(/\r?\n/g, ' ').trim()
|
||||||
|
}
|
||||||
|
})
|
||||||
var statusId = 'status-' + target
|
var statusId = 'status-' + target
|
||||||
setStatus(statusId, '저장 중…')
|
setStatus(statusId, '저장 중…')
|
||||||
fetch('/op/list/' + encodeURIComponent(PACK_KEY), {
|
fetch('/op/list/' + encodeURIComponent(PACK_KEY), {
|
||||||
|
|||||||
@@ -412,14 +412,37 @@ body.siteBody.centerLayout {
|
|||||||
padding: 8px 12px; background: var(--bg-card);
|
padding: 8px 12px; background: var(--bg-card);
|
||||||
border: 1px solid var(--border); border-radius: 8px;
|
border: 1px solid var(--border); border-radius: 8px;
|
||||||
cursor: grab; user-select: none;
|
cursor: grab; user-select: none;
|
||||||
|
transition: margin-top 0.18s ease, border-color 0.12s ease;
|
||||||
}
|
}
|
||||||
.trackRow.dragging { opacity: 0.5; }
|
.trackRow.dragging { opacity: 0.5; }
|
||||||
.trackRow.dragOver { border-color: var(--accent); }
|
/* 드래그 중 hover 표시: 위쪽으로 64px 공간이 벌어지며 "여기 끼울 수 있다" 를 표현 */
|
||||||
|
.trackRow.dropAbove { margin-top: 64px; border-color: var(--accent); }
|
||||||
.rowNum { color: var(--text-muted); font-size: 14px; text-align: center; }
|
.rowNum { color: var(--text-muted); font-size: 14px; text-align: center; }
|
||||||
.rowThumb { width: 80px; height: 45px; object-fit: cover; border-radius: 4px; background: #000; }
|
.rowThumb { width: 80px; height: 45px; object-fit: cover; border-radius: 4px; background: #000; }
|
||||||
.rowMeta { min-width: 0; }
|
.rowMeta { min-width: 0; }
|
||||||
.rowTitle { font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.rowTitle {
|
||||||
.rowSub { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
outline: none; border-radius: 4px; padding: 2px 4px; margin: -2px -4px;
|
||||||
|
}
|
||||||
|
.rowSub {
|
||||||
|
font-size: 12px; color: var(--text-muted); margin-top: 2px;
|
||||||
|
outline: none; border-radius: 4px; padding: 2px 4px;
|
||||||
|
}
|
||||||
|
.rowTitle[contenteditable="true"]:hover,
|
||||||
|
.rowSub[contenteditable="true"]:hover { background: rgba(255,255,255,0.04); }
|
||||||
|
.rowTitle[contenteditable="true"]:focus,
|
||||||
|
.rowSub[contenteditable="true"]:focus {
|
||||||
|
background: var(--bg);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent);
|
||||||
|
white-space: normal; cursor: text;
|
||||||
|
}
|
||||||
|
/* placeholder 효과: 비어 있을 때 회색 안내 텍스트 */
|
||||||
|
.rowTitle[contenteditable="true"]:empty::before,
|
||||||
|
.rowSub[contenteditable="true"]:empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
.rowDur { color: var(--text-muted); font-size: 13px; }
|
.rowDur { color: var(--text-muted); font-size: 13px; }
|
||||||
|
|
||||||
/* 사진 그리드 */
|
/* 사진 그리드 */
|
||||||
@@ -432,9 +455,11 @@ body.siteBody.centerLayout {
|
|||||||
position: relative; aspect-ratio: 1 / 1; background: var(--bg-card);
|
position: relative; aspect-ratio: 1 / 1; background: var(--bg-card);
|
||||||
border: 1px solid var(--border); border-radius: 10px;
|
border: 1px solid var(--border); border-radius: 10px;
|
||||||
overflow: hidden; cursor: grab; user-select: none;
|
overflow: hidden; cursor: grab; user-select: none;
|
||||||
|
transition: margin-left 0.18s ease, border-color 0.12s ease;
|
||||||
}
|
}
|
||||||
.imageCard.dragging { opacity: 0.5; }
|
.imageCard.dragging { opacity: 0.5; }
|
||||||
.imageCard.dragOver { border-color: var(--accent); }
|
/* 사진 그리드도 동일하게 "옆이 벌어진다" 표현 */
|
||||||
|
.imageCard.dropAbove { margin-left: 80px; border-color: var(--accent); }
|
||||||
.imageCard img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
.imageCard img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
.cardNum {
|
.cardNum {
|
||||||
position: absolute; top: 6px; left: 6px;
|
position: absolute; top: 6px; left: 6px;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
savePackList
|
savePackList
|
||||||
} from '../../shared/store'
|
} from '../../shared/store'
|
||||||
import { fetchReleaseVersions } from '../../shared/mojang'
|
import { fetchReleaseVersions } from '../../shared/mojang'
|
||||||
import { fetchPlaylistEntries, YtDlpUnavailableError } from '../youtube'
|
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube'
|
||||||
import { requireAuth } from '../middleware/auth'
|
import { requireAuth } from '../middleware/auth'
|
||||||
import type { PackDefinition, PackList } from '../../shared/types'
|
import type { PackDefinition, PackList } from '../../shared/types'
|
||||||
|
|
||||||
@@ -174,6 +174,30 @@ opRouter.post('/op/list/:packName', requireAuth, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 단일 영상 메타데이터 조회 (음악 항목 수정에서 URL 변경 시 자동 갱신용).
|
||||||
|
// body: { url: string }
|
||||||
|
opRouter.post('/op/list/:packName/video-meta', requireAuth, async (req, res) => {
|
||||||
|
const url = pickFirstValue(req.body?.url).trim()
|
||||||
|
if (!url) {
|
||||||
|
res.status(400).json({ ok: false, message: '영상 주소를 입력해 주세요.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const entry = await fetchVideoMeta(url)
|
||||||
|
if (!entry) {
|
||||||
|
res.status(404).json({ ok: false, message: '메타데이터를 찾을 수 없습니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.json({ ok: true, entry })
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof YtDlpUnavailableError) {
|
||||||
|
res.status(503).json({ ok: false, message: error.message, code: 'NO_YTDLP' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.status(500).json({ ok: false, message: (error as Error).message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 플레이리스트 주소를 yt-dlp 로 풀어 목록 후보를 반환.
|
// 플레이리스트 주소를 yt-dlp 로 풀어 목록 후보를 반환.
|
||||||
// body: { url: string }
|
// body: { url: string }
|
||||||
opRouter.post('/op/list/:packName/playlist', requireAuth, async (req, res) => {
|
opRouter.post('/op/list/:packName/playlist', requireAuth, async (req, res) => {
|
||||||
|
|||||||
@@ -140,6 +140,54 @@ function downloadToFile(url: string, dest: string, redirects = 0): Promise<void>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 영상 URL 의 메타데이터를 가져온다.
|
||||||
|
* `--no-playlist` 로 플레이리스트 URL 이 들어와도 단일 영상 정보만 뽑음.
|
||||||
|
*/
|
||||||
|
export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | null> {
|
||||||
|
const bin = await ensureYtDlp()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(bin, [
|
||||||
|
'--dump-json',
|
||||||
|
'--no-warnings',
|
||||||
|
'--no-playlist',
|
||||||
|
'--skip-download',
|
||||||
|
url
|
||||||
|
], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8')))
|
||||||
|
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
|
||||||
|
child.on('error', (err) => reject(err))
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`yt-dlp 영상 조회 실패 (code=${code}): ${stderr.trim() || stdout.trim()}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const line = stdout.trim().split('\n').find((l) => l.trim().length > 0)
|
||||||
|
if (!line) { resolve(null); return }
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(line) as Record<string, unknown>
|
||||||
|
const id = typeof obj.id === 'string' ? obj.id : ''
|
||||||
|
if (!id) { resolve(null); return }
|
||||||
|
resolve({
|
||||||
|
id,
|
||||||
|
title: typeof obj.title === 'string' ? obj.title : '',
|
||||||
|
channel: typeof obj.channel === 'string'
|
||||||
|
? obj.channel
|
||||||
|
: (typeof obj.uploader === 'string' ? obj.uploader : ''),
|
||||||
|
durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0,
|
||||||
|
url: typeof obj.webpage_url === 'string' && obj.webpage_url.length > 0
|
||||||
|
? obj.webpage_url
|
||||||
|
: `https://www.youtube.com/watch?v=${id}`
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 플레이리스트 URL 을 펼쳐 각 영상의 메타데이터를 가져온다.
|
* 플레이리스트 URL 을 펼쳐 각 영상의 메타데이터를 가져온다.
|
||||||
* `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON.
|
* `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON.
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
<div class="listActionsRow">
|
<div class="listActionsRow">
|
||||||
<button type="button" class="primaryButton" data-action="save" data-target="image">목록 저장</button>
|
<button type="button" class="primaryButton" data-action="save" data-target="image">목록 저장</button>
|
||||||
<button type="button" class="dangerButton" data-action="clear" data-target="image">목록 초기화</button>
|
<button type="button" class="dangerButton" data-action="clear" data-target="image">목록 초기화</button>
|
||||||
|
<button type="button" class="secondaryButton" id="image-from-music">음악목록에서 가져오기</button>
|
||||||
<span class="statusText" id="status-image"></span>
|
<span class="statusText" id="status-image"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -92,6 +93,10 @@
|
|||||||
<label>유튜브 영상 주소
|
<label>유튜브 영상 주소
|
||||||
<input type="url" id="edit-music-url" class="textInput" />
|
<input type="url" id="edit-music-url" class="textInput" />
|
||||||
</label>
|
</label>
|
||||||
|
<p class="muted" style="margin-top:6px;font-size:12px;">
|
||||||
|
저장하면 yt-dlp 로 제목·가수·재생시간을 자동으로 갱신합니다.
|
||||||
|
</p>
|
||||||
|
<p class="statusText" id="edit-music-status" style="margin-top:4px;"></p>
|
||||||
</div>
|
</div>
|
||||||
<footer style="display:flex;gap:8px;justify-content:flex-end;">
|
<footer style="display:flex;gap:8px;justify-content:flex-end;">
|
||||||
<button type="button" class="secondaryButton" data-modal-close>취소</button>
|
<button type="button" class="secondaryButton" data-modal-close>취소</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user