배포
This commit is contained in:
commit
d9ef64eafe
35 files changed
+4733
No files matched your search
@@ -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"))
|
||||
Reference in new issue
Block a user