From 745b1db1ccf8d37ac1c1dc72b26faa422255512d Mon Sep 17 00:00:00 2001 From: "Claude (owner)" Date: Sun, 14 Jun 2026 01:50:54 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=95=98=EC=9C=84=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=EC=97=90=20"=EB=AA=A9=EB=A1=9D=20=EB=BD=91=EA=B8=B0"=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(=EC=98=81=EC=83=81=20ID=20+=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EC=A3=BC=EC=86=8C=20txt=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 플레이리스트 추가 버튼 옆에 "목록 뽑기"를 두어, 누르면 `<영상ID> <실제 영상 주소>` 형식의 txt 를 내려받는다. YouTube 는 watch?v=/embed/shorts/live/youtu.be 등 어떤 형식이든 11자 영상 ID 를 뽑아 https://youtu.be/ID 로 간결화하고, 그 외(네이버 등) source_url 은 원본 그대로 출력한다. 업로드 영상처럼 주소가 없으면 ID 만 남긴다. GET /op/folder/:topName/:subName/list.txt (3세그먼트라 폴더 라우트와 충돌 없음). 파일명은 폴더 경로 기반, RFC5987 UTF-8 + ASCII 폴백. Co-Authored-By: Claude Opus 4.7 --- src/routes/op.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ views/op/folder.ejs | 2 ++ 2 files changed, 47 insertions(+) diff --git a/src/routes/op.ts b/src/routes/op.ts index b63289a..35dad0e 100644 --- a/src/routes/op.ts +++ b/src/routes/op.ts @@ -306,6 +306,51 @@ opRouter.get( } ) +// ─── 목록 뽑기 (영상 ID + 실제 영상 주소 txt) ────────────────────────── + +/** YouTube URL 의 11자 영상 ID 추출. watch?v= / youtu.be / embed / shorts / live 형식 지원. */ +function extractYoutubeId(url: string): string | null { + const m = url.match( + /(?:youtu\.be\/|youtube\.com\/(?:watch\?(?:[^#]*&)?v=|embed\/|shorts\/|live\/|v\/))([\w-]{11})/ + ) + return m ? m[1] : null +} + +/** 영상의 "실제 주소". YouTube 는 https://youtu.be/ID 로 간결화, 그 외(네이버 등)는 원본 그대로. */ +function videoSourceUrl(sourceUrl: string | null): string { + if (!sourceUrl) return '' + const ytId = extractYoutubeId(sourceUrl) + return ytId ? `https://youtu.be/${ytId}` : sourceUrl +} + +opRouter.get( + '/op/folder/:topName/:subName/list.txt', + requireAuth, + (req, res, next) => { + try { + const folder = getFolderByPath(collectFolderSegments(req.params)) + if (!folder) { + res.status(404).send('폴더를 찾을 수 없습니다.') + return + } + const lines = listVideosInFolder(folder.id).map((v) => + `${v.id} ${videoSourceUrl(v.sourceUrl)}`.trimEnd() + ) + const body = lines.join('\n') + (lines.length ? '\n' : '') + const baseName = folderPathNames(folder.id).join('_') || 'list' + const asciiName = baseName.replace(/[^\x20-\x7e]+/g, '_').replace(/["\\]/g, '_') + res.setHeader('Content-Type', 'text/plain; charset=utf-8') + res.setHeader( + 'Content-Disposition', + `attachment; filename="${asciiName}.txt"; filename*=UTF-8''${encodeURIComponent(baseName)}.txt` + ) + res.send(body) + } catch (err) { + next(err) + } + } +) + // ─── 플레이리스트 임시저장(draft) ────────────────────────────────────── // 다운로드 시작 전 수정해둔 값(URL + 항목별 제목/ID/구간)을 폴더당 1개 저장/복원. diff --git a/views/op/folder.ejs b/views/op/folder.ejs index 784e665..e016b1a 100644 --- a/views/op/folder.ejs +++ b/views/op/folder.ejs @@ -15,6 +15,7 @@ var parentLabel = isSubFolder ? '← ' + breadcrumb[0] : '← 폴더 목록' var editorHref = '/op/folder/' + folderPathEnc + '/video/editor' var playlistHref = '/op/folder/' + folderPathEnc + '/playlist/new' + var listTxtHref = '/op/folder/' + folderPathEnc + '/list.txt' %>
@@ -30,6 +31,7 @@ 영상 추가 <% if (isSubFolder) { %> 플레이리스트 추가 + 목록 뽑기 <% } %>