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"))