This commit is contained in:
simva committed 2026-10-23 12:05:26 +09:00
commit d9ef64eafe
35 files changed
+4733

No files matched your search

+21
View File
@@ -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()
+462
View File
@@ -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)}")
+263
View File
@@ -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"))
+43
View File
@@ -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="다운로드될 압축 파일명")
+129
View File
@@ -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()
+6
View File
@@ -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