컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다. .gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와 비밀키(.gemini_key)를 제외했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
468 lines
20 KiB
Python
468 lines
20 KiB
Python
"""YT Cut - 유튜브 구간 잘라 받기.
|
|
|
|
GET / 폼 + 서버 파일 목록(받기/삭제)
|
|
POST /cut yt-dlp로 지정한 시작~끝 구간만 mp4로 잘라 /downloads 에 저장
|
|
GET /download 저장된 파일을 브라우저로 내려받기
|
|
POST /delete 저장된 파일 삭제
|
|
"""
|
|
|
|
import html
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from datetime import datetime
|
|
from urllib.parse import quote
|
|
|
|
from fastapi import FastAPI, Form, HTTPException
|
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
|
|
|
|
app = FastAPI(title="ClipCut")
|
|
|
|
DOWNLOAD_DIR = "/downloads"
|
|
# MM:SS 또는 HH:MM:SS 만 허용 (shell 인자로 넘기기 전 검증)
|
|
TIME_RE = re.compile(r"^(?:\d{1,2}:)?\d{1,2}:[0-5]\d$")
|
|
TIMEOUT_SEC = 1800 # 30분
|
|
|
|
|
|
# ── 인라인 SVG 아이콘 (currentColor 사용) ────────────────────────────
|
|
def _svg(body: str) -> str:
|
|
return ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" '
|
|
'stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" '
|
|
f'aria-hidden="true">{body}</svg>')
|
|
|
|
|
|
IC_FILM = _svg('<rect x="3" y="4" width="18" height="16" rx="2.2"/>'
|
|
'<path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>')
|
|
IC_SCISSORS = _svg('<circle cx="6" cy="6" r="2.6"/><circle cx="6" cy="18" r="2.6"/>'
|
|
'<path d="M20 4 8.1 15.9M14.5 14.5 20 20M8.1 8.1 12 12"/>')
|
|
IC_LINK = _svg('<path d="M9.5 14.5 14.5 9.5"/>'
|
|
'<path d="M11.5 7.3 13 5.8a3.7 3.7 0 0 1 5.2 5.2l-1.5 1.5"/>'
|
|
'<path d="M12.5 16.7 11 18.2a3.7 3.7 0 0 1-5.2-5.2l1.5-1.5"/>')
|
|
IC_DOWNLOAD = _svg('<path d="M12 3.5v11"/><path d="m7.5 10.5 4.5 4.5 4.5-4.5"/>'
|
|
'<path d="M5 20.5h14"/>')
|
|
IC_TRASH = _svg('<path d="M4 7h16"/><path d="M10 11v6M14 11v6"/>'
|
|
'<path d="M6.5 7 7.5 20a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1L17.5 7"/>'
|
|
'<path d="M9.5 7V4.5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1V7"/>')
|
|
IC_CLOCK = _svg('<circle cx="12" cy="12" r="8.4"/><path d="M12 7.5V12l3 1.8"/>')
|
|
|
|
|
|
def _safe_name(name: str) -> str:
|
|
"""파일명만 허용(경로 탈출 차단)하고 DOWNLOAD_DIR 내부 실제 경로를 반환."""
|
|
if "/" in name or "\\" in name or name in ("", ".", ".."):
|
|
raise HTTPException(status_code=400, detail="잘못된 파일명")
|
|
path = os.path.join(DOWNLOAD_DIR, name)
|
|
base = os.path.realpath(DOWNLOAD_DIR)
|
|
if not os.path.realpath(path).startswith(base + os.sep):
|
|
raise HTTPException(status_code=400, detail="잘못된 경로")
|
|
return path
|
|
|
|
|
|
def _human(n: int) -> str:
|
|
size = float(n)
|
|
for unit in ("B", "KB", "MB", "GB"):
|
|
if size < 1024 or unit == "GB":
|
|
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
size /= 1024
|
|
return f"{size:.1f} GB"
|
|
|
|
|
|
def _list_files():
|
|
try:
|
|
names = os.listdir(DOWNLOAD_DIR)
|
|
except OSError:
|
|
return []
|
|
items = []
|
|
for name in names:
|
|
if name.startswith("."): # .metube 등 숨김 항목 제외
|
|
continue
|
|
path = os.path.join(DOWNLOAD_DIR, name)
|
|
if os.path.isfile(path):
|
|
items.append((name, os.path.getsize(path), os.path.getmtime(path)))
|
|
items.sort(key=lambda e: e[2], reverse=True) # 최신순
|
|
return items
|
|
|
|
|
|
def _file_list_html() -> str:
|
|
items = _list_files()
|
|
if not items:
|
|
body = (f'<div class="empty">{IC_FILM}'
|
|
'<p>아직 자른 클립이 없어요.<br>위에서 첫 구간을 잘라보세요.</p></div>')
|
|
else:
|
|
rows = []
|
|
for name, size, mtime in items:
|
|
esc = html.escape(name)
|
|
href = "download?name=" + quote(name)
|
|
date = datetime.fromtimestamp(mtime).strftime("%y.%m.%d %H:%M")
|
|
rows.append(
|
|
'<li class="file">'
|
|
f'<span class="file-ic">{IC_FILM}</span>'
|
|
'<span class="file-meta">'
|
|
f'<span class="file-name" title="{esc}">{esc}</span>'
|
|
f'<span class="file-sub">{_human(size)} · {date}</span>'
|
|
'</span>'
|
|
f'<a class="iconbtn dl" href="{html.escape(href)}" '
|
|
f'aria-label="받기" title="받기">{IC_DOWNLOAD}</a>'
|
|
'<form class="inline" method="post" action="delete" '
|
|
'onsubmit="return confirm(\'이 파일을 삭제할까요?\')">'
|
|
f'<input type="hidden" name="name" value="{esc}">'
|
|
'<button class="iconbtn del" type="submit" '
|
|
f'aria-label="삭제" title="삭제">{IC_TRASH}</button>'
|
|
'</form>'
|
|
'</li>'
|
|
)
|
|
body = '<ul class="files">' + "".join(rows) + '</ul>'
|
|
return (
|
|
'<section class="files-sec">'
|
|
'<div class="files-head"><h2>파일</h2>'
|
|
f'<span class="count">{len(items)}개</span></div>'
|
|
f'{body}</section>'
|
|
)
|
|
|
|
|
|
STYLE = """
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
:root{
|
|
--bg:#17120E; --bg-soft:#1f1813; --panel:#241c16; --panel-2:#2c2219;
|
|
--line:#3a2d22; --line-soft:#2c2219;
|
|
--text:#F2EBE2; --muted:#A99C8F; --faint:#7C7064;
|
|
--accent:#FF5C39; --accent-soft:#ff5c3922; --accent-ink:#2a0d04;
|
|
--ok-bd:#3f7a4e; --ok-bg:#15321f; --ok-tx:#9fe6b4;
|
|
--err-bd:#7a3a35; --err-bg:#341b18; --err-tx:#f3b0aa;
|
|
--danger:#E0584E;
|
|
}
|
|
html{-webkit-text-size-adjust:100%}
|
|
body{font-family:Pretendard,system-ui,sans-serif;color:var(--text);
|
|
background:
|
|
radial-gradient(900px 380px at 50% -120px, #ff5c3914, transparent 70%),
|
|
var(--bg);
|
|
min-height:100vh;line-height:1.55;
|
|
padding:max(24px,env(safe-area-inset-top)) 16px 64px;
|
|
-webkit-font-smoothing:antialiased;}
|
|
.wrap{max-width:560px;margin:0 auto}
|
|
.mono{font-family:'JetBrains Mono',ui-monospace,monospace;font-variant-numeric:tabular-nums}
|
|
|
|
/* header */
|
|
.brand{display:flex;align-items:center;gap:13px;margin:6px 2px 22px;
|
|
animation:rise .5s .02s both}
|
|
.brand .logo{width:46px;height:46px;border-radius:13px;display:grid;place-items:center;
|
|
background:linear-gradient(150deg,var(--panel-2),var(--panel));
|
|
border:1px solid var(--line);color:var(--accent);flex:none;
|
|
box-shadow:inset 0 1px 0 #ffffff10}
|
|
.brand .logo svg{width:25px;height:25px}
|
|
.brand h1{font-size:20px;font-weight:800;letter-spacing:-.01em}
|
|
.brand h1 .ac{color:var(--accent)}
|
|
.brand .sub{font-size:13px;font-weight:500;color:var(--muted);margin-top:1px}
|
|
|
|
.card{background:linear-gradient(180deg,var(--panel),var(--bg-soft));
|
|
border:1px solid var(--line);border-radius:20px;padding:20px;
|
|
box-shadow:0 30px 70px -34px #000c, inset 0 1px 0 #ffffff08;
|
|
animation:rise .5s .06s both}
|
|
|
|
/* film-strip timeline signature */
|
|
.timeline{margin:2px 0 20px}
|
|
.tl-track{position:relative;height:48px;border-radius:9px;overflow:hidden;
|
|
background:linear-gradient(180deg,#1c1510,#171008);border:1px solid var(--line)}
|
|
.tl-track::before,.tl-track::after{content:"";position:absolute;left:0;right:0;height:8px;
|
|
background-image:repeating-linear-gradient(90deg,#0c0805 0 9px,transparent 9px 20px)}
|
|
.tl-track::before{top:5px}.tl-track::after{bottom:5px}
|
|
.tl-seg{position:absolute;top:0;bottom:0;left:30%;right:30%;
|
|
background:linear-gradient(180deg,var(--accent-soft),#ff5c3910);
|
|
border-left:2px solid var(--accent);border-right:2px solid var(--accent)}
|
|
.tl-seg::before{content:"";position:absolute;inset:0;margin:auto;width:26px;height:26px;
|
|
border-radius:50%;background:#1a120c;border:1px solid var(--accent);
|
|
display:grid;place-items:center}
|
|
.tl-cut{position:absolute;inset:0;margin:auto;width:16px;height:16px;color:var(--accent);
|
|
z-index:1}
|
|
.tl-marks{display:flex;align-items:flex-end;justify-content:space-between;margin-top:11px}
|
|
.tl-pt{display:flex;flex-direction:column;gap:2px}
|
|
.tl-pt.out{align-items:flex-end}
|
|
.tl-pt b{font-size:10px;font-weight:700;letter-spacing:.16em;color:var(--faint)}
|
|
.tl-pt time{font-size:19px;font-weight:600;color:var(--text)}
|
|
|
|
/* fields */
|
|
.field{margin-top:15px}
|
|
.field>label{display:block;font-size:12px;font-weight:600;letter-spacing:.02em;
|
|
color:var(--muted);margin-bottom:7px}
|
|
.input-wrap{display:flex;align-items:center;gap:10px;background:var(--bg-soft);
|
|
border:1px solid var(--line);border-radius:12px;padding:0 13px;
|
|
transition:border-color .15s,box-shadow .15s}
|
|
.input-wrap:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
|
.input-wrap>svg{width:18px;height:18px;color:var(--faint);flex:none}
|
|
.input-wrap input{flex:1;min-width:0;background:none;border:0;outline:0;color:var(--text);
|
|
font-family:inherit;font-size:15px;padding:13px 0}
|
|
.input-wrap input::placeholder{color:var(--faint)}
|
|
.tc-row{display:flex;gap:12px}
|
|
.tc-row .field{flex:1;margin-top:15px}
|
|
.tc input{font-family:'JetBrains Mono',monospace;font-size:15px;letter-spacing:.03em}
|
|
.help{font-size:12px;color:var(--faint);margin-top:9px;display:flex;gap:6px;align-items:center}
|
|
.help svg{width:14px;height:14px;flex:none}
|
|
|
|
/* primary cut button */
|
|
.cut-btn{width:100%;margin-top:20px;display:flex;gap:9px;align-items:center;justify-content:center;
|
|
padding:15px;font-family:inherit;font-size:16px;font-weight:700;color:var(--accent-ink);
|
|
background:var(--accent);border:0;border-radius:13px;cursor:pointer;
|
|
box-shadow:0 8px 24px -8px #ff5c3977;
|
|
transition:filter .15s,box-shadow .2s,transform .1s}
|
|
.cut-btn:hover{filter:brightness(1.06);box-shadow:0 12px 30px -9px #ff5c3999}
|
|
.cut-btn:active{transform:translateY(1px)}
|
|
.cut-btn svg{width:20px;height:20px}
|
|
.cut-btn .spin{display:none;width:18px;height:18px;border-radius:50%;
|
|
border:2.5px solid #2a0d0455;border-top-color:var(--accent-ink);animation:spin .7s linear infinite}
|
|
.cut-btn.loading .ic{display:none}
|
|
.cut-btn.loading .spin{display:block}
|
|
|
|
/* result banner */
|
|
.banner{margin-top:18px;padding:14px 16px;border-radius:13px;border:1px solid;
|
|
display:flex;flex-direction:column;gap:10px;animation:rise .35s both}
|
|
.banner.ok{border-color:var(--ok-bd);background:var(--ok-bg)}
|
|
.banner.err{border-color:var(--err-bd);background:var(--err-bg)}
|
|
.banner .msg{font-size:14px;font-weight:600;white-space:pre-wrap;word-break:break-all}
|
|
.banner.ok .msg{color:var(--ok-tx)}
|
|
.banner.err .msg{color:var(--err-tx)}
|
|
.dl-big{align-self:flex-start;display:inline-flex;align-items:center;gap:8px;
|
|
padding:11px 17px;border-radius:11px;background:var(--accent);color:var(--accent-ink);
|
|
font-weight:700;font-size:14px;text-decoration:none;
|
|
box-shadow:0 8px 22px -10px #ff5c3988;transition:filter .15s,transform .1s}
|
|
.dl-big:hover{filter:brightness(1.06)} .dl-big:active{transform:translateY(1px)}
|
|
.dl-big svg{width:18px;height:18px}
|
|
.dl-name{font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--ok-tx);
|
|
opacity:.8;word-break:break-all}
|
|
|
|
/* file list */
|
|
.files-sec{margin-top:26px;animation:rise .5s .1s both}
|
|
.files-head{display:flex;align-items:baseline;justify-content:space-between;
|
|
padding:0 2px 4px}
|
|
.files-head h2{font-size:15px;font-weight:700}
|
|
.files-head .count{font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--muted)}
|
|
.files{list-style:none}
|
|
.file{display:flex;align-items:center;gap:12px;padding:12px 6px;
|
|
border-top:1px solid var(--line-soft)}
|
|
.file:first-child{border-top:0}
|
|
.file-ic{width:40px;height:40px;border-radius:11px;display:grid;place-items:center;
|
|
background:var(--bg-soft);border:1px solid var(--line);color:var(--faint);flex:none}
|
|
.file-ic svg{width:19px;height:19px}
|
|
.file-meta{flex:1;min-width:0}
|
|
.file-name{display:block;font-size:14px;font-weight:500;
|
|
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.file-sub{font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--faint);margin-top:3px}
|
|
.inline{display:inline;margin:0}
|
|
.iconbtn{width:40px;height:40px;flex:none;display:grid;place-items:center;border-radius:11px;
|
|
border:1px solid var(--line);background:var(--bg-soft);color:var(--muted);cursor:pointer;
|
|
transition:.15s}
|
|
.iconbtn svg{width:18px;height:18px}
|
|
.iconbtn.dl:hover{color:var(--accent);border-color:var(--accent)}
|
|
.iconbtn.del:hover{color:#fff;background:var(--danger);border-color:var(--danger)}
|
|
.empty{display:flex;flex-direction:column;align-items:center;gap:12px;
|
|
padding:36px 0 30px;color:var(--faint);text-align:center;font-size:13.5px}
|
|
.empty svg{width:34px;height:34px;opacity:.7}
|
|
|
|
/* loading overlay */
|
|
.overlay{position:fixed;inset:0;display:none;flex-direction:column;gap:16px;
|
|
align-items:center;justify-content:center;z-index:50;text-align:center;
|
|
background:rgba(15,11,8,.72);backdrop-filter:blur(6px)}
|
|
.overlay.show{display:flex}
|
|
.overlay .sp{width:42px;height:42px;border-radius:50%;
|
|
border:3px solid #ffffff1f;border-top-color:var(--accent);animation:spin .8s linear infinite}
|
|
.overlay p{font-size:14px;font-weight:600;color:var(--text)}
|
|
.overlay small{display:block;color:var(--muted);font-weight:400;margin-top:3px}
|
|
|
|
a,button{-webkit-tap-highlight-color:transparent}
|
|
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:6px}
|
|
@keyframes spin{to{transform:rotate(360deg)}}
|
|
@keyframes rise{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
|
|
@media (prefers-reduced-motion:reduce){
|
|
*{animation:none!important;transition:none!important}
|
|
}
|
|
"""
|
|
|
|
SCRIPT = """
|
|
(function(){
|
|
var s=document.getElementById('start'), e=document.getElementById('end');
|
|
var ti=document.getElementById('tcIn'), to=document.getElementById('tcOut');
|
|
function up(){ if(ti) ti.textContent=(s.value.trim()||'--:--');
|
|
if(to) to.textContent=(e.value.trim()||'--:--'); }
|
|
if(s&&e){ s.addEventListener('input',up); e.addEventListener('input',up); }
|
|
var f=document.getElementById('cutForm'), ov=document.getElementById('overlay'),
|
|
b=document.getElementById('cutBtn');
|
|
if(f){ f.addEventListener('submit',function(){
|
|
if(b) b.classList.add('loading'); if(ov) ov.classList.add('show'); }); }
|
|
})();
|
|
"""
|
|
|
|
HEAD = """<!doctype html><html lang="ko"><head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
<meta name="theme-color" content="#17120E">
|
|
<title>ClipCut · 유튜브 구간 잘라받기</title>
|
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap">
|
|
<style>__STYLE__</style></head>"""
|
|
|
|
|
|
def page(message: str = "", ok: bool = False, url: str = "",
|
|
start: str = "", end: str = "", download_name: str = "") -> str:
|
|
banner = ""
|
|
if message:
|
|
cls = "ok" if ok else "err"
|
|
dl = ""
|
|
if download_name:
|
|
href = "download?name=" + quote(download_name)
|
|
dl = (f'<a class="dl-big" href="{html.escape(href)}">{IC_DOWNLOAD}파일 받기</a>'
|
|
f'<span class="dl-name">{html.escape(download_name)}</span>')
|
|
banner = (f'<div class="banner {cls}"><span class="msg">{html.escape(message)}</span>'
|
|
f'{dl}</div>')
|
|
|
|
tc_in = html.escape(start) if start else "03:30"
|
|
tc_out = html.escape(end) if end else "06:20"
|
|
|
|
head = HEAD.replace("__STYLE__", STYLE)
|
|
return f"""{head}
|
|
<body>
|
|
<div class="overlay" id="overlay">
|
|
<div class="sp"></div>
|
|
<p>구간을 자르는 중…<small>영상 길이에 따라 몇 초~몇 분 걸려요</small></p>
|
|
</div>
|
|
<div class="wrap">
|
|
<header class="brand">
|
|
<span class="logo">{IC_FILM}</span>
|
|
<div>
|
|
<h1>Clip<span class="ac">Cut</span></h1>
|
|
<div class="sub">유튜브 구간을 잘라 바로 받기</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="card">
|
|
<div class="timeline" aria-hidden="true">
|
|
<div class="tl-track">
|
|
<span class="tl-seg"></span>
|
|
<span class="tl-cut">{IC_SCISSORS}</span>
|
|
</div>
|
|
<div class="tl-marks">
|
|
<span class="tl-pt"><b>IN</b><time class="mono" id="tcIn">{tc_in}</time></span>
|
|
<span class="tl-pt out"><b>OUT</b><time class="mono" id="tcOut">{tc_out}</time></span>
|
|
</div>
|
|
</div>
|
|
|
|
{banner}
|
|
|
|
<form method="post" action="cut" id="cutForm">
|
|
<div class="field">
|
|
<label for="url">유튜브 URL</label>
|
|
<div class="input-wrap">{IC_LINK}
|
|
<input id="url" name="url" type="url" required
|
|
placeholder="https://www.youtube.com/watch?v=…" value="{html.escape(url)}">
|
|
</div>
|
|
</div>
|
|
<div class="tc-row">
|
|
<div class="field tc">
|
|
<label for="start">시작</label>
|
|
<div class="input-wrap">{IC_CLOCK}
|
|
<input id="start" name="start" required placeholder="03:30"
|
|
inputmode="numeric" value="{html.escape(start)}">
|
|
</div>
|
|
</div>
|
|
<div class="field tc">
|
|
<label for="end">끝</label>
|
|
<div class="input-wrap">{IC_CLOCK}
|
|
<input id="end" name="end" required placeholder="06:20"
|
|
inputmode="numeric" value="{html.escape(end)}">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p class="help">{IC_CLOCK}형식: 분:초(03:30) 또는 시:분:초(01:03:30)</p>
|
|
<button class="cut-btn" id="cutBtn" type="submit">
|
|
<span class="ic">{IC_SCISSORS}</span><span class="spin"></span>잘라서 받기
|
|
</button>
|
|
</form>
|
|
</main>
|
|
|
|
{_file_list_html()}
|
|
</div>
|
|
<script>{SCRIPT}</script>
|
|
</body></html>"""
|
|
|
|
|
|
FAVICON = (
|
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">'
|
|
'<rect width="24" height="24" rx="6" fill="#17120E"/>'
|
|
'<g fill="none" stroke="#FF5C39" stroke-width="1.7" stroke-linecap="round" '
|
|
'stroke-linejoin="round"><circle cx="7" cy="7" r="2.2"/><circle cx="7" cy="17" r="2.2"/>'
|
|
'<path d="M19 5 8.5 15.5M14 14l5 5M8.5 8.5 12 12"/></g></svg>'
|
|
)
|
|
|
|
|
|
@app.get("/favicon.svg")
|
|
@app.get("/favicon.ico")
|
|
def favicon() -> Response:
|
|
return Response(FAVICON, media_type="image/svg+xml")
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def index() -> str:
|
|
return page()
|
|
|
|
|
|
@app.post("/cut", response_class=HTMLResponse)
|
|
def cut(url: str = Form(...), start: str = Form(...), end: str = Form(...)) -> str:
|
|
url, start, end = url.strip(), start.strip(), end.strip()
|
|
|
|
if not (url.startswith("http://") or url.startswith("https://")):
|
|
return page("올바른 URL이 아닙니다.", url=url, start=start, end=end)
|
|
if not TIME_RE.match(start) or not TIME_RE.match(end):
|
|
return page("시간 형식이 올바르지 않습니다. 예) 03:30 또는 01:03:30",
|
|
url=url, start=start, end=end)
|
|
|
|
section = f"*{start}-{end}"
|
|
suffix = f"{start}-{end}".replace(":", "")
|
|
outtmpl = f"{DOWNLOAD_DIR}/%(title)s_{suffix}.%(ext)s"
|
|
|
|
cmd = [
|
|
"yt-dlp",
|
|
"--download-sections", section,
|
|
"-f", "bv*+ba/b",
|
|
"--merge-output-format", "mp4",
|
|
"--no-playlist",
|
|
"--no-simulate", "--print", "after_move:filepath", # 최종 저장 경로 출력
|
|
"-o", outtmpl,
|
|
url,
|
|
]
|
|
|
|
try:
|
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=TIMEOUT_SEC)
|
|
except subprocess.TimeoutExpired:
|
|
return page(f"처리 시간이 초과됐습니다(>{TIMEOUT_SEC // 60}분).",
|
|
url=url, start=start, end=end)
|
|
|
|
if proc.returncode != 0:
|
|
tail = (proc.stderr or proc.stdout or "").strip()[-1500:]
|
|
return page(f"실패했습니다.\n\n{tail}", url=url, start=start, end=end)
|
|
|
|
final_path = ""
|
|
for line in (proc.stdout or "").splitlines():
|
|
line = line.strip()
|
|
if line.startswith(DOWNLOAD_DIR + "/"):
|
|
final_path = line
|
|
download_name = os.path.basename(final_path) if final_path else ""
|
|
|
|
return page(f"완료! {start} ~ {end} 구간을 잘랐어요.", ok=True,
|
|
download_name=download_name)
|
|
|
|
|
|
@app.get("/download")
|
|
def download(name: str) -> FileResponse:
|
|
path = _safe_name(name)
|
|
if not os.path.isfile(path):
|
|
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
|
|
return FileResponse(path, filename=name, media_type="application/octet-stream")
|
|
|
|
|
|
@app.post("/delete")
|
|
def delete(name: str = Form(...)) -> RedirectResponse:
|
|
path = _safe_name(name)
|
|
if os.path.isfile(path):
|
|
os.remove(path)
|
|
return RedirectResponse("/", status_code=303)
|