"""Synology DSM 7.x API 클라이언트. 각 호출 = login → 작업 → logout (세션 풀링은 v1.1+에서). 단일 컨테이너 + 동시성 낮음 가정. - create_share_link(file_path, expires_in_sec) -> share URL """ import asyncio import logging import os from datetime import datetime, timedelta, timezone import httpx logger = logging.getLogger("packs-lab.dsm") DSM_HOST = os.getenv("DSM_HOST", "") # 예: https://gahusb.synology.me:5001 DSM_USER = os.getenv("DSM_USER", "") DSM_PASS = os.getenv("DSM_PASS", "") # LAN IP로 DSM 접근 시 self-signed cert가 IP에 매칭 안 되어 검증 실패. LAN 내부 통신이라 false 허용. # 운영에서 LAN IP + self-signed면 DSM_VERIFY_SSL=false. 도메인 + 정상 cert면 기본값(true) 유지. DSM_VERIFY_SSL = os.getenv("DSM_VERIFY_SSL", "true").strip().lower() != "false" DSM_MAX_RETRIES = max(1, int(os.getenv("DSM_MAX_RETRIES", "3"))) DSM_BACKOFF_SEC = float(os.getenv("DSM_BACKOFF_SEC", "0.5")) API_AUTH = "/webapi/auth.cgi" API_SHARE = "/webapi/entry.cgi" class DSMError(RuntimeError): pass async def _request_with_retry( client: httpx.AsyncClient, url: str, params: dict, timeout: float, ) -> httpx.Response: """5xx · transport · timeout만 지수백오프 retry. 4xx와 DSM success=false는 호출자가 판단.""" last_exc: Exception | None = None for attempt in range(DSM_MAX_RETRIES): try: r = await client.get(url, params=params, timeout=timeout) if r.status_code < 500: return r last_exc = httpx.HTTPStatusError( f"HTTP {r.status_code}", request=r.request, response=r ) logger.warning( "DSM HTTP %s — attempt %s/%s body=%s", r.status_code, attempt + 1, DSM_MAX_RETRIES, r.text[:200], ) except (httpx.TransportError, httpx.TimeoutException) as e: last_exc = e logger.warning( "DSM transport error: %s — attempt %s/%s", e, attempt + 1, DSM_MAX_RETRIES, ) if attempt < DSM_MAX_RETRIES - 1: await asyncio.sleep(DSM_BACKOFF_SEC * (2 ** attempt)) raise DSMError(f"DSM 요청 실패 (재시도 {DSM_MAX_RETRIES}회): {last_exc}") async def _login(client: httpx.AsyncClient) -> str: """DSM 세션 sid 반환.""" if not all([DSM_HOST, DSM_USER, DSM_PASS]): raise DSMError("DSM 환경변수 미설정") r = await _request_with_retry( client, f"{DSM_HOST}{API_AUTH}", { "api": "SYNO.API.Auth", "version": "7", "method": "login", "account": DSM_USER, "passwd": DSM_PASS, "session": "FileStation", "format": "sid", }, 15.0, ) r.raise_for_status() data = r.json() if not data.get("success"): err = data.get("error", {}) logger.error("DSM login 실패: code=%s error=%s", err.get("code"), err) raise DSMError(f"DSM login 실패: code={err.get('code')} error={err}") return data["data"]["sid"] async def _logout(client: httpx.AsyncClient, sid: str) -> None: try: await client.get( f"{DSM_HOST}{API_AUTH}", params={ "api": "SYNO.API.Auth", "version": "7", "method": "logout", "session": "FileStation", "_sid": sid, }, timeout=10.0, ) except Exception as e: logger.warning("DSM logout 실패: %s", e) async def create_share_link(file_path: str, expires_in_sec: int = 14400) -> tuple[str, datetime]: """파일 공유 링크 생성. 반환: (URL, expires_at). file_path: NAS 절대경로 (예: /volume1/docker/webpage/media/packs/master/x.mp4) expires_in_sec: 만료 (기본 4시간) """ expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in_sec) expire_time_ms = int(expires_at.timestamp() * 1000) async with httpx.AsyncClient(verify=DSM_VERIFY_SSL) as client: sid = await _login(client) try: r = await _request_with_retry( client, f"{DSM_HOST}{API_SHARE}", { "api": "SYNO.FileStation.Sharing", "version": "3", "method": "create", "path": file_path, "date_expired": expire_time_ms, "_sid": sid, }, 15.0, ) r.raise_for_status() data = r.json() if not data.get("success"): err = data.get("error", {}) logger.error( "DSM Sharing.create 실패: path=%s code=%s error=%s", file_path, err.get("code"), err, ) raise DSMError(f"DSM Sharing.create 실패: code={err.get('code')} error={err}") links = data["data"]["links"] if not links: raise DSMError("Sharing 응답에 링크 없음") url = links[0]["url"] logger.info("DSM share link created: path=%s", file_path) return url, expires_at finally: await _logout(client, sid)