배포
This commit is contained in:
commit
d9ef64eafe
35 files changed
+4733
No files matched your search
@@ -0,0 +1,21 @@
|
||||
import os
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
TRANSMISSION_HOST: str = os.getenv("TRANSMISSION_HOST", "127.0.0.1")
|
||||
TRANSMISSION_PORT: int = int(os.getenv("TRANSMISSION_PORT", "9091"))
|
||||
TRANSMISSION_PATH: str = os.getenv("TRANSMISSION_PATH", "/transmission/rpc")
|
||||
TRANSMISSION_USER: str = os.getenv("TRANSMISSION_USER", "")
|
||||
TRANSMISSION_PASSWORD: str = os.getenv("TRANSMISSION_PASSWORD", "")
|
||||
|
||||
# 다운로드 디렉토리 기본 경로 (Transmission 볼륨 마운트 경로)
|
||||
DOWNLOADS_BASE_PATH: str = os.getenv("DOWNLOADS_DIR", "/mnt/ssd/transmission/downloads")
|
||||
|
||||
API_HOST: str = "0.0.0.0"
|
||||
API_PORT: int = 8000
|
||||
|
||||
@property
|
||||
def rpc_url(self) -> str:
|
||||
return f"http://{self.TRANSMISSION_HOST}:{self.TRANSMISSION_PORT}{self.TRANSMISSION_PATH}"
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,462 @@
|
||||
import os
|
||||
import shutil
|
||||
import asyncio
|
||||
import re
|
||||
import json
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional, AsyncGenerator, Tuple
|
||||
from fastapi import HTTPException
|
||||
from .config import settings
|
||||
|
||||
CATEGORY_EXTENSIONS = {
|
||||
"video": [".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".ts"],
|
||||
"audio": [".mp3", ".flac", ".wav", ".aac", ".ogg", ".m4a", ".wma"],
|
||||
"image": [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"],
|
||||
"archive": [".zip", ".rar", ".7z", ".tar", ".gz", ".bz2", ".iso"],
|
||||
"document": [".pdf", ".txt", ".md", ".csv", ".json", ".xml", ".html", ".smi", ".srt", ".vtt"],
|
||||
}
|
||||
|
||||
def get_category(ext: str) -> str:
|
||||
ext_lower = ext.lower()
|
||||
for cat, exts in CATEGORY_EXTENSIONS.items():
|
||||
if ext_lower in exts:
|
||||
return cat
|
||||
return "other"
|
||||
|
||||
def resolve_secure_path(sub_path: str = "") -> Path:
|
||||
"""Path traversal 방지 및 경로 확인"""
|
||||
base_dir = Path(settings.DOWNLOADS_BASE_PATH).resolve()
|
||||
if not base_dir.exists():
|
||||
# 디렉토리가 없을 경우 생성 시도
|
||||
base_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 상대 경로 처리
|
||||
target_path = (base_dir / sub_path.lstrip("/")).resolve()
|
||||
|
||||
try:
|
||||
# base_dir 내부 경로인지 확인
|
||||
target_path.relative_to(base_dir)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403, detail="접근이 제한된 잘못된 경로입니다.")
|
||||
|
||||
if not target_path.exists():
|
||||
raise HTTPException(status_code=404, detail="요청한 파일 또는 폴더를 찾을 수 없습니다.")
|
||||
|
||||
return target_path
|
||||
|
||||
def list_directory(sub_path: str = "") -> Dict[str, Any]:
|
||||
"""디렉토리 내 파일 및 폴더 목록 조회"""
|
||||
target_path = resolve_secure_path(sub_path)
|
||||
base_dir = Path(settings.DOWNLOADS_BASE_PATH).resolve()
|
||||
|
||||
if not target_path.is_dir():
|
||||
raise HTTPException(status_code=400, detail="디렉토리가 아닙니다.")
|
||||
|
||||
items = []
|
||||
total_size = 0
|
||||
|
||||
try:
|
||||
entries = sorted(list(target_path.iterdir()), key=lambda p: (not p.is_dir(), p.name.lower()))
|
||||
for entry in entries:
|
||||
try:
|
||||
stat = entry.stat()
|
||||
is_dir = entry.is_dir()
|
||||
rel_path = str(entry.relative_to(base_dir)).replace("\\", "/")
|
||||
|
||||
size = 0
|
||||
if not is_dir:
|
||||
size = stat.st_size
|
||||
total_size += size
|
||||
|
||||
ext = entry.suffix
|
||||
category = "folder" if is_dir else get_category(ext)
|
||||
|
||||
items.append({
|
||||
"name": entry.name,
|
||||
"path": rel_path,
|
||||
"is_dir": is_dir,
|
||||
"size": size,
|
||||
"modified": stat.st_mtime,
|
||||
"extension": ext,
|
||||
"category": category,
|
||||
})
|
||||
except (PermissionError, FileNotFoundError):
|
||||
continue
|
||||
|
||||
current_rel = str(target_path.relative_to(base_dir)).replace("\\", "/")
|
||||
if current_rel == ".":
|
||||
current_rel = ""
|
||||
|
||||
return {
|
||||
"current_path": current_rel,
|
||||
"items": items,
|
||||
"total_items": len(items),
|
||||
"total_size": total_size,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"디렉토리 읽기 실패: {str(e)}")
|
||||
|
||||
def delete_path(sub_path: str) -> None:
|
||||
"""파일 또는 폴더 삭제"""
|
||||
target_path = resolve_secure_path(sub_path)
|
||||
base_dir = Path(settings.DOWNLOADS_BASE_PATH).resolve()
|
||||
|
||||
# 루트 디렉토리 자체는 삭제 불가
|
||||
if target_path == base_dir:
|
||||
raise HTTPException(status_code=400, detail="최상위 다운로드 디렉토리는 삭제할 수 없습니다.")
|
||||
|
||||
try:
|
||||
if target_path.is_dir():
|
||||
shutil.rmtree(target_path)
|
||||
else:
|
||||
target_path.unlink()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"삭제 실패: {str(e)}")
|
||||
|
||||
async def check_archive_encrypted(sub_path: str) -> Dict[str, Any]:
|
||||
"""압축 파일 내부 암호화 여부 초고속 검사"""
|
||||
archive_path = resolve_secure_path(sub_path)
|
||||
if not archive_path.is_file():
|
||||
raise HTTPException(status_code=400, detail="압축 파일이 아니거나 존재하지 않습니다.")
|
||||
|
||||
# 1. zip 파일인 경우: 파이썬 표준 라이브러리로 즉각 확인 (대용량 파일도 수 밀리초만에 완료)
|
||||
if archive_path.suffix.lower() == ".zip":
|
||||
try:
|
||||
import zipfile
|
||||
with zipfile.ZipFile(str(archive_path)) as zf:
|
||||
is_encrypted = any(z.flag_bits & 0x1 for z in zf.infolist())
|
||||
return {
|
||||
"is_encrypted": is_encrypted,
|
||||
"filename": archive_path.name,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. 기타 확장자(.7z, .rar 등)인 경우: 7z l 명령으로 확인 (타임아웃 적용)
|
||||
cmd = ["7z", "l", "-slt", "-p-", str(archive_path)]
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
stdin=asyncio.subprocess.DEVNULL
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=3.0)
|
||||
out_text = stdout.decode('utf-8', errors='ignore') + stderr.decode('utf-8', errors='ignore')
|
||||
|
||||
is_encrypted = (
|
||||
"Encrypted = +" in out_text or
|
||||
any(kw in out_text for kw in ["Wrong password", "Enter password", "Can not open encrypted"])
|
||||
)
|
||||
return {
|
||||
"is_encrypted": is_encrypted,
|
||||
"filename": archive_path.name,
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"is_encrypted": False,
|
||||
"filename": archive_path.name,
|
||||
}
|
||||
|
||||
async def extract_archive(
|
||||
sub_path: str,
|
||||
password: Optional[str] = None,
|
||||
create_subfolder: bool = True,
|
||||
custom_dest: Optional[str] = None,
|
||||
delete_archive: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""압축 파일 해제 (7z 활용, 암호 지원)"""
|
||||
archive_path = resolve_secure_path(sub_path)
|
||||
base_dir = Path(settings.DOWNLOADS_BASE_PATH).resolve()
|
||||
|
||||
if not archive_path.is_file():
|
||||
raise HTTPException(status_code=400, detail="압축 파일이 아니거나 존재하지 않습니다.")
|
||||
|
||||
parent_dir = archive_path.parent
|
||||
if create_subfolder:
|
||||
folder_name = custom_dest.strip() if (custom_dest and custom_dest.strip()) else archive_path.stem
|
||||
folder_name = folder_name.replace("/", "_").replace("\\", "_")
|
||||
dest_dir = parent_dir / folder_name
|
||||
else:
|
||||
dest_dir = parent_dir
|
||||
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 7z 명령어 구성
|
||||
cmd = ["7z", "x", "-y"]
|
||||
if password is not None and str(password).strip():
|
||||
cmd.append(f"-p{password.strip()}")
|
||||
else:
|
||||
cmd.append("-p-") # 암호 프롬프트 대기 없이 즉시 에러 반환
|
||||
|
||||
cmd.append(f"-o{str(dest_dir)}")
|
||||
cmd.append(str(archive_path))
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
stdin=asyncio.subprocess.DEVNULL
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
out_text = stdout.decode('utf-8', errors='ignore')
|
||||
err_text = stderr.decode('utf-8', errors='ignore')
|
||||
combined_output = out_text + "\n" + err_text
|
||||
|
||||
# 암호 오류 패턴 검사
|
||||
password_err_keywords = [
|
||||
"Wrong password",
|
||||
"Enter password",
|
||||
"Data Error in encrypted file",
|
||||
"Cannot open encrypted",
|
||||
"Headers Error",
|
||||
"Sub items Errors"
|
||||
]
|
||||
has_password_error = any(kw in combined_output for kw in password_err_keywords)
|
||||
|
||||
if has_password_error:
|
||||
if not password or not str(password).strip():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="PASSWORD_REQUIRED: 이 파일은 암호로 보호되어 있습니다. 압축 해제 암호를 입력해주세요."
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="WRONG_PASSWORD: 입력한 암호가 올바르지 않습니다. 암호를 다시 확인해주세요."
|
||||
)
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"압축 해제 중 오류가 발생했습니다: {err_text[:300] or out_text[:300]}"
|
||||
)
|
||||
|
||||
# 원본 압축 파일 삭제 옵션
|
||||
if delete_archive:
|
||||
try:
|
||||
archive_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
rel_dest = str(dest_dir.relative_to(base_dir)).replace("\\", "/")
|
||||
if rel_dest == ".":
|
||||
rel_dest = ""
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"'{archive_path.name}' 압축이 성공적으로 해제되었습니다.",
|
||||
"dest_path": rel_dest
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"압축 해제 실행 실패: {str(e)}")
|
||||
|
||||
async def extract_archive_stream(
|
||||
sub_path: str,
|
||||
password: Optional[str] = None,
|
||||
create_subfolder: bool = True,
|
||||
custom_dest: Optional[str] = None,
|
||||
delete_archive: bool = False
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""실시간 진행률을 SSE 형식으로 스트리밍하는 압축 해제 함수"""
|
||||
archive_path = resolve_secure_path(sub_path)
|
||||
base_dir = Path(settings.DOWNLOADS_BASE_PATH).resolve()
|
||||
|
||||
if not archive_path.is_file():
|
||||
yield f"data: {json.dumps({'status': 'error', 'error_type': 'NOT_FOUND', 'message': '압축 파일이 아니거나 존재하지 않습니다.'})}\n\n"
|
||||
return
|
||||
|
||||
parent_dir = archive_path.parent
|
||||
if create_subfolder:
|
||||
folder_name = custom_dest.strip() if (custom_dest and custom_dest.strip()) else archive_path.stem
|
||||
folder_name = folder_name.replace("/", "_").replace("\\", "_")
|
||||
dest_dir = parent_dir / folder_name
|
||||
else:
|
||||
dest_dir = parent_dir
|
||||
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 7z 명령어: -bsp1 (진행률 출력), -bb1 (파일명 출력)
|
||||
cmd = ["7z", "x", "-y", "-bsp1", "-bb1"]
|
||||
if password is not None and str(password).strip():
|
||||
cmd.append(f"-p{password.strip()}")
|
||||
else:
|
||||
cmd.append("-p-")
|
||||
|
||||
cmd.append(f"-o{str(dest_dir)}")
|
||||
cmd.append(str(archive_path))
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
stdin=asyncio.subprocess.DEVNULL
|
||||
)
|
||||
|
||||
current_percent = 0
|
||||
current_file = ""
|
||||
files_done = 0
|
||||
full_output = []
|
||||
|
||||
# 초기 상태 전송
|
||||
yield f"data: {json.dumps({'status': 'progress', 'percent': 0, 'current_file': '압축 해제 준비 중...', 'files_done': 0})}\n\n"
|
||||
|
||||
try:
|
||||
buffer = b""
|
||||
while True:
|
||||
chunk = await proc.stdout.read(256)
|
||||
if not chunk:
|
||||
break
|
||||
buffer += chunk
|
||||
# \r, \n, \x08 단위로 분할하여 파싱
|
||||
if b"\n" in buffer or b"\r" in buffer or b"\x08" in buffer:
|
||||
# 텍스트 디코딩
|
||||
text = buffer.decode("utf-8", errors="ignore")
|
||||
buffer = b""
|
||||
full_output.append(text)
|
||||
|
||||
clean = re.sub(r"[\x08\r]+", " ", text)
|
||||
|
||||
percent_m = re.search(r"(\d+)\s*%", clean)
|
||||
file_m = re.search(r"-\s+(.+)$", clean)
|
||||
done_m = re.search(r"%\s+(\d+)", clean)
|
||||
|
||||
updated = False
|
||||
if percent_m:
|
||||
p = int(percent_m.group(1))
|
||||
if p >= current_percent:
|
||||
current_percent = p
|
||||
updated = True
|
||||
|
||||
if file_m:
|
||||
f = file_m.group(1).strip()
|
||||
if f and f != current_file:
|
||||
current_file = f
|
||||
updated = True
|
||||
|
||||
if done_m:
|
||||
files_done = int(done_m.group(1))
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
yield f"data: {json.dumps({'status': 'progress', 'percent': current_percent, 'current_file': current_file, 'files_done': files_done})}\n\n"
|
||||
|
||||
stderr_bytes = await proc.stderr.read()
|
||||
err_text = stderr_bytes.decode("utf-8", errors="ignore")
|
||||
combined_output = "".join(full_output) + "\n" + err_text
|
||||
|
||||
# 암호 오류 패턴 검사
|
||||
password_err_keywords = [
|
||||
"Wrong password",
|
||||
"Enter password",
|
||||
"Data Error in encrypted file",
|
||||
"Cannot open encrypted",
|
||||
"Headers Error",
|
||||
"Sub items Errors"
|
||||
]
|
||||
has_password_error = any(kw in combined_output for kw in password_err_keywords)
|
||||
|
||||
if has_password_error:
|
||||
if not password or not str(password).strip():
|
||||
yield f"data: {json.dumps({'status': 'error', 'error_type': 'PASSWORD_REQUIRED', 'message': '이 파일은 암호로 보호되어 있습니다. 압축 해제 암호를 입력해주세요.'})}\n\n"
|
||||
else:
|
||||
yield f"data: {json.dumps({'status': 'error', 'error_type': 'WRONG_PASSWORD', 'message': '입력한 암호가 올바르지 않습니다. 암호를 다시 확인해주세요.'})}\n\n"
|
||||
return
|
||||
|
||||
if proc.returncode != 0:
|
||||
yield f"data: {json.dumps({'status': 'error', 'error_type': 'EXTRACT_FAILED', 'message': f'압축 해제 오류: {err_text[:200]}'})}\n\n"
|
||||
return
|
||||
|
||||
# 원본 압축 파일 삭제 옵션
|
||||
if delete_archive:
|
||||
try:
|
||||
archive_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
rel_dest = str(dest_dir.relative_to(base_dir)).replace("\\", "/")
|
||||
if rel_dest == ".":
|
||||
rel_dest = ""
|
||||
|
||||
yield f"data: {json.dumps({'status': 'completed', 'percent': 100, 'current_file': '압축 해제 완료', 'files_done': files_done, 'message': f'{archive_path.name} 압축이 성공적으로 해제되었습니다.', 'dest_path': rel_dest})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'status': 'error', 'error_type': 'EXCEPTION', 'message': str(e)})}\n\n"
|
||||
|
||||
def create_folder_zip(sub_path: str) -> Tuple[str, str]:
|
||||
"""폴더 전체를 zip으로 묶어 임시 파일 생성"""
|
||||
target_path = resolve_secure_path(sub_path)
|
||||
if not target_path.is_dir():
|
||||
raise HTTPException(status_code=400, detail="대상 경로가 폴더가 아닙니다.")
|
||||
|
||||
folder_name = target_path.name or "downloads"
|
||||
zip_filename = f"{folder_name}.zip"
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
# ZIP_STORED: 이미 압축된 미디어 파일이 많으므로 CPU 낭비 없이 최고 속도로 패키징
|
||||
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as zf:
|
||||
for root, dirs, files in os.walk(target_path):
|
||||
for file in files:
|
||||
file_path = Path(root) / file
|
||||
# 상대 경로를 아카이브 내부 경로로 설정
|
||||
arcname = file_path.relative_to(target_path)
|
||||
try:
|
||||
zf.write(file_path, arcname=str(arcname))
|
||||
except Exception:
|
||||
continue
|
||||
return tmp_path, zip_filename
|
||||
except Exception as e:
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
raise HTTPException(status_code=500, detail=f"ZIP 생성 실패: {str(e)}")
|
||||
|
||||
def create_batch_zip(sub_paths: List[str], custom_name: Optional[str] = "bundle.zip") -> Tuple[str, str]:
|
||||
"""선택된 여러 파일/폴더를 하나의 zip으로 묶어 임시 파일 생성"""
|
||||
if not sub_paths:
|
||||
raise HTTPException(status_code=400, detail="다운로드할 파일이 선택되지 않았습니다.")
|
||||
|
||||
base_dir = Path(settings.DOWNLOADS_BASE_PATH).resolve()
|
||||
zip_filename = custom_name.strip() if custom_name and custom_name.strip() else "bundle.zip"
|
||||
if not zip_filename.lower().endswith(".zip"):
|
||||
zip_filename += ".zip"
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as zf:
|
||||
for sp in sub_paths:
|
||||
target = resolve_secure_path(sp)
|
||||
if target.is_dir():
|
||||
for root, dirs, files in os.walk(target):
|
||||
for file in files:
|
||||
file_path = Path(root) / file
|
||||
# 선택된 폴더명을 최상위로 유지
|
||||
rel_to_target = file_path.relative_to(target)
|
||||
arcname = Path(target.name) / rel_to_target
|
||||
try:
|
||||
zf.write(file_path, arcname=str(arcname))
|
||||
except Exception:
|
||||
continue
|
||||
elif target.is_file():
|
||||
try:
|
||||
zf.write(target, arcname=target.name)
|
||||
except Exception:
|
||||
continue
|
||||
return tmp_path, zip_filename
|
||||
except Exception as e:
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
raise HTTPException(status_code=500, detail=f"선택 항목 ZIP 생성 실패: {str(e)}")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, HTTPException, Query, BackgroundTasks
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
import urllib.parse
|
||||
import os
|
||||
import asyncio
|
||||
|
||||
from .config import settings
|
||||
from .schemas import AddMagnetRequest, ApiResponse, StatusResponse, ExtractArchiveRequest, BatchDownloadRequest
|
||||
from .transmission import transmission_client
|
||||
from .files import (
|
||||
list_directory,
|
||||
resolve_secure_path,
|
||||
delete_path,
|
||||
extract_archive,
|
||||
check_archive_encrypted,
|
||||
extract_archive_stream,
|
||||
create_folder_zip,
|
||||
create_batch_zip
|
||||
)
|
||||
|
||||
app = FastAPI(title="Transmission Magnet Manager API", version="1.0.0")
|
||||
|
||||
# CORS 허용 (개발 시 Vite dev 서버 5173 포트 등 접근 허용)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.get("/api/status", response_model=ApiResponse)
|
||||
async def check_status():
|
||||
"""Transmission 연결 상태 및 통계 확인"""
|
||||
try:
|
||||
session = await transmission_client.get_session_stats()
|
||||
torrents = await transmission_client.get_torrents()
|
||||
return ApiResponse(
|
||||
success=True,
|
||||
message="Transmission에 성공적으로 연결되었습니다.",
|
||||
data={
|
||||
"connected": True,
|
||||
"rpc_url": settings.rpc_url,
|
||||
"version": session.get("version"),
|
||||
"download_dir": session.get("download-dir"),
|
||||
"torrents_count": len(torrents)
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return ApiResponse(
|
||||
success=False,
|
||||
message=f"Transmission 연결 실패: {str(e)}",
|
||||
data={
|
||||
"connected": False,
|
||||
"rpc_url": settings.rpc_url,
|
||||
"version": None,
|
||||
"torrents_count": 0
|
||||
}
|
||||
)
|
||||
|
||||
@app.post("/api/torrents/add", response_model=ApiResponse)
|
||||
async def add_magnet(req: AddMagnetRequest):
|
||||
"""마그넷 또는 토렌트 URL 추가"""
|
||||
magnet = req.magnet.strip()
|
||||
if not magnet:
|
||||
raise HTTPException(status_code=400, detail="마그넷 주소를 입력해주세요.")
|
||||
|
||||
try:
|
||||
result = await transmission_client.add_torrent(
|
||||
magnet_or_url=magnet,
|
||||
download_dir=req.download_dir,
|
||||
paused=req.paused
|
||||
)
|
||||
if result["status"] == "duplicate":
|
||||
return ApiResponse(
|
||||
success=True,
|
||||
message="이미 목록에 존재하는 토렌트입니다.",
|
||||
data=result
|
||||
)
|
||||
return ApiResponse(
|
||||
success=True,
|
||||
message="토렌트가 성공적으로 추가되었습니다.",
|
||||
data=result
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"토렌트 추가 중 오류 발생: {str(e)}")
|
||||
|
||||
@app.get("/api/torrents", response_model=ApiResponse)
|
||||
async def list_torrents():
|
||||
"""토렌트 목록 조회"""
|
||||
try:
|
||||
torrents = await transmission_client.get_torrents()
|
||||
return ApiResponse(
|
||||
success=True,
|
||||
message="토렌트 목록을 가져왔습니다.",
|
||||
data=torrents
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"토렌트 목록 조회 실패: {str(e)}")
|
||||
|
||||
@app.delete("/api/torrents/{torrent_id}", response_model=ApiResponse)
|
||||
async def delete_torrent(torrent_id: int, delete_data: bool = Query(False, description="로컬 데이터도 함께 삭제")):
|
||||
"""토렌트 삭제"""
|
||||
try:
|
||||
await transmission_client.remove_torrent(torrent_id, delete_local_data=delete_data)
|
||||
return ApiResponse(
|
||||
success=True,
|
||||
message=f"토렌트 #{torrent_id}가 삭제되었습니다. (데이터 삭제: {'예' if delete_data else '아니오'})"
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"토렌트 삭제 실패: {str(e)}")
|
||||
|
||||
@app.post("/api/torrents/{torrent_id}/start", response_model=ApiResponse)
|
||||
async def start_torrent(torrent_id: int):
|
||||
"""토렌트 시작/재개"""
|
||||
try:
|
||||
await transmission_client.start_torrent(torrent_id)
|
||||
return ApiResponse(
|
||||
success=True,
|
||||
message=f"토렌트 #{torrent_id}를 시작했습니다."
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"토렌트 시작 실패: {str(e)}")
|
||||
|
||||
@app.post("/api/torrents/{torrent_id}/stop", response_model=ApiResponse)
|
||||
async def stop_torrent(torrent_id: int):
|
||||
"""토렌트 일시정지"""
|
||||
try:
|
||||
await transmission_client.stop_torrent(torrent_id)
|
||||
return ApiResponse(
|
||||
success=True,
|
||||
message=f"토렌트 #{torrent_id}를 일시정지했습니다."
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"토렌트 일시정지 실패: {str(e)}")
|
||||
|
||||
# ==================== 파일 뷰어 API ====================
|
||||
|
||||
@app.get("/api/files", response_model=ApiResponse)
|
||||
async def get_files(path: str = Query("", description="상대 디렉토리 경로")):
|
||||
"""다운로드 디렉토리 파일 목록 조회"""
|
||||
data = list_directory(path)
|
||||
return ApiResponse(
|
||||
success=True,
|
||||
message="파일 목록을 가져왔습니다.",
|
||||
data=data
|
||||
)
|
||||
|
||||
@app.get("/api/files/download")
|
||||
async def download_file(path: str = Query(..., description="다운로드할 파일 상대 경로")):
|
||||
"""파일 개별 다운로드 또는 스트리밍 서빙"""
|
||||
file_path = resolve_secure_path(path)
|
||||
if file_path.is_dir():
|
||||
raise HTTPException(status_code=400, detail="폴더는 개별 다운로드할 수 없습니다. '폴더 전체 다운로드'를 이용해주세요.")
|
||||
|
||||
filename = file_path.name
|
||||
quoted_filename = urllib.parse.quote(filename)
|
||||
|
||||
headers = {
|
||||
"Content-Disposition": f"inline; filename*=UTF-8''{quoted_filename}"
|
||||
}
|
||||
return FileResponse(path=str(file_path), filename=filename, headers=headers)
|
||||
|
||||
@app.get("/api/files/download-folder")
|
||||
async def download_folder_as_zip(
|
||||
path: str = Query(..., description="다운로드할 폴더 상대 경로"),
|
||||
background_tasks: BackgroundTasks = None
|
||||
):
|
||||
"""폴더 전체를 ZIP으로 묶어서 한 번에 다운로드"""
|
||||
tmp_path, zip_name = await asyncio.to_thread(create_folder_zip, path)
|
||||
quoted_filename = urllib.parse.quote(zip_name)
|
||||
|
||||
headers = {
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{quoted_filename}"
|
||||
}
|
||||
|
||||
if background_tasks:
|
||||
background_tasks.add_task(os.unlink, tmp_path)
|
||||
|
||||
return FileResponse(path=tmp_path, filename=zip_name, headers=headers)
|
||||
|
||||
@app.post("/api/files/download-batch")
|
||||
async def download_selected_files_batch(
|
||||
req: BatchDownloadRequest,
|
||||
background_tasks: BackgroundTasks = None
|
||||
):
|
||||
"""선택된 여러 파일/폴더를 ZIP으로 묶어서 한 번에 다운로드"""
|
||||
tmp_path, zip_name = await asyncio.to_thread(create_batch_zip, req.paths, req.zip_name)
|
||||
quoted_filename = urllib.parse.quote(zip_name)
|
||||
|
||||
headers = {
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{quoted_filename}"
|
||||
}
|
||||
|
||||
if background_tasks:
|
||||
background_tasks.add_task(os.unlink, tmp_path)
|
||||
|
||||
return FileResponse(path=tmp_path, filename=zip_name, headers=headers)
|
||||
|
||||
@app.delete("/api/files", response_model=ApiResponse)
|
||||
async def remove_file(path: str = Query(..., description="삭제할 파일/폴더 상대 경로")):
|
||||
"""파일 또는 폴더 삭제"""
|
||||
delete_path(path)
|
||||
return ApiResponse(
|
||||
success=True,
|
||||
message=f"'{path}'이(가) 삭제되었습니다."
|
||||
)
|
||||
|
||||
@app.get("/api/files/check-archive", response_model=ApiResponse)
|
||||
async def check_archive(path: str = Query(..., description="압축 파일 상대 경로")):
|
||||
"""압축 파일 암호화 여부 사전 검사"""
|
||||
data = await check_archive_encrypted(path)
|
||||
return ApiResponse(
|
||||
success=True,
|
||||
message="압축 파일 검사 완료",
|
||||
data=data
|
||||
)
|
||||
|
||||
@app.post("/api/files/extract", response_model=ApiResponse)
|
||||
async def extract_file_archive(req: ExtractArchiveRequest):
|
||||
"""압축 파일 해제"""
|
||||
result = await extract_archive(
|
||||
sub_path=req.path,
|
||||
password=req.password,
|
||||
create_subfolder=req.create_subfolder,
|
||||
custom_dest=req.custom_dest,
|
||||
delete_archive=req.delete_archive
|
||||
)
|
||||
return ApiResponse(
|
||||
success=True,
|
||||
message=result["message"],
|
||||
data=result
|
||||
)
|
||||
|
||||
@app.post("/api/files/extract-stream")
|
||||
async def extract_file_archive_stream(req: ExtractArchiveRequest):
|
||||
"""압축 파일 실시간 진행률 스트리밍 해제 (SSE)"""
|
||||
return StreamingResponse(
|
||||
extract_archive_stream(
|
||||
sub_path=req.path,
|
||||
password=req.password,
|
||||
create_subfolder=req.create_subfolder,
|
||||
custom_dest=req.custom_dest,
|
||||
delete_archive=req.delete_archive
|
||||
),
|
||||
media_type="text/event-stream"
|
||||
)
|
||||
|
||||
# Frontend dist 정적 파일 서빙 지원 (Vite build 결과물 서빙)
|
||||
FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
|
||||
if FRONTEND_DIST.exists():
|
||||
app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIST / "assets")), name="assets")
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_frontend(full_path: str):
|
||||
file_path = FRONTEND_DIST / full_path
|
||||
if file_path.is_file():
|
||||
return FileResponse(str(file_path))
|
||||
return FileResponse(str(FRONTEND_DIST / "index.html"))
|
||||
@@ -0,0 +1,43 @@
|
||||
from typing import Optional, List, Any
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class AddMagnetRequest(BaseModel):
|
||||
magnet: str = Field(..., description="마그넷 링크 (magnet:?xt=urn:btih:...) 또는 토렌트 URL")
|
||||
download_dir: Optional[str] = Field(None, description="저장 경로 (비워둘 시 Transmission 기본값 사용)")
|
||||
paused: bool = Field(False, description="추가 후 일시정지 상태로 둘지 여부")
|
||||
|
||||
class TorrentItem(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
status: int
|
||||
status_label: str = ""
|
||||
percent_done: float
|
||||
rate_download: int
|
||||
rate_upload: int
|
||||
total_size: int
|
||||
eta: Optional[int] = None
|
||||
download_dir: Optional[str] = None
|
||||
error_string: Optional[str] = None
|
||||
is_finished: bool = False
|
||||
|
||||
class ApiResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
data: Optional[Any] = None
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
connected: bool
|
||||
rpc_url: str
|
||||
version: Optional[str] = None
|
||||
torrents_count: int = 0
|
||||
|
||||
class ExtractArchiveRequest(BaseModel):
|
||||
path: str = Field(..., description="압축 파일 상대 경로")
|
||||
password: Optional[str] = Field(None, description="압축 해제 암호 (비밀번호)")
|
||||
create_subfolder: bool = Field(True, description="하위 폴더를 생성하여 압축 풀기 여부")
|
||||
custom_dest: Optional[str] = Field(None, description="커스텀 해제 폴더명")
|
||||
delete_archive: bool = Field(False, description="해제 후 원본 압축파일 삭제 여부")
|
||||
|
||||
class BatchDownloadRequest(BaseModel):
|
||||
paths: List[str] = Field(..., description="다운로드할 파일 및 폴더 상대 경로 목록")
|
||||
zip_name: Optional[str] = Field("bundle.zip", description="다운로드될 압축 파일명")
|
||||
@@ -0,0 +1,129 @@
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional
|
||||
import httpx
|
||||
from .config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATUS_MAP = {
|
||||
0: "일시정지됨",
|
||||
1: "검사 대기",
|
||||
2: "검사 중",
|
||||
3: "다운로드 대기",
|
||||
4: "다운로드 중",
|
||||
5: "시딩 대기",
|
||||
6: "시딩 중 (완료)",
|
||||
}
|
||||
|
||||
class TransmissionClient:
|
||||
def __init__(self):
|
||||
self.session_id: str = ""
|
||||
self.base_url = settings.rpc_url
|
||||
self.auth = None
|
||||
if settings.TRANSMISSION_USER and settings.TRANSMISSION_PASSWORD:
|
||||
self.auth = (settings.TRANSMISSION_USER, settings.TRANSMISSION_PASSWORD)
|
||||
|
||||
async def _request(self, method: str, arguments: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Transmission RPC 요청 전송 (세션 ID 자동 획득 및 409 처리)"""
|
||||
payload = {"method": method}
|
||||
if arguments:
|
||||
payload["arguments"] = arguments
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0, auth=self.auth) as client:
|
||||
headers = {}
|
||||
if self.session_id:
|
||||
headers["X-Transmission-Session-Id"] = self.session_id
|
||||
|
||||
# 첫 번째 요청
|
||||
response = await client.post(self.base_url, json=payload, headers=headers)
|
||||
|
||||
# 409 Conflict: 세션 ID 갱신 후 재시도
|
||||
if response.status_code == 409:
|
||||
new_session_id = response.headers.get("X-Transmission-Session-Id")
|
||||
if not new_session_id:
|
||||
raise Exception("Transmission에서 X-Transmission-Session-Id 헤더를 받지 못했습니다.")
|
||||
self.session_id = new_session_id
|
||||
headers["X-Transmission-Session-Id"] = self.session_id
|
||||
response = await client.post(self.base_url, json=payload, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Transmission RPC 오류: HTTP {response.status_code} - {response.text}")
|
||||
|
||||
res_data = response.json()
|
||||
if res_data.get("result") != "success":
|
||||
raise Exception(f"Transmission 요청 실패: {res_data.get('result')}")
|
||||
|
||||
return res_data
|
||||
|
||||
async def add_torrent(self, magnet_or_url: str, download_dir: Optional[str] = None, paused: bool = False) -> Dict[str, Any]:
|
||||
"""마그넷 또는 토렌트 URL 추가"""
|
||||
arguments: Dict[str, Any] = {
|
||||
"filename": magnet_or_url.strip(),
|
||||
"paused": paused
|
||||
}
|
||||
if download_dir and download_dir.strip():
|
||||
arguments["download-dir"] = download_dir.strip()
|
||||
|
||||
data = await self._request("torrent-add", arguments)
|
||||
args = data.get("arguments", {})
|
||||
|
||||
# 'torrent-added' or 'torrent-duplicate'
|
||||
if "torrent-added" in args:
|
||||
return {"status": "added", "torrent": args["torrent-added"]}
|
||||
elif "torrent-duplicate" in args:
|
||||
return {"status": "duplicate", "torrent": args["torrent-duplicate"]}
|
||||
else:
|
||||
return {"status": "unknown", "arguments": args}
|
||||
|
||||
async def get_torrents(self) -> List[Dict[str, Any]]:
|
||||
"""토렌트 목록 조회"""
|
||||
fields = [
|
||||
"id", "name", "status", "percentDone", "rateDownload",
|
||||
"rateUpload", "totalSize", "eta", "downloadDir",
|
||||
"errorString", "isFinished", "addedDate"
|
||||
]
|
||||
data = await self._request("torrent-get", {"fields": fields})
|
||||
torrents = data.get("arguments", {}).get("torrents", [])
|
||||
|
||||
# 가공 및 상태 레이블 추가
|
||||
result = []
|
||||
for t in torrents:
|
||||
status_code = t.get("status", 0)
|
||||
result.append({
|
||||
"id": t.get("id"),
|
||||
"name": t.get("name", "알 수 없음"),
|
||||
"status": status_code,
|
||||
"status_label": STATUS_MAP.get(status_code, "알 수 없음"),
|
||||
"percent_done": round(t.get("percentDone", 0) * 100, 2),
|
||||
"rate_download": t.get("rateDownload", 0),
|
||||
"rate_upload": t.get("rateUpload", 0),
|
||||
"total_size": t.get("totalSize", 0),
|
||||
"eta": t.get("eta"),
|
||||
"download_dir": t.get("downloadDir"),
|
||||
"error_string": t.get("errorString"),
|
||||
"is_finished": t.get("isFinished", False),
|
||||
})
|
||||
return result
|
||||
|
||||
async def remove_torrent(self, torrent_id: int, delete_local_data: bool = False) -> None:
|
||||
"""토렌트 삭제"""
|
||||
arguments = {
|
||||
"ids": [torrent_id],
|
||||
"delete-local-data": delete_local_data
|
||||
}
|
||||
await self._request("torrent-remove", arguments)
|
||||
|
||||
async def start_torrent(self, torrent_id: int) -> None:
|
||||
"""토렌트 시작/재개"""
|
||||
await self._request("torrent-start", {"ids": [torrent_id]})
|
||||
|
||||
async def stop_torrent(self, torrent_id: int) -> None:
|
||||
"""토렌트 일시정지"""
|
||||
await self._request("torrent-stop", {"ids": [torrent_id]})
|
||||
|
||||
async def get_session_stats(self) -> Dict[str, Any]:
|
||||
"""세션 정보 및 통계 확인"""
|
||||
session_data = await self._request("session-get")
|
||||
return session_data.get("arguments", {})
|
||||
|
||||
transmission_client = TransmissionClient()
|
||||
@@ -0,0 +1,6 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
httpx>=0.27.0
|
||||
pydantic>=2.8.0
|
||||
pydantic-settings>=2.4.0
|
||||
python-multipart>=0.0.9
|
||||
Reference in new issue
Block a user