Web Server API 명세서 Web Server API 명세서 1문서 목적 및 범위 2핵심 용어 3연결 및 인증 4메시지 규격 5인증 · 토큰 6계정 관리 7서버 등록 8외부 접속 주소 중계 9CCTV · 그룹 10스트리밍 설정 11실시간 스트리밍 12ONVIF 13녹화 14녹화재생 · 내보내기 15AI 이벤트 16로그 수집 17에러 코드 18전체 목록 [h1] Web Server API 명세서 Version 2.0HTTPS RESTOpaque Token [h2] 문서 목적 및 범위1 Web Server는 계정·권한, 서버 등록, CCTV 통합 관리, AI 이벤트, 로그를 중앙 관리하는 웹서버다. 이 문서는 Web Server가 제공하는 HTTPS REST 계약을 정의한다. Media Server와 AI는 로그인해 받은 Opaque Token으로 토큰 유효성 검사와 로그 전송을 호출한다. Client는 관제와 조회만 요청하며 Media Server 제어 채널에는 접근하지 않는다. 권한별 범위는 6.2를 따른다. Media Server와 AI는 둘 다 WSS 제어 채널 서버이고 Web Server가 양쪽에 client로 접속해 설정 push, 폐기 통지, 상태 조회를 한다. 각 채널의 action 계약은 Media Server API 명세서와 AI 명세를 따르며 이 문서 범위 밖이다. CCTV·그룹의 등록·수정·삭제와 MEDIA·AI 배정은 전부 Client admin 전용이다. Media Server와 AI는 이 중 어느 것도 스스로 만들거나 요청할 수 없다(9.2, 9.4, 12.1). 그룹 식별자는 Web Server가 전역으로 한 번만 발급한다(9.6) — 특정 MEDIA에 종속되지 않으며 여러 Media Server가 같은 그룹을 공유한다. Web Server는 Media Server가 꺼져 있어도 CCTV·그룹을 등록·배정할 수 있고 Media Server가 다시 붙을 때 반영된다. standalone 구성은 Media Server가 로컬에서 CCTV·그룹을 발급하고 단독 동작한다. 이 문서는 managed만 다룬다. [h2] 핵심 용어2 | 용어 | 정의 | 비고 | accountId | Web Server 로그인 ID. actorType과 관계없이 전역 유일하며 변경·재사용하지 않는다. 로그인 요청의 loginId와 같다. | 토큰 주체 | actorType | 계정 유형. MEDIA / AI / Client. 계정으로 등록할 수 있는 것은 이 셋뿐이다. | 6장 | permission | 계정 권한. admin / user. actorType마다 허용 조합과 의미가 다르다. | 6.1 | serverId | 계정 등록 시 Web Server가 actorType별로 발급하는 영구 식별자. MEDIA-01 / AI-01 / Client-01 형식이며 actorType별 01~99를 사용하고 재사용하지 않는다. | accountId와 별개 | cameraId | CCTV의 유일 식별자. Web Server가 CCTV 등록 시 한 번만 발급하는 8자리 zero-padding 문자열이다. MEDIA·AI에 몇 대가 배정되든 값은 바뀌지 않는다. | 예: 00000038 | Opaque Token | Web Server가 발급하는 난수 Bearer 토큰. 계정·권한 정보를 담지 않는다. | 발급은 Web Server만 | seq | 로그인마다 1부터 다시 세는 로그 일련번호. 요청 토큰의 해시와 묶어 중복 제거 키로 쓴다. | 16장 | API time | UTC epoch milliseconds. 절대 시각은 ...AtWallMs, 기간·오프셋은 ...Ms로 끝난다. | CCTV는 cameraId 하나로 유일하게 식별한다. 어느 Media Server가 녹화하고 어느 AI가 분석할지는 CCTV 등록과 분리된 배정(assignment)으로 관리하며, 같은 CCTV를 여러 MEDIA·여러 AI에 동시에 중복 배정할 수 있다(9.4 참고) — 배정된 MEDIA마다 이 값을 그대로 받아 각자 독립적으로 수신·녹화한다. 배정을 늘려도 새 id는 발급하지 않는다. [h2] 연결 및 인증3 [h3] 연결 정보3.1 | 구분 | 값 | 정책 | API base | https://{host}:{port}/api | 전 구간 HTTPS. | Method | POST | 모든 엔드포인트가 POST다. GET·PUT·PATCH·DELETE는 쓰지 않는다. | 파라미터 | 요청 body(JSON) | 필터·식별자·쓰기 payload 전부 body에 싣는다. 쿼리스트링은 쓰지 않는다. 자원 경로의 {accountId} 같은 식별자만 예외로 경로에 남는다. | 인증 | Authorization: Bearer {token} | 로그인 계열을 제외한 모든 요청. | Content-Type | application/json; charset=utf-8 | | 토큰 원문 로그 | 금지 | 토큰 해시, accountId만 기록한다. [h3] 인증서3.2 Web Server가 서버 인증서를 보유하고 https로 서비스한다. Client는 별도 설정 없이 https로 요청한다. Media Server와 AI는 서버 인증서를 검증하지 않는다. 자체서명 인증서를 쓰는 내부망 배포를 전제로 하며, 어떤 인증서든 수락한다. [h3] 로그인3.3 모든 계정은 동일하게 loginId와 password로 로그인한다. Web Server는 등록된 계정에서 actorType을 판정한다. 요청 { "loginId": "media-seoul", "password": "...", "controlIp": "192.168.30.42", "controlPort": 27000, "streamingBindIp": "192.168.30.43" } 요청 { "loginId": "operator01", "password": "..." } 응답 { "token": "9f2c7a1e4b8d...", "accountId": "media-seoul", "serverId": "MEDIA-01", "actorType": "MEDIA", "permission": "admin", "expiresAtWallMs": null, "mustChangePassword": false } Media Server와 AI 모두 controlPort를 함께 보고한다. Web Server는 이 값으로 각 서버의 WSS 제어 채널에 client로 접속한다. 두 서버 다 WSS 서버이며, Web Server는 양쪽에 각각 연결을 유지한다. serverId는 로그인 요청에 넣지 않는다. Web Server가 등록 시 발급한 값을 토큰과 함께 응답하며, Media Server·AI는 세션 메모리에 보관해 서버 경로와 로그에 사용한다. 아이디가 없거나 비밀번호가 틀리면 AUTH_FAILED다. 아이디 오류와 비밀번호 오류는 계정 존재 여부가 드러나지 않도록 구분하지 않는다. 비밀번호가 맞는데 비활성화된 계정이면 ACCOUNT_DISABLED다. 비밀번호가 틀리면 비활성 여부와 관계없이 AUTH_FAILED다. PTZ 등 기능 권한은 소속 운영자 그룹 단위이며(6.3) 로그인 응답에는 싣지 않는다 — 화면은 /cameras/list(9.4)가 카메라 목록 응답에 함께 내려주는 권한으로 구성한다. externalAccess는 Web Server가 URL을 내려줄 때만 쓰므로 싣지 않는다. Media Server 로그인엔 주소 두 개가 들어온다 — 출처도 저장 위치도 용도도 다르므로 섞지 않는다. AI는 controlIp만 보낸다(스트리밍 엔진이 없어 streamingBindIp가 필요 없다). | 주소 | 출처 | 저장 위치 | 용도 | controlIp | 요청 본문 | server.control_ip | 나중에 Web Server가 이 주소로 WSS·녹화재생·내보내기에 접속. 매 로그인 갱신 | streamingBindIp | 요청 본문 | media_streaming_config.streaming_bind_ip | Media Server가 스트리밍 엔진을 바인딩. 영상 URL의 host streamingBindIp는 10.1의 확정 규칙을 거쳐 저장되며, 이미 정상 IP가 있으면 덮어쓰지 않는다. 감지 실패 시 0.0.0.0을 보내도 로그인은 거부되지 않는다. controlIp는 Media Server가 로컬에서 확정해 매 로그인 보내는 값이며, Web Server는 이 값을 그대로 저장한다 — HTTPS 연결에서 관측한 상대 IP로 덮어쓰지 않는다. 0.0.0.0은 허용하지 않는다. 두 값의 차이는 7.1을 따른다. [h3] 토큰 정책3.4 | 토큰 주체 | 만료 | Media Server·AI 제어 채널 사용 | MEDIA / AI 계정 | 없음, 계정당 활성 1개 | 있음 | Web Server 자신 | 없음 | 있음 | Client 계정 | 있음, 복수 세션 허용 | 없음 서버 계정 토큰은 무제한이다. Media Server가 검증 결과를 영구 캐시하는 구조라 만료를 두면 캐시가 통과시켜 버린다. Media Server·AI가 새로 로그인하면 ALREADY_LOGGED_IN으로 거부하지 않는다. 기존 활성 토큰을 원자적으로 폐기하고 새 토큰을 발급하며, 기존 제어 채널도 새 연결로 교체한다. 로그인 응답의 replacedSession으로 교체 여부를 알 수 있다. 부트스트랩 재시도는 /auth/login을 다시 호출하지 않고 현재 토큰으로 POST /servers/{serverId}/bootstrap을 다시 호출한다. 부트스트랩 실패만으로 토큰을 폐기하지 않는다. Media Server·AI의 /auth/logout은 계정 전환, Managed → Standalone 전환, 사용자의 명시적 로그아웃에만 호출한다. 사람이 쓰는 세션은 만료를 둔다. 이 토큰들은 제어 채널에 사용하지 않으므로 Media Server·AI의 캐시 모델에 영향을 주지 않는다. Web Server 자신의 토큰은 계정이 아니라 서비스 토큰이다. 자기가 발급하며 계정 관리 화면에 노출하지 않고, introspection 응답에서만 actorType: MANAGER로 나타난다. 토큰 해시와 활성 상태만 DB에 저장한다. 원문은 저장하지 않는다. [h3] 최초 관리자 계정3.5 최초 기동 시 기본 관리자 계정 admin / admin과 관리자 그룹(관리자)이 함께 만들어진다. 이 계정은 actorType: Client, permission: admin이며 serverId는 Client-01이고 관리자 그룹에 속한다. Web Server 운영자용 별도 유형은 두지 않는다. Client 관리자는 관리자 그룹에 속한 최초 계정 하나로 고정한다. 추가 Client 계정은 user만 생성할 수 있으며 관리자 추가·승격·교체는 지원하지 않는다. 관리자 생성 요청과 계정을 관리자 그룹에 넣으려는 요청은 ADMIN_ALREADY_EXISTS, 기존 관리자의 삭제·비활성화·운영자 전환·그룹 이동은 LAST_ADMIN_PROTECTED로 거부한다. 관리자 비밀번호 변경은 허용한다. 로그인 응답의 mustChangePassword가 true면 비밀번호 변경 외의 모든 API가 PASSWORD_CHANGE_REQUIRED로 거부된다. 계정 등록도 막힌다. 기본 비밀번호로 되돌릴 수 없다. 비밀번호 분실 시 복구 경로는 운영 절차로 정한다. 메일 발송 수단이 없다. [h2] 메시지 규격4 모든 엔드포인트는 POST다. 경로가 호출할 동작을 정하고, 그 동작에 필요한 값(식별자·필터·쓰기 payload)은 전부 요청 body에 담는다. 쿼리스트링은 쓰지 않는다. 경로의 {accountId} 같은 자원 식별자만 예외로 경로에 남는다. 같은 자원 경로에 조회·등록처럼 서로 다른 동작이 겹치면 경로를 분리한다. 목록 조회는 /list, 수정은 /update, 삭제는 /delete, Media Server 자기 조회는 /fetch를 붙인다. 응답 봉투는 성공·실패 관계없이 항상 같은 네 필드를 갖는다. error는 실패했을 때만, data는 성공했을 때만 값이 있고 나머지는 null이다. 필드 순서는 result → error → data → serverTimeMs다. 응답 { "result": true, "error": null, "data": { }, "serverTimeMs": 1780282800000 } 오류 { "result": false, "error": { "code": "ACCOUNT_NOT_FOUND", "message": "Account does not exist.", "retryable": false, "details": { "accountId": "media-tokyo" } }, "data": null, "serverTimeMs": 1780282800000 } 필터가 없어 body에 실을 값이 없는 조회는 빈 객체 {}를 보낸다. body를 아예 생략하지 않는다. HTTP status와 error.code를 함께 쓴다. status는 분류, code는 판단 근거다. 페이지형 목록 응답은 data.items 배열과 data.total을 반환하며 body의 offset / limit로 페이지를 나눈다. offset 기본값은 0, limit 기본값은 100이며 범위는 1~1000이다. 음수 offset이나 범위 밖 limit은 INVALID_PARAMS다. 부트스트랩과 서버별 전체 설정 스냅샷은 페이지를 나누지 않는다. /accounts/list, /cameras/list, /events/list, /logs/list는 페이지형 목록이다. 계정·카메라는 ID 오름차순, 이벤트는 detectedAtWallMs 내림차순 후 eventId 오름차순, 로그는 occurredAtWallMs 내림차순 후 serverId·logId 오름차순으로 정렬한다. total은 권한·필터 적용 후 페이지를 자르기 전 개수다. 그 외 목록은 별도 명시가 없으면 전체를 반환한다. 시각 범위 조회는 body의 fromMs / toMs를 쓰고 fromMs <= t < toMs로 해석한다. 경로의 {serverId}는 Media Server·AI가 호출할 때 토큰의 serverId와 일치해야 한다. Client admin은 6.2에서 명시적으로 허용한 서버 관리·스트리밍 설정 경로에 한해 다른 serverId를 지정할 수 있다. Media Server·AI가 밀어 넣는 API는 모두 at-least-once다. 전송 실패 시 발신 서버가 재전송하므로 같은 항목이 두 번 이상 도착하며, Web Server가 각 API에 정의된 키로 중복을 제거한다. [h2] 인증 · 토큰5 Media Server·AI가 로그인해서 토큰을 받고, 그 토큰으로 자기 WSS 제어 채널에 Web Server를 연결시키기까지의 전체 흐름이다. 접속 직후 순서는 다음과 같다. 로그인 응답을 반환한다. Web Server는 이 응답을 반환한 뒤에 WSS로 접속하며, 최초 인증이 거절되면 재시도한다. control.server.status로 재기동 여부를 확인한다. 필요하면 tokens.flushCache를 연결당 한 번만 호출한다. status.get을 조회한다. BOOTSTRAPPING이면 WSS를 유지한 채 1초쯤 뒤 다시 조회하고, NOT_READY면 사유를 기록하고 재시도 정책대로 다시 조회하며, READY면 다음으로 넘어간다. cameras.runtimeStatus.list와 설정 조회·대조를 진행하고, 끝나면 초기 동기화 완료로 처리한다. SERVER_NOT_READY를 받아도 WSS를 닫지 않는다. 해당 작업만 보류하고 READY 뒤에 재시도한다. WSS가 실제로 끊겼을 때만 재접속하며, 재접속 뒤에는 2번부터 다시 한다. POST /auth/login 3.3 참조. 인증 헤더 없이 호출한다. 응답 { "result": true, "error": null, "data": { "token": "...", "accountId": "media-tokyo", "serverId": "MEDIA-01", "actorType": "MEDIA", "permission": "admin", "expiresAtWallMs": null, "mustChangePassword": false, "replacedSession": true, "revokedPreviousTokens": 1 }, "serverTimeMs": 1780282800000 } Media Server·AI 계정의 첫 로그인은 replacedSession: false다. 같은 계정으로 다시 로그인하면 이전 토큰과 제어 연결을 교체하고 true를 반환한다. Client 로그인은 기존 세션을 교체하지 않는다. POST /servers/{serverId}/bootstrap Managed 모드 Media Server는 로그인 응답을 받은 뒤 이 API로 기동에 필요한 그룹·CCTV·스트리밍 설정·녹화 스케줄 전체를 한 번에 받는다. 필터가 없으므로 body는 빈 객체다. POST /api/servers/MEDIA-01/bootstrap Authorization: Bearer {Media Server token} Content-Type: application/json {} 이 API는 경로의 serverId와 일치하는 Media Server 토큰만 호출할 수 있다. CCTV 비밀번호가 포함되므로 Client와 AI에는 허용하지 않는다. Media Server WSS가 아직 시작되지 않은 로그인 단계에서 호출하므로 WSS runtime 조회에는 의존하지 않는다. 응답은 WSS 연결 여부, status.get, Media Server의 READY를 기다리지 않고 Web Server DB 값으로 즉시 반환한다 — Media Server는 이 응답을 받아야 READY가 되므로, 기다리면 서로 기다리는 교착이 된다. 응답 { "result": true, "error": null, "data": { "snapshotId": "2d1d318f-f9a0-42f5-9788-687fde383e10", "generatedAtWallMs": 1787193000000, "serverId": "MEDIA-01", "groups": [ { "groupId": "00000003", "groupName": "1F", "sortOrder": 0 } ], "cameras": [ { "cameraId": "00000038", "cameraName": "1F Lobby", "groupId": "00000003", "ipAddress": "192.168.0.10", "httpPort": 80, "onvifPort": 80, "onvifVideoSourceToken": null, "configRevision": 3, "streams": [ { "trackType": 0, "rtspPort": 554, "rtspUrl": "/stream/main" }, { "trackType": 1, "rtspPort": 554, "rtspUrl": "/stream/sub" } ], "auth": { "deviceId": "admin", "devicePw": "Admin11!!" }, "encryptedRtsp": { "enabled": false, "id": "", "pw": "" } } ], "streaming": { "revision": 7, "updatedAtWallMs": 1787192900000, "server": { "enabled": true, "streamingBindIp": "192.168.30.42", "rtspOutputMode": "plain", "ports": { "rtsp": 8554, "http": 8888, "rtc": 8000 } }, "channels": [ { "cameraId": "00000038", "cameraName": "1F Lobby", "trackType": 0, "streamId": "camera_38_0", "enabled": true }, { "cameraId": "00000038", "cameraName": "1F Lobby", "trackType": 1, "streamId": "camera_38_1", "enabled": true } ] }, "recordingSchedules": { "revision": 13, "timeZoneId": "Asia/Seoul", "items": [ { "cameraId": "00000038", "mode": "weekly", "periods": [ { "daysOfWeek": [1, 2, 3, 4, 5], "startMinuteOfDay": 480, "endMinuteOfDay": 1200 } ] } ] } }, "serverTimeMs": 1787193000000 } 스트리밍 streamingBindIp가 확정되지 않아도 이 API는 실패하지 않는다. streamingBindIp에 0.0.0.0을, streaming.server.enabled에 false를 실어 반환하며 그룹·CCTV 동기화는 그대로 진행한다. 결정 규칙은 10.1을 따른다. bootstrap은 아래 세 조회 결과를 합친 응답이다. | 개별 조회 | bootstrap에서 대응하는 필드 | POST /servers/{serverId}/cameras/fetch | groups + cameras | POST /servers/{serverId}/streaming-config | streaming | POST /servers/{serverId}/recording-schedules/fetch | recordingSchedules Media Server는 응답 전체를 검증한 뒤 하나의 트랜잭션으로 그룹·CCTV·채널을 반영한다. 중간 검증 또는 반영이 실패하면 전부 rollback한다. 계정 인증은 이미 성공한 상태이며 실패한 건 초기 설정 반영뿐이다 — 로그인 실패가 아니므로 토큰을 폐기하거나 WSS를 끊지 않는다. 실패 시 Media Server는 WSS를 유지한 채 status.get이 NOT_READY를 반환하도록 두고 로컬 runtime만 정리한다. 토큰과 serverId는 유지하므로 재시도는 로그인 없이 부트스트랩부터 재개한다. bound port, protocol 시작 여부, streamingToken, 완성 URL, viewer 수, CCTV online 상태와 codec/bitrate/fps는 runtime 값이라 이 응답에 넣지 않는다. 서버가 시작된 뒤 Media Server API에서 조회한다. POST /auth/logout 호출자 자신의 토큰을 폐기한다. Media Server·AI는 해당 토큰으로 열린 제어 채널도 닫고 자동 재연결을 중지한다. 부트스트랩 실패 처리에는 이 API를 호출하지 않는다. 응답 { "result": true, "error": null, "data": { "revoked": true, "detached": true, "notifiedServers": 0 }, "serverTimeMs": 1780282800000 } POST /auth/password 자기 비밀번호를 변경한다. mustChangePassword 상태에서 허용되는 유일한 API다. 요청 { "currentPassword": "admin", "newPassword": "..." } 기본 비밀번호와 같은 값은 거부한다. 비밀번호를 바꿔도 기존 토큰은 폐기하지 않는다. 새 비밀번호는 다음 로그인부터 적용되며, 이미 발급된 토큰은 그대로 유효하다. Media Server·AI가 무제한 토큰으로 상시 접속해 있으므로 비밀번호 변경이 운영 중인 연결을 끊지 않게 한다. mustChangePassword는 계정 속성이라 변경 즉시 해제된다. 최초 관리자도 쓰던 토큰으로 계속 진행하며 다시 로그인하지 않아도 된다. 자격증명 유출이 의심되면 비밀번호 변경과 별개로 POST /tokens/revoke를 호출해 토큰을 폐기한다. POST /tokens/introspect Media Server·AI가 남이 제시한 토큰의 유효성을 묻는다. Media Server 또는 AI 서비스 토큰만 호출할 수 있으며 응답 계약은 Media Server API 명세서 3.5와 동일하다. 요청 { "token": "9f2c7a1e4b8d..." } 응답 { "result": true, "error": null, "data": { "active": true, "accountId": "ai-gpu-01", "serverId": "AI-01", "actorType": "AI", "permission": "admin", "reason": null }, "serverTimeMs": 1780282800000 } | 필드 | 설명 | active | 토큰이 유효한지 여부. | accountId | 계정 식별자. 호출자는 감사 로그에 기록하고 토큰 캐시 키로 쓴다. | serverId | 발급된 영구 식별자. Web Server 서비스 토큰이면 null이다. | actorType | MEDIA / AI / Client. Web Server 자신의 서비스 토큰이면 MANAGER다. | permission | admin / user. 감사 로그 기록용이며 Media Server·AI는 이 값으로 호출을 거부하지 않는다. | reason | active: false일 때만. revoked 또는 unknown. 호출자 자신도 인증한다. Media Server는 자기 토큰을 Authorization 헤더에 실어야 한다. 무인증으로 열면 토큰 유효성 판별기가 외부에 노출된다. 존재하지 않는 토큰도 200과 active: false로 응답한다. 존재 여부를 status로 노출하지 않는다. 검사 대상 토큰은 body에만 싣는다. POST /tokens/revoke 관리자가 특정 계정의 토큰을 폐기한다. 요청 { "accountIds": ["ai-gpu-01"] } 응답 { "result": true, "error": null, "data": { "revoked": 2, "detachedServers": 1, "notifiedServers": 3 }, "serverTimeMs": 1780282800000 } Web Server는 폐기 후 등록된 모든 Media Server와 AI에 제어 채널로 폐기 통지를 전파해 캐시를 지운다. 전파하지 않으면 두 서버의 캐시가 계속 통과시킨다. Media Server·AI 계정을 대상으로 폐기하면 해당 계정의 제어 연결도 끊고 자동 재연결을 중지한다. 다시 사용하려면 그 서비스가 로그인해야 한다. 접속되어 있지 않은 서버에는 전파하지 못한다. 다시 붙을 때 전파하며, 그때까지는 해당 서버의 캐시가 유효한 것으로 취급된다. 계정 삭제·비활성화 시에도 먼저 이 폐기 전파를 수행한 뒤 계정을 처리한다. [h2] 계정 관리6 accountId와 serverId는 모두 변경할 수 없고 삭제 후 재사용하지 않는다(2장). Media Server가 accountId를 토큰 캐시 키로 쓰기 때문에, 재사용하면 이전 캐시가 새 계정을 그대로 통과시킨다. [h3] actorType과 permission6.1 | actorType | permission | 의미 | MEDIA | admin | admin만 사용한다. user로 등록하면 INVALID_PARAMS다. | AI | admin | admin만 사용한다. user로 등록하면 INVALID_PARAMS다. | Client | admin | 실시간 관제 + 과거 영상 조회(user와 동일) + 관리(계정·서버 등록, CCTV·그룹 등록·배정, 운영자 그룹 카메라·권한 설정, AI 이벤트 규칙 설정). 시스템 전체에 정확히 하나만 존재한다(3.5). | Client | user | 소속 운영자 그룹에 배정된 카메라에 한해 실시간 관제 + 과거 영상 조회(녹화 재생·내보내기, AI 이벤트 검색·BBox 조회는 소속 운영자 그룹에 부여된 기능 권한에 따름, 6.3). 관리 기능은 FORBIDDEN이다. 기능 제한이 실제로 걸리는 것은 Client뿐이다. Media Server와 AI의 permission은 감사 기록용이며, 제어 채널에 붙은 뒤에는 Media Server·AI가 이 값으로 호출을 거르지 않는다. Web Server 운영자는 별도 actorType이 아니라 Client admin이다. 최초 기본 계정 admin이 여기에 해당한다. admin은 배정과 무관하게 모든 카메라의 모든 기능(실시간·녹화재생·내보내기·PTZ·AI 이벤트)을 쓸 수 있다. user는 소속 운영자 그룹에 배정된 카메라만 쓸 수 있으며, 배정된 카메라라도 실시간 외의 기능(녹화재생·내보내기·PTZ·주야간·포커스·AI 이벤트)은 소속된 운영자 그룹에서 허용돼야 열린다 — 운영자 그룹은 6.3, 카메라 배정은 6.4, 세부 검사 규칙은 각 장을 따른다. 매니저 모드에서는 그룹·카메라·배정·스트리밍 설정·녹화 스케줄을 Web Server의 Client admin만 변경한다. MEDIA 계정으로 변경을 요청하는 경로는 없고, MEDIA는 push받은 설정을 반영하고 상태만 보고한다. 변경 범위의 전체 목록은 ARCH 문서의 "설정 변경과 그룹 소속 정책"을 따른다. [h3] 엔드포인트 권한6.2 호출 자격은 아래가 전부다. 표에 없는 조합은 FORBIDDEN이다. | 엔드포인트 | Media Server | AI | Client admin | Client user | /auth/login · /auth/logout · /auth/password | O | O | O | O | /tokens/introspect | O | O | — | — | /tokens/revoke | — | — | O | — | /accounts 전체 | — | — | O | — | /operator-groups/list | — | — | O | — | /operator-groups · .../{operatorGroupId}/update · .../{operatorGroupId}/delete | — | — | O | — | /operator-groups/{operatorGroupId}/camera-access/put | — | — | O | — | /servers 전체 | — | — | O | — | /groups/list | — | — | O | O(소속 그룹에 배정된 카메라가 있는 카메라 그룹만) | /groups · .../{groupId}/update · .../{groupId}/delete | — | — | O | — | /servers/{serverId}/cameras/discover · .../cameras/probe | — | — | O | — | /servers/{serverId}/camera-tests/create · .../camera-tests/get · .../camera-tests/close | — | — | O | — | /cameras/{id}/capabilities/get · .../capabilities/refresh · .../onvif/status/get | — | — | O | O(소속 그룹에 배정된 카메라만) | /servers/{serverId}/cameras/fetch | O | — | — | — | /servers/{serverId}/bootstrap | O | — | — | — | /servers/{serverId}/streaming-config | O(자기 serverId만) | — | O | — | /servers/{serverId}/streaming-config/server | — | — | O | — | /servers/{serverId}/streaming-config/channels | — | — | O | — | /cameras/{id}/get | — | — | O | — | /cameras/list | — | — | O | O(소속 그룹에 배정된 카메라만, 적용 권한 동봉) | /cameras · .../update · .../delete | — | — | O | — | /cameras/{id}/media-assignments · .../media-assignments/{mediaServerId}/delete | — | — | O | — | /cameras/{id}/ai-assignments · .../ai-assignments/{aiServerId}/update · .../ai-assignments/{aiServerId}/delete | — | — | O | — | /servers/{serverId}/analysis-assignments/fetch | — | O(자기 serverId만) | — | — | /cameras/{id}/live · .../ai-stream | — | — | O | O(소속 그룹에 배정된 카메라만) | /cameras/{id}/ptz | — | — | O | O(소속 그룹 배정 + ptzAllowed) | /cameras/{id}/imaging/dayNight/* · .../imaging/focus/* | — | — | O | O(소속 그룹 배정 + ptzAllowed) | /cameras/{id}/recordings | — | — | O | O(소속 그룹 배정 + playbackAllowed) | /cameras/{id}/recplay-sessions | — | — | O | O(소속 그룹 배정 + playbackAllowed) | /cameras/{id}/recplay-sessions/get · .../refresh · .../close | — | — | O | O(생성 시에만 검사, 이후 세션 보유로 간주) | /servers/{serverId}/recexports/estimate · /servers/{serverId}/recexports(생성) | — | — | O | O(요청 카메라 전부 소속 그룹 배정 + exportAllowed) | /servers/{serverId}/recexports/get · .../list · .../cancel | — | — | O | O(자기 requestedBy만) | /servers/{serverId}/recording-schedules/fetch | — | — | O | — | /servers/{serverId}/recording-schedules | — | — | O | — | /events | — | O | — | — | /events/list | — | — | O | O(소속 운영자 그룹 aiEventsAllowed가 켜진 경우만, 소속 그룹에 배정된 카메라로 제한) | /logs | O | O | — | — | /logs/list · /alerts | — | — | O | — Client user는 소속 운영자 그룹에 배정된 카메라의 관제 기능을 그룹 권한에 따라 사용한다 — 실시간 관제, CCTV 목록, 과거 녹화 재생, 내보내기, AI 이벤트 검색, AnchorBox까지가 대상이다. 다만 admin과 달리 소속 그룹에 배정된 카메라로 범위가 제한되고, 실시간을 뺀 나머지(녹화재생·내보내기·PTZ·AI 이벤트)는 소속된 운영자 그룹에 그 기능이 허용돼 있어야 한다(6.3) — 이 네 권한은 카메라별이 아니라 운영자 그룹 하나에 한 세트이며, 그 그룹에 속한 모든 운영자와 그 그룹에 배정된 모든 카메라에 동일하게 적용된다. 어느 그룹에도 속하지 않은 운영자는 볼 수 있는 카메라가 없어 모든 카메라 접근이 FORBIDDEN이다. 관리 기능(계정·서버 등록, CCTV 카탈로그 등록·배정, 그룹 등록, 스트리밍·녹화 스케줄 설정, 운영자 그룹 카메라 배정, 운영자 그룹 설정, 토큰 폐기, 로그·장애 조회)은 배정·권한과 무관하게 항상 FORBIDDEN이다. PTZ·주야간·포커스 조작(/ptz, /imaging/*)은 admin은 항상 열려 있고, user는 그 카메라가 소속 운영자 그룹에 배정돼 있고 그 그룹의 ptzAllowed(6.3)가 true일 때만 열린다 — 아니면 FORBIDDEN이다. 카메라 장치 자체가 PTZ·이미징을 지원하지 않으면 admin·user 모두 Media Server가 반환한 PTZ_NOT_SUPPORTED/IMAGING_NOT_SUPPORTED를 그대로 받는다 — "권한이 없음"(FORBIDDEN)과 "장치가 못함"(PTZ_NOT_SUPPORTED 등)을 구분한다. MEDIA와 AI는 자기 serverId 경로에만 접근한다. 다른 서버의 경로를 호출하면 permission과 무관하게 FORBIDDEN이다. POST /accounts/list 필터가 없으면 body는 빈 객체다. 요청 {} 응답 { "result": true, "error": null, "data": { "total": 3, "items": [ { "accountId": "operator01", "serverId": "Client-01", "actorType": "Client", "permission": "admin", "enabled": true, "externalAccess": null, "operatorGroupId": "00000001", "createdAtWallMs": 1780200000000, "lastLoginAtWallMs": 1780282000000 }, { "accountId": "viewer01", "serverId": "Client-02", "actorType": "Client", "permission": "user", "enabled": true, "externalAccess": null, "operatorGroupId": "00000002", "createdAtWallMs": 1780200100000, "lastLoginAtWallMs": 1780282100000 }, { "accountId": "media-seoul", "serverId": "MEDIA-01", "actorType": "MEDIA", "permission": "admin", "enabled": true, "createdAtWallMs": 1780100000000, "lastLoginAtWallMs": 1780282700000 } ] }, "serverTimeMs": 1780282800000 } actorType, permission, enabled, operatorGroupId로 거를 수 있다(그룹별 계정 목록). Client 항목에는 externalAccess·operatorGroupId가 함께 실리며, 다른 유형에는 없다. operatorGroupId는 Client만 값을 가지며(관리자는 관리자 그룹), 그룹이 없는 운영자는 null이다 — 적용되는 권한 자체는 운영자 그룹 쪽에서 조회한다(6.3 /operator-groups/list). POST /accounts 계정을 등록한다. Client admin만 호출한다. 요청 { "accountId": "ai-gpu-02", "actorType": "AI", "permission": "admin", "password": "..." } 응답 { "result": true, "error": null, "data": { "accountId": "ai-gpu-02", "serverId": "AI-02", "actorType": "AI", "permission": "admin", "enabled": true }, "serverTimeMs": 1780282800000 } Client 계정은 외부 접속 주소를 함께 지정할 수 있다. permission: user는 소속 운영자 그룹도 함께 지정할 수 있다. 요청 { "accountId": "field01", "actorType": "Client", "permission": "user", "password": "...", "operatorGroupId": "00000002", "externalAccess": { "host": "203.0.113.10", "portMap": { "MEDIA-01": { "8888": 18888, "27001": 18001 }, "AI-01": { "28100": 38100 } } } } 응답 { "result": true, "error": null, "data": { "accountId": "field01", "serverId": "Client-03", "actorType": "Client", "permission": "user", "enabled": true, "operatorGroupId": "00000002", "externalAccess": { "host": "203.0.113.10", "portMap": { "MEDIA-01": { "8888": 18888, "27001": 18001 }, "AI-01": { "28100": 38100 } } } }, "serverTimeMs": 1780282800000 } Web Server는 actorType별 다음 번호로 serverId를 발급한다. MEDIA·AI 계정은 등록과 동시에 7장의 서버 레코드가 생성되며, 로그인 전에는 controlIp, controlPort, url이 null이고 connected는 false다. 이 상태에서도 Web Server는 해당 serverId 앞으로 CCTV·그룹 관리(Media Server)와 CCTV의 담당 AI 지정(AI)을 수행할 수 있으며, 로그인 후 반영된다. actorType은 MEDIA / AI / Client 셋뿐이다. permission 조합은 6.1을 따르며 어긋나면 INVALID_PARAMS다. actorType별 번호가 99를 넘으면 SERVER_ID_EXHAUSTED를 반환한다. 삭제된 번호는 재사용하지 않는다. 비밀번호는 단방향 해시로 저장한다. 원문과 복원 가능한 형태로 저장하지 않는다. 중복이면 ACCOUNT_CONFLICT를 반환한다. 삭제된 accountId도 중복으로 취급한다. 추가 Client 계정은 permission: user만 허용한다. permission: admin 생성 요청은 ADMIN_ALREADY_EXISTS다. MEDIA·AI 서비스 계정은 기존대로 admin만 사용한다. operatorGroupId는 Client user 전용 선택 필드다(6.3). 생략하거나 null이면 그룹 없음으로 등록되며, 그룹이 정해질 때까지 볼 수 있는 카메라가 없다. Client가 아니거나 permission: admin인데 넣으면 INVALID_PARAMS, 존재하지 않는 값이면 OPERATOR_GROUP_NOT_FOUND, 관리자 그룹이면 ADMIN_ALREADY_EXISTS다. externalAccess는 Client 전용 선택 필드다. 이 계정이 서버와 다른 망에서 접속할 때 Web Server가 내려주는 Media Server·AI Server URL의 접속 주소를 정한다. 생략하거나 null이면 내부 주소를 그대로 준다(8장). Client가 아닌 계정에 externalAccess를 넣으면 INVALID_PARAMS다. externalAccess.host가 비어 있거나, portMap의 키가 등록된 serverId가 아니거나, 포트가 1~65535가 아니어도 INVALID_PARAMS다. 볼 수 있는 카메라는 계정이 아니라 소속 운영자 그룹의 카메라 배정이 정한다(6.4) — 운영자마다 따로 배정하지 않는다. POST /accounts/{accountId}/update enabled, password, operatorGroupId(Client user 전용), externalAccess(Client 전용)를 변경한다. accountId, serverId, actorType, permission은 바꿀 수 없다. permission 변경 요청은 INVALID_PARAMS이며, 관리자 강등·그룹 이동 요청은 LAST_ADMIN_PROTECTED다. enabled를 false로 바꾸면 해당 계정의 토큰을 전부 폐기하고 Media Server·AI에 전파한다. password만 바꾸는 경우에는 토큰을 폐기하지 않는다. 다음 로그인부터 새 비밀번호가 필요할 뿐이며, 기존 토큰은 계속 유효하다. 즉시 차단하려면 enabled: false로 바꾸거나 POST /tokens/revoke를 호출한다. operatorGroupId를 바꾸면 그룹 이동이다 — 토큰을 폐기하지 않고 다음 권한 검사부터 바로 적용된다(6.3). null을 보내면 그룹에서 빠진다. 존재하지 않는 값이면 OPERATOR_GROUP_NOT_FOUND, 관리자 그룹으로 옮기려 하면 ADMIN_ALREADY_EXISTS다. externalAccess는 통째로 교체되며 부분 병합하지 않는다. null을 보내면 해제되어 내부 주소로 돌아간다. externalAccess는 토큰을 폐기하지 않고 이후 호출부터 바로 적용된다. 고정 관리자 계정의 enabled: false 요청은 LAST_ADMIN_PROTECTED다. POST /accounts/{accountId}/delete 계정을 삭제한다. 토큰 폐기와 Media Server 전파를 먼저 수행하며, 운영자의 operatorGroupId도 null로 해제한다. 필터가 없으므로 body는 빈 객체다. 시스템의 유일한 Client admin이면 LAST_ADMIN_PROTECTED다(3.5). Client 계정을 비활성화해도 Media Server의 영상 엔드포인트는 계정을 보지 않는다. 이미 URL을 아는 사람은 녹화재생을 계속할 수 있다. 실시간 스트림은 Media Server 재시작으로 streamingToken이 바뀔 때, 녹화재생·내보내기는 세션 토큰이 만료될 때 끊긴다. [h3] 운영자 그룹6.3 운영자 그룹은 소속 운영자가 볼 수 있는 카메라(cameraIds)와 녹화재생·내보내기·PTZ·AI 이벤트 기능 권한을 정한다. 운영자는 최대 한 그룹에 소속되며 계정별 권한 재정의는 허용하지 않는다. MEDIA·AI 서비스 계정은 그룹 적용 대상이 아니다. 관리자 그룹(isAdminGroup: true)은 시스템이 만든 고정 그룹이다. 권한 4개는 항상 true이고 카메라는 전체(cameraIds: null)이며, 이름·권한·카메라를 바꾸거나 삭제할 수 없다(OPERATOR_GROUP_PROTECTED). 카메라 그룹(9.1, 9.6)과는 별개다. 카메라 그룹은 카메라 분류이고, 운영자 그룹은 운영자의 카메라 범위와 기능 권한이다. 운영자 그룹은 카메라를 cameraIds로 직접 가지며 카메라 그룹을 거치지 않는다. | 필드 | 의미 | playbackAllowed | 녹화 구간 조회·녹화재생(13, 14장) | exportAllowed | 내보내기(14장) | ptzAllowed | PTZ·주야간·포커스(12장) | aiEventsAllowed | AI 이벤트 검색·조회(15장) 네 권한은 서로 독립이지만, 같은 그룹에 속한 운영자 전원에게 그룹의 값 그대로 적용된다 — 운영자 한 명만 따로 조정할 수 없다. 카메라 범위(cameraIds)도 같은 그룹 운영자 전원에게 그대로 적용된다(6.4) — 같은 카메라를 여러 그룹에 넣을 수 있고, 권한은 그룹마다 다르게 줄 수 있다. 그룹의 cameraIds에 있는 것 자체가 배정이므로 실시간(11장)은 권한과 무관하게 허용된다. 그룹에 속하지 않은 운영자는 볼 수 있는 카메라가 없어 모든 카메라 접근이 FORBIDDEN이다. 운영자 계정의 소속 그룹은 이 장이 아니라 계정 등록·수정(POST /accounts, .../update)의 operatorGroupId로 정한다. 그룹 관리와 소속 변경 모두 Client admin 전용이다. [h4] POST /operator-groups/list 등록된 운영자 그룹 목록과 각 그룹의 권한·배정된 카메라·소속 운영자 수를 반환한다. 인원수는 삭제되지 않은 계정을 기준으로 하며 비활성 계정도 포함한다. 필터가 없으므로 body는 빈 객체다. 요청 {} 응답 { "result": true, "error": null, "data": { "items": [ { "operatorGroupId": "00000001", "groupName": "관리자", "isAdminGroup": true, "permissions": { "playbackAllowed": true, "exportAllowed": true, "ptzAllowed": true, "aiEventsAllowed": true }, "cameraIds": null, "operatorCount": 1 }, { "operatorGroupId": "00000002", "groupName": "관제팀", "isAdminGroup": false, "permissions": { "playbackAllowed": true, "exportAllowed": false, "ptzAllowed": true, "aiEventsAllowed": true }, "cameraIds": ["00000001", "00000002", "00000003", "00000004", "00000005"], "operatorCount": 2 } ] }, "serverTimeMs": 1780282800000 } [h4] POST /operator-groups 그룹을 새로 만든다. 요청 { "groupName": "관제팀", "permissions": { "playbackAllowed": true, "exportAllowed": false, "ptzAllowed": true, "aiEventsAllowed": true }, "cameraIds": ["00000001", "00000002"] } 응답 { "result": true, "error": null, "data": { "operatorGroupId": "00000002", "groupName": "관제팀", "isAdminGroup": false, "permissions": { "playbackAllowed": true, "exportAllowed": false, "ptzAllowed": true, "aiEventsAllowed": true }, "cameraIds": ["00000001", "00000002"] }, "serverTimeMs": 1780282800000 } operatorGroupId는 Web Server가 발급하는 전역 유일값이다. groupName이 삭제되지 않은 다른 운영자 그룹과 겹치면 OPERATOR_GROUP_NAME_CONFLICT다. permissions의 네 필드는 생략하면 false다. cameraIds는 생략하면 빈 배열이다. 없는 cameraId가 섞여 있으면 CAMERA_NOT_FOUND이며 아무것도 저장하지 않는다. [h4] POST /operator-groups/{operatorGroupId}/update 이름·권한을 변경한다. 카메라 배정은 이 API가 아니라 camera-access/put(6.4)으로 바꾼다. 지정한 필드만 반영하고 생략한 필드는 기존 값을 유지한다. 소속된 모든 운영자에게 즉시 적용된다 — 토큰을 폐기하지 않고 다음 권한 검사부터 반영된다(6.2). 요청 { "permissions": { "exportAllowed": true } } 응답 { "result": true, "error": null, "data": { "operatorGroupId": "00000002", "groupName": "관제팀", "isAdminGroup": false, "permissions": { "playbackAllowed": true, "exportAllowed": true, "ptzAllowed": true, "aiEventsAllowed": true }, "cameraIds": ["00000001", "00000002"] }, "serverTimeMs": 1780282800000 } groupName을 다른 그룹과 겹치게 바꾸면 OPERATOR_GROUP_NAME_CONFLICT다. 관리자 그룹이면 OPERATOR_GROUP_PROTECTED다. 진행 중인 녹화재생 세션·내보내기 작업은 즉시 끊지 않는다(14장) — 세션 종료·작업 취소는 소유권 기준으로 계속 허용한다. [h4] POST /operator-groups/{operatorGroupId}/delete 그룹을 삭제한다. 필터가 없으므로 body는 빈 객체다. 요청 { } 응답 { "result": true, "error": null, "data": { "operatorGroupId": "00000002" }, "serverTimeMs": 1780282800000 } 삭제되지 않은 소속 운영자(비활성 계정 포함)가 하나라도 남아있으면 OPERATOR_GROUP_HAS_OPERATORS로 거부된다. 먼저 그 운영자들을 다른 그룹으로 옮기거나(POST /accounts/{accountId}/update의 operatorGroupId) 그룹 소속을 해제해야 삭제할 수 있다. 그룹을 삭제하면 그 그룹의 카메라 배정도 함께 지워진다. 관리자 그룹이면 OPERATOR_GROUP_PROTECTED다(소속 운영자 검사보다 먼저 적용된다). [h3] 운영자 그룹별 카메라 배정6.4 운영자 그룹에 속한 Client user가 볼 수 있는 카메라 범위를 그룹 단위로 정한다. admin은 이 설정과 무관하게 전부 허용되며, 관리자 그룹은 이 API의 대상이 아니다. 기능 권한(녹화재생·내보내기·PTZ·AI 이벤트)은 같은 그룹의 permissions가 정한다(6.3). 그룹의 cameraIds에 있는 것 자체가 배정이므로 실시간(11장)은 기본 허용된다. cameraIds에 없는 카메라는 이 그룹 소속 운영자에게 실시간을 포함해 전부 FORBIDDEN이다. 현재 배정은 /operator-groups/list(6.3)의 cameraIds로 조회한다. [h4] POST /operator-groups/{operatorGroupId}/camera-access/put 그룹의 카메라 배정을 전체 교체한다. Client admin 전용. 요청 { "cameraIds": ["00000003", "00000004", "00000005"] } 응답 { "result": true, "error": null, "data": { "saved": true, "cameraCount": 3 }, "serverTimeMs": 1780282800000 } 전체 교체다. cameraIds에 없는 기존 배정은 해제된다. 빈 배열을 보내면 이 그룹의 카메라 배정을 전부 해제한다. 같은 카메라를 여러 그룹에 배정할 수 있다. 그룹마다 기능 권한은 다르게 줄 수 있다. 없는 cameraId가 섞여 있으면 CAMERA_NOT_FOUND, 없는 그룹이면 OPERATOR_GROUP_NOT_FOUND, 관리자 그룹이면 OPERATOR_GROUP_PROTECTED다. 어느 쪽이든 아무것도 바꾸지 않는다. 같은 그룹 안에서 운영자마다 다른 카메라를 주는 방식은 지원하지 않는다. 필요하면 별도 그룹을 만든다. 그룹 단위로 한꺼번에 배정하는 화면 편의 기능은 이 API 위에 클라이언트가 카메라 그룹 소속 카메라 ID를 cameraIds에 채워 넣는 방식으로 구현한다 — 별도 group 파라미터는 두지 않는다. 카메라 ID 목록은 POST /cameras/list를 groupId로 걸러 얻는다(9.4). 진행 중인 녹화재생 세션·내보내기 작업은 즉시 끊지 않는다(14장) — 세션 종료·작업 취소는 소유권 기준으로 계속 허용한다. 같은 운영자 그룹에 속해 있어도 다른 운영자가 만든 녹화재생 세션·내보내기 작업은 조작할 수 없다 — 소유권은 그룹이 아니라 계정 단위다(14장). [h2] 서버 등록7 POST /servers/list 등록된 Media Server·AI 목록과 접속 상태다. 필터가 없으면 body는 빈 객체다. 계정을 등록하면 서버 레코드가 즉시 생성되며, 로그인 전에는 controlIp, controlPort, url, startedAtWallMs, apiVersion, lastSeenAtWallMs가 모두 null이고 connected는 false다. 응답 { "result": true, "error": null, "data": { "items": [ { "serverId": "MEDIA-01", "actorType": "MEDIA", "controlIp": "192.168.0.20", "controlPort": 27000, "url": "wss://192.168.0.20:27000/ws", "enabled": true, "connected": true, "startedAtWallMs": 1783664000000, "apiVersion": "2.0", "lastSeenAtWallMs": 1780282790000 }, { "serverId": "AI-01", "actorType": "AI", "controlIp": "192.168.0.30", "controlPort": 28000, "url": "wss://192.168.0.30:28000/ws", "enabled": true, "connected": true, "startedAtWallMs": 1783664100000, "apiVersion": "2.0", "lastSeenAtWallMs": 1780282780000 }, { "serverId": "AI-02", "actorType": "AI", "controlIp": null, "controlPort": null, "url": null, "enabled": true, "connected": false, "startedAtWallMs": null, "apiVersion": null, "lastSeenAtWallMs": null } ] }, "serverTimeMs": 1780282800000 } AI-02처럼 아직 로그인하지 않은 서버는 접속 필드가 전부 null이다. 이 상태에서도 Web Server는 CCTV·그룹 관리(Media Server)와 담당 AI 지정(AI)을 그대로 수행하며, 로그인 후 Media Server·AI에 반영된다. connected는 Web Server가 유지하는 제어 채널 연결 상태다. 서버가 보고하는 값이 아니며, 준비 여부(READY)와는 별개다. 준비 상태는 POST /servers/{serverId}/status의 status로 본다. Media Server와 AI 모두 이 값으로 생존을 판정한다. 별도 Heartbeat는 쓰지 않는다. lastSeenAtWallMs는 그 서버가 마지막으로 Web Server를 호출한 시각이다. 참고용이며 생존 판정 근거가 아니다. startedAtWallMs는 control.server.status에서 회수한 값이며, 이 값이 바뀌면 Media Server가 재기동한 것이다. 9장의 재동기화 판단에 쓴다. POST /servers/{serverId}/update 접속 정보를 수동으로 교정한다. controlIp·streamingBindIp는 로그인 요청에 실린 값을 그대로 저장하므로 평소에는 이 API가 필요 없다. 실제 제어 서버 바인딩 주소(controlIp)를 바꾸려면 Media Server 로컬 설정(과 필요시 인증서)을 바꾸고 재로그인해야 반영된다. [h3] controlIp와 streamingBindIp의 차이7.1 두 값 다 Media Server가 로그인 요청에 실어 보내는 declared 값이며, Web Server는 그 값을 그대로 저장해 쓴다 — 관측한 접속 주소로 덮어쓰지 않는다. | 값 | 대상 서버 | 포트 | Media Server의 바인딩 | 접속하는 쪽 | controlIp | 제어 채널 WSS | controlPort (기본 27000) | controlIp 한 곳 | Web Server | controlIp | 녹화재생·내보내기 HTTP (Kestrel) | recplayHttpPort (기본 27001) | controlIp 한 곳 | Client | streamingBindIp (10.1) | 스트리밍 엔진 (ZLM) | 8554 / 8888 / 8000 | streamingBindIp 한 곳 | AI · Client Media Server의 WSS 제어 채널과 녹화재생·내보내기 HTTP 서버는 로컬에서 설정한 controlIp에 바인딩한다. 두 서버의 접속 URL에도 같은 주소를 쓰며, 기동·종료를 함께 처리한다(Media Server API 명세서 3.1 참고). ZLM은 별도의 streamingBindIp에 바인딩하고 실시간 스트리밍 URL에도 그 주소를 쓴다. 어느 쪽이든 접속하는 상대방의 IP·대역은 제한하지 않는다 — 네트워크에서 그 주소로 도달할 수만 있으면 다른 대역에서도 접속할 수 있다. 도달할 수 없는 대역(NAT 바깥 등)은 8장의 externalAccess로 다룬다. 192.168.0.1 서비스망 → streamingBindIp 10.10.0.15 관리망 → controlIp Media Server와 Web Server는 같은 망에 있는 것을 전제로 한다 — 둘 사이에 NAT가 있으면 controlIp로 선언한 로컬 주소에 Web Server가 도달하지 못할 수 있으며, 이 계약은 그 경우를 다루지 않는다. 서버 간 NAT까지 지원해야 한다면 접속 주소를 controlIp와 별도로 정의해야 한다. Client가 Media Server와 다른 망에 있으면 streamingBindIp·controlIp로 만든 URL에 접속할 수 없다. 이 경우는 그 Client 계정의 externalAccess로 푼다(8장). POST /servers/{serverId}/status Web Server가 제어 채널로 status.get, storage.status.get, control.server.status, streaming.server.status를 호출해 합친 결과다. 캐시하지 않고 매번 조회하며, streaming에는 실행 여부와 실제 바인딩 포트가 포함된다. 응답의 status에는 status.get의 값(BOOTSTRAPPING/READY/NOT_READY)을 그대로 싣고, READY가 아니면 streaming.server.status를 호출하지 않고 streaming을 null로 둔다. streamingToken은 반환하거나 저장하지 않는다. 필터가 없으므로 body는 빈 객체다. 연결이 없으면 SERVER_UNREACHABLE을 반환한다. 마지막으로 알려진 값을 유효한 것처럼 반환하지 않는다. [h2] 외부 접속 주소 중계8 Client가 서버와 다른 망(NAT·VPN 바깥)에서 접속하면 서버가 만든 내부 주소 URL로는 붙을 수 없다. 이 문제는 서버가 아니라 Client 계정에 접속 주소를 지정해 푼다 — 같은 서버라도 사무실 안 계정은 내부 주소로, 바깥 계정은 외부 주소로 받아야 하기 때문이다. Media Server·AI 서버의 API와 설정은 바뀌지 않는다. 서버는 계속 내부 주소로 URL을 만들고, Web Server가 Client에게 내려주는 순간에만 바꾼다. | 계정의 externalAccess | 동작 | 미설정 (null) | 내부 주소를 그대로 전달한다. | 설정 | URL의 host를 host로 교체한다. 포트는 portMap[serverId][내부 포트]가 있으면 그 값, 없으면 내부 포트 그대로 쓴다. 스킴·경로·쿼리는 유지한다. portMap은 serverId → (내부 포트 → 외부 포트) 형태다. 공인 IP 하나 뒤에 Media Server가 여러 대면 서버마다 외부 포트가 달라야 하므로 서버별로 둔다. serverId는 URL을 내려주는 대상 서버(mediaServerId 또는 aiServerId)다. 치환하는 URL은 Client에게 내려주는 응답의 아래 항목이다. | 서버 | API | 필드 | Media Server | /cameras/{cameraId}/live | 실시간 접속 URL (whepUrl, webRtcUrl) | Media Server | /servers/{serverId}/camera-tests/get | 연결 테스트 미리보기 URL (whepUrl, webRtcUrl) | Media Server | /cameras/{cameraId}/recplay-sessions* | hlsUrl | Media Server | /servers/{serverId}/recexports/get | items[].downloadUrl | AI | /cameras/{cameraId}/ai-stream | url | AI | /events/list | clipUrl, thumbnailUrl 저장된 값은 바뀌지 않는다. clipUrl 등은 DB에 내부 주소 그대로 있고, 응답을 만들 때만 치환한다. rtspUrl은 AI가 내부망에서 직접 쓰는 URL이라 치환 대상이 아니다. Media Server와 AI 서버는 같은 망에 있는 것을 전제로 하므로 서버 간 통신에는 외부 주소를 쓰지 않는다. WebRTC 영상 자체(rtcPort, UDP)는 signaling 뒤에 ICE candidate로 협상되므로 URL 치환만으로는 NAT를 통과하지 못할 수 있다. 이 부분은 스트리밍 엔진의 ICE 설정 영역이며 이 계약이 다루지 않는다. [h2] CCTV · 그룹9 CCTV·그룹 정보의 기준은 Web Server다. CCTV는 cameraId로 한 번만 등록하고(9.1), 어느 Media Server가 녹화하고 어느 AI가 분석할지는 별도의 배정으로 관리한다(9.4). ONVIF 카메라 검색은 12.1을 따른다. 그룹은 전역이다. 관리자가 공통 그룹과 카메라를 한 번 구성해 두면, 운영자(6.4)·MEDIA(9.4)·AI(9.4)에는 그중 필요한 카메라만 배정한다 — 배정마다 그룹을 따로 정하지 않는다. 그룹 이름은 모두 같고 각 대상의 카메라 목록만 다르다. groupId는 Web Server가 한 번만 발급하며 카메라 하나에 그룹 하나뿐이다. [h3] CCTV 설정 필드9.1 Web Server는 CCTV의 접속 설정 전체를 카탈로그에 보관한다. Media Server가 DB를 잃거나 교체되어도 Web Server에 저장된 정보만으로 완전히 복원할 수 있어야 하기 때문이다. 필드는 Media Server API 명세서의 camera-assignments.create와 같다. { "cameraId": "00000038", "cameraName": "1F Lobby", "groupId": "00000003", "ipAddress": "192.168.0.10", "httpPort": 80, "onvifPort": 80, "onvifVideoSourceToken": null, "streams": [ { "trackType": 0, "rtspPort": 554, "rtspUrl": "/stream/main" }, { "trackType": 1, "rtspPort": 554, "rtspUrl": "/stream/sub" } ], "auth": { "deviceId": "admin", "devicePw": "Admin11!!" }, "encryptedRtsp": { "enabled": false, "id": "", "pw": "" } } 이 묶음을 아래에서 'CCTV 설정'이라 부른다. cameraId는 CCTV 등록 시 한 번만 발급되며 모든 MEDIA 배정이 같은 값을 공유한다. groupId도 이 CCTV 설정에 속한다. 배정과 무관하게 카메라 하나에 값 하나뿐이며, 반드시 존재하는 그룹 하나에 소속된다(null 불허). onvifPort는 등록 단계(9.2)에서 다른 필드와 마찬가지로 필수이며 기본값을 보충하지 않는다. Media Server API 쪽의 "생략하면 80"은 Web Server가 아닌 다른 경로로 직접 호출될 때를 대비한 방어적 기본값이다(Media Server API 5장). onvifVideoSourceToken은 선택이다. NVR·스트리밍서버처럼 한 IP 뒤에 카메라가 여러 대인 장치에서만 채우며(12.1 검색 결과의 videoSourceToken을 그대로 넣는다), 값은 ONVIF VideoSource 토큰이다. 단일 채널 카메라는 생략하거나 null이고, 등록 여부 판단에는 쓰이지 않는다. ONVIF 연결은 Media Server가 ipAddress·onvifPort로 자동 시도한다(Media Server API 8.1). Web Server는 onvifPort·onvifVideoSourceToken 외의 ONVIF 값을 저장하지 않으며, 기능 지원 여부는 12.1로 조회한다. Media Server로 push할 때는 그룹을 포함한 CCTV 설정 전체를 그대로 보낸다. auth.devicePw와 encryptedRtsp.pw는 CCTV 접속 비밀번호다. Web Server는 이 값을 암호화해 저장하고, 아래 두 경우에만 응답에 싣는다. - POST /servers/{serverId}/cameras/fetch를 includeSecrets: true로 호출했을 때 (Media Server 복원 전용) - POST /servers/{serverId}/bootstrap으로 최초 기동 설정을 받을 때 (Media Server가 로컬 DB를 아직 못 채운 시점이라 이 값이 없으면 CCTV에 접속할 수 없다) 그 외에는 어떤 응답에도 싣지 않으며 null로 반환한다 Client가 호출하는 POST /cameras/list에는 설정 자체를 싣지 않는다. CCTV 접속 정보는 관제에 필요 없다. [h3] 등록 경로9.2 CCTV·그룹의 등록·수정·삭제, MEDIA·AI 배정은 전부 Client admin 전용이다. Media Server와 AI는 이 중 어느 것도 스스로 만들거나 바꾸거나 지울 수 없다 — 자기 앞으로 오는 WSS push(camera-groups.*/camera-assignments.*)를 받아 반영만 한다. | 절차 | 흐름 | Media Server 연결 | 그룹 등록 (Web Server UI) | Client admin이 POST /groups로 전역 등록(9.6) | 필요 없음 — 배정 전까지는 어느 MEDIA도 모른다 | CCTV 등록 (Web Server UI) | Client admin이 POST /cameras로 카탈로그에 등록(그룹 포함) | 필요 없음 | MEDIA 배정 (Web Server UI) | Client admin이 POST /cameras/{cameraId}/media-assignments로 지정 MEDIA에 배정 | 없어도 된다. Web Server 저장을 기준으로 응답하고, 반영은 재접속·동기화(9.3)가 이어서 맞춘다 origin 필드는 폐지했다 — 등록·배정 요청자가 Client admin 하나뿐이라 더는 구분할 대상이 없다. [h3] 동기화9.3 Web Server DB가 항상 기준이다. 요청의 유효성과 권한을 검사한 뒤 Web Server에 설정을 먼저 저장하고, 저장 성공을 기준으로 응답한다. 저장 직후 대상 MEDIA에 즉시 반영을 시도하지만, MEDIA 연결이 없거나 반영이 실패해도 이미 저장된 설정을 되돌리지 않는다 — 재접속 및 주기적 동기화에서 Web Server의 최신 설정을 기준으로 다시 반영한다. 반영 대기 상태를 화면에 표시할 필요는 없으며, 반영 실패 원인은 서버 로그에 남긴다. 동기화 시점은 아래와 같다. | 시점 | 동작 | 제어 채널 (재)접속 직후 | control.server.status로 재기동 여부를 확인하고, status.get이 READY가 되면 camera-groups.list, camera-assignments.list로 Media Server의 그룹·CCTV를 읽어 Web Server DB와 대조한다. 같은 ID의 CCTV는 list의 configRevision을 Web Server 값과 비교해, 같으면 그대로 두고 다르거나 없으면 비밀번호를 포함한 전체 설정을 create/update로 다시 보낸다. | Web Server에서 추가·변경·삭제 | camera-groups.* / camera-assignments.*로 즉시 반영을 시도한다. 실패해도 저장은 유지되며, 원인은 서버 로그에 남긴다. | 주기적 재확인 | "재접속 직후"와 같다. camera-assignments.get을 카메라마다 부를 필요 없이 list 한 번으로 그룹·CCTV의 추가·수정·배정 해제와 configRevision을 대조해 반영한다 — 최초 반영 실패분만 메우는 게 아니라 매번 전체를 다시 맞춘다. configRevision은 카메라 설정이 바뀔 때마다 오르는 Web Server의 번호다. 9.1 CCTV 설정에는 속하지 않고 camera-assignments.*로 push할 때만 함께 보낸다. MEDIA는 이 번호를 설정과 함께 저장해 list/get으로 돌려주므로, MEDIA가 예전 설정으로 돌아가거나 비밀번호만 바뀐 경우도 번호 차이로 감지한다(비밀번호는 응답에 싣지 않는다). 반영 실패 시 저장된 설정을 유지하고 재시도한다. 같은 카메라·MEDIA의 push는 순서대로 보내며, 늦게 도착한 이전 번호의 요청은 MEDIA가 무시한다(applied: false). 번호(configRevision, 녹화 스케줄·스트리밍 설정의 revision)는 모두 Web Server가 소유하고, MEDIA는 저장해 돌려주기만 한다. 접속 직후와 주기적 재확인 때 MEDIA가 돌려준 번호를 Web Server 번호와 비교해, 다르거나 비어 있으면(MEDIA 재설치 포함) 웹 값 전체를 push한다. 웹에 스케줄이 하나도 없으면 빈 items를 보내 MEDIA에 남은 스케줄도 지운다. 웹 DB를 예전 백업으로 되돌려 MEDIA 번호가 웹 번호보다 크면, Web Server는 자기 번호를 MEDIA 번호보다 크게 올려 저장한 뒤 다시 push한다 — 그렇지 않으면 MEDIA가 "저장된 값보다 작은 번호"로 판단해 새 요청을 계속 무시한다. Media Server가 BOOTSTRAPPING·NOT_READY인 동안 카메라 구성에 의존하는 action은 SERVER_NOT_READY(retryable: true)로 거절된다. Web Server는 이를 연결 실패로 취급하지 않는다 — WSS를 유지한 채 status.get을 1초 간격 정도로 폴링하다가 READY가 되면 대조를 시작한다. control.server.status, status.get, tokens.*는 준비 여부와 무관하게 언제든 호출한다. 준비 전에 목록을 읽으면 반영 중인 절반짜리 목록이 orphan으로 잘못 판정된다(허용 action 표는 Media Server API 15장). 그룹은 카메라 배정과 완전히 분리된 개념이다(9.6) — camera-groups.* push는 카메라 배정·녹화에 아무 영향을 주지 않는다. 어떤 MEDIA가 특정 그룹을 처음 알아야 하면(그 그룹의 카메라가 그 MEDIA에 처음 배정될 때) camera-groups.create를, 더는 몰라도 되면(그 MEDIA에 남은 소속 카메라가 없어질 때) camera-groups.delete를 push한다. 카메라 자체의 배정 해제·그룹 이동은 이 push와 무관하게 camera-assignments.*로 따로 처리한다. 대조 규칙은 아래와 같다. Web Server DB가 기준이다. | 상태 | 처리 | Web Server DB에는 있고 Media Server에 없음 | 미반영분이다. camera-assignments.create로 push한다. | Media Server에는 있고, cameraId 자체가 Web Server 카탈로그에 없음 | 진짜 orphan이다 — Web Server 복구나 Media Server 교체로 생긴다. 관리자 확인 전에는 삭제하지 않는다. | Media Server에는 있고, 카탈로그엔 있지만 이 MEDIA에는 배정이 없음 | Web Server에서 정상적으로 배정 해제된 카메라다. camera-assignments.delete(녹화 파일 유지)를 다시 보내 반영한다 — 배정 해제 사실 자체는 이미 Web Server DB에 확정돼 있으므로 관리자 확인 없이 처리한다. | 양쪽에 있고 값이 다름 | Web Server 값을 push한다. 두 번째·세 번째 행을 구분하는 이유는, 배정 해제를 곧바로 잊어버리면 다음 대조에서 "삭제 요청이 실패해서 남은 카메라"인지 "출처를 알 수 없는 카메라"인지 구별할 수 없기 때문이다 — Web Server는 배정 해제 사실을 자기 DB에서 지우지 않고 그대로 유지하므로(단지 media_assignment 행이 없을 뿐이다) 언제든 다시 판단할 수 있다. camera-assignments.sync는 CCTV·그룹 전체를 Web Server 기준으로 덮어쓴다. Web Server가 발급 주체이므로 이 action이 데이터 일치를 맞추는 정상 수단이며, orphan을 정리하는 데도 쓸 수 있다. 다만 Media Server의 로컬 이력이 함께 사라지므로 관리자 확인 후 호출한다 — 위 표의 항목별 대조와 달리 이 action은 관리자가 명시적으로 호출하는 수동 도구로 남긴다. 그룹 자체의 등록·수정·삭제는 서버와 무관한 전역 API다(9.6). 이 절에는 그 전역 그룹의 카메라를 각 MEDIA로 내려주는 절차만 남는다. POST /servers/{serverId}/cameras/fetch POST /servers/{serverId}/bootstrap 응답에서 groups와 cameras만 반환한다. 스트리밍 설정은 포함하지 않는다. Media Server가 기동 직후와 주기적으로 자기 담당 CCTV·그룹 정보를 보정할 때 호출하며, 응답에는 9.1의 CCTV 설정 전체가 실린다. 요청 { "includeSecrets": true } 응답 { "result": true, "error": null, "data": { "cameras": [ { "cameraId": "00000038", "cameraName": "1F Lobby", "groupId": "00000003", "ipAddress": "192.168.0.10", "httpPort": 80, "onvifPort": 80, "onvifVideoSourceToken": null, "configRevision": 3, "streams": [ { "trackType": 0, "rtspPort": 554, "rtspUrl": "/stream/main" }, { "trackType": 1, "rtspPort": 554, "rtspUrl": "/stream/sub" } ], "auth": { "deviceId": "admin", "devicePw": "Admin11!!" }, "encryptedRtsp": { "enabled": false, "id": "", "pw": "" } } ], "groups": [ { "groupId": "00000003", "groupName": "1F", "sortOrder": 0 } ] }, "serverTimeMs": 1780282800000 } includeSecrets: true일 때만 auth.devicePw와 encryptedRtsp.pw에 실제 값이 실린다. 생략하면 두 필드는 null이다. Media Server는 로컬 DB를 복원할 때만 true로 호출하며, Web Server는 이 호출을 CONFIG_CHANGED 감사 로그에 남긴다. 이 응답의 카메라는 Web Server DB 기준으로 이 MEDIA에 배정된 전부이며, 반영 대기 여부는 싣지 않는다. Media Server는 로컬에 없는 항목을 받아서 DB에 추가한다. onvifVideoSourceToken도 복원 대상이다 — 이 값이 없으면 NVR처럼 채널이 여러 개인 장치의 PTZ·이미징 채널을 복구 뒤에 구분하지 못한다. 각 카메라의 configRevision도 함께 내려가며, Media Server는 복원할 때 이 번호를 설정과 함께 저장한다. 이미 로컬에 있는 항목은 그대로 둔다. 여기 없는 로컬 항목을 임의로 삭제하지 않는다 — 다음 접속 시 9.3의 대조(camera-assignments.list)에서 orphan으로 판단한다. 등록된 카메라의 groupId는 조회·bootstrap·배정 스냅샷에서 항상 존재하는 그룹 ID로 반환한다. 누락·null인 스냅샷은 잘못된 응답이며 미분류 카메라로 반영하지 않는다. 응답에 없는 필드로 로컬 값을 덮어쓰지 않는다. 값이 null이거나 키가 없으면 기존 로컬 설정을 유지한다. 이 조회는 push를 놓쳤을 때의 보정 수단이다. 정상 경로는 제어 채널 push다. groups는 전역 그룹 중 이 MEDIA에 배정된 카메라가 속한 것만 담는다(9.6) — 관리자가 만든 그룹 전체가 아니다. [h3] CCTV 배정9.4 CCTV 하나를 MEDIA 여러 대·AI 여러 대에 중복 배정할 수 있다. 배정된 MEDIA마다 같은 cameraId로 각자 독립적으로 수신·녹화한다(이중 기록) — failover가 아니다. MEDIA-01이 죽어도 MEDIA-02가 이어받지 않고, MEDIA-02는 원래부터 자기 몫을 따로 녹화하고 있었을 뿐이다. 배정은 전부 Client admin이 아래 API로 한다 — Media Server가 스스로 배정을 요청하는 경로는 없다. [h4] POST /cameras/{cameraId}/media-assignments 지정 MEDIA에 CCTV를 배정한다. Client admin 전용. 그룹은 카메라 자체의 속성이라(9.1) 이 API에는 없다. 요청 { "mediaServerId": "MEDIA-02" } 응답 { "result": true, "error": null, "data": { "mediaServerId": "MEDIA-02" }, "serverTimeMs": 1780282800000 } Web Server DB에 저장한 뒤 그 MEDIA에 camera-assignments.create로 즉시 반영을 시도한다. 연결이 없거나 실패해도 저장은 유지된다 — 반영 대기 상태를 화면에 알릴 필요는 없다. 재접속·주기적 동기화(9.3)가 이어서 맞추며, 실패 원인은 서버 로그에 남는다. 이미 배정되어 있으면 ASSIGNMENT_CONFLICT를 반환한다. [h4] POST /cameras/{cameraId}/media-assignments/{mediaServerId}/delete 지정 MEDIA에서만 배정을 해제한다(녹화 파일 유지, CCTV·다른 MEDIA 배정은 그대로). Client admin 전용. 필터가 없으므로 body는 빈 객체다. 배정이 없으면 ASSIGNMENT_NOT_FOUND를 반환한다. 이 MEDIA의 녹화 스케줄 설정에서도 이 CCTV 항목을 함께 지우며, revision은 올리지 않는다(13장). 이 카메라를 이 MEDIA로부터 공급받는 AI 배정이 있으면 AI_SUPPLY_MEDIA_IN_USE로 거부한다 — 먼저 그 AI 배정의 공급 MEDIA를 다른 곳으로 바꾸거나(ai-assignments/{aiServerId}/update) AI 배정 자체를 해제해야 한다. [h4] POST /cameras/{cameraId}/ai-assignments 지정 AI에 CCTV를 배정한다. Client admin 전용. AI는 카메라 영상을 Web Server를 거치지 않고 MEDIA에서 직접 받으므로(9.4 하단, analysis-assignments/fetch 참고), 이 카메라를 공급할 MEDIA를 함께 정한다. 요청 { "aiServerId": "AI-02", "mediaServerId": "MEDIA-01" } 응답 { "result": true, "error": null, "data": { "aiServerId": "AI-02", "mediaServerId": "MEDIA-01" }, "serverTimeMs": 1780282800000 } AI는 MEDIA와 달리 카메라 설정을 로컬에 보관하지 않아 단순 매핑이다. 이미 배정돼 있으면 ASSIGNMENT_CONFLICT를 반환한다. 이 카메라의 MEDIA 배정이 하나뿐이면 mediaServerId를 생략할 수 있고, 그 MEDIA로 자동 선택된다. MEDIA 배정이 둘 이상이면 mediaServerId가 필수다 — 생략하면 INVALID_PARAMS다. 그 카메라의 MEDIA 배정 목록에 없는 값을 주면 ASSIGNMENT_NOT_FOUND다. 이 카메라가 MEDIA에 아직 하나도 배정돼 있지 않으면 CAMERA_NOT_ASSIGNED_TO_MEDIA로 거부한다 — 먼저 9.4의 MEDIA 배정부터 만들어야 한다. [h4] POST /cameras/{cameraId}/ai-assignments/{aiServerId}/update 이미 배정된 AI의 공급 MEDIA(mediaServerId)만 바꾼다. 같은 카메라가 배정된 다른 MEDIA로 옮길 때, AI 배정을 지웠다가 다시 만들 필요가 없다. Client admin 전용. 요청 { "mediaServerId": "MEDIA-02" } 응답 { "result": true, "error": null, "data": { "aiServerId": "AI-02", "mediaServerId": "MEDIA-02" }, "serverTimeMs": 1780282800000 } 배정이 없으면 ASSIGNMENT_NOT_FOUND다. 그 카메라의 MEDIA 배정 목록에 없는 mediaServerId를 주면 ASSIGNMENT_NOT_FOUND다. [h4] POST /cameras/{cameraId}/ai-assignments/{aiServerId}/delete AI 배정을 해제한다. Client admin 전용. 필터가 없으므로 body는 빈 객체다. 배정이 없으면 ASSIGNMENT_NOT_FOUND를 반환한다. [h4] POST /servers/{serverId}/analysis-assignments/fetch AI가 자신의 분석 대상 카메라와, 각 카메라를 어느 MEDIA에서 받을지를 조회한다. AI actorType이 자기 serverId로만 호출한다 — 다른 serverId를 지정하면 FORBIDDEN이다. Web Server는 이 연결을 push하지 않는다 — AI가 로그인 직후와 주기적으로 다시 조회해 갱신한다. 필터가 없으므로 body는 빈 객체다. 요청 {} 응답 { "result": true, "error": null, "data": { "items": [ { "cameraId": "00000001", "mediaServerId": "MEDIA-01", "url": "wss://192.168.30.41:27000/ws", "connected": true }, { "cameraId": "00000005", "mediaServerId": "MEDIA-02", "url": "wss://192.168.30.42:27000/ws", "connected": false }, { "cameraId": "00000009", "mediaServerId": "MEDIA-03", "url": null, "connected": false } ] }, "serverTimeMs": 1780282800000 } url은 그 MEDIA의 WSS 제어 채널 주소다. AI는 자기 로그인 토큰으로 그대로 접속해 streaming.streams.info(Media Server API 7장)를 호출한다 — RTSP 경로는 이 응답에 싣지 않는다. connected는 그 MEDIA의 현재 제어 채널 연결 상태다(7장 /servers/list와 같은 값). false면 접속을 미뤄도 된다. url이 null이면 그 MEDIA가 한 번도 로그인한 적이 없어 주소를 아직 모르는 것이다(이때 connected는 항상 false) — AI는 이 카메라의 배정을 그대로 유지한 채 연결만 미루고, 다음 조회에서 url이 채워지면 그때 접속한다. 목록에서 빠진 카메라는 AI 배정이 해제된 것이므로 분석을 멈춘다. mediaServerId 또는 url이 바뀐 카메라는 새 MEDIA(또는 새 주소)로 연결을 갱신한다. POST /cameras/{cameraId}/get CCTV 카탈로그의 단건 상세 조회다. 관리자 편집 화면이 폼을 채울 때 쓴다. Client admin 전용이며, 배정 여부와 무관하게 조회된다 — 아직 어떤 MEDIA에도 배정되지 않은 카메라도 조회할 수 있다. 응답 { "result": true, "error": null, "data": { "cameraId": "00000038", "cameraName": "1F Lobby", "groupId": "00000003", "ipAddress": "192.168.0.10", "httpPort": 80, "onvifPort": 80, "onvifVideoSourceToken": null, "streams": [ { "trackType": 0, "rtspPort": 554, "rtspUrl": "/stream/main" }, { "trackType": 1, "rtspPort": 554, "rtspUrl": "/stream/sub" } ], "auth": { "deviceId": "admin" }, "encryptedRtsp": { "enabled": false, "id": "" } }, "serverTimeMs": 1780282800000 } 비밀번호(auth.devicePw, encryptedRtsp.pw)는 절대 반환하지 않는다 — includeSecrets 옵션 자체가 없다. 비밀번호까지 포함한 전체 복원용 조회는 POST /servers/{serverId}/cameras/fetch(includeSecrets: true, MEDIA 전용)뿐이다. 없는 cameraId면 CAMERA_NOT_FOUND다. 이어서 POST /cameras/{cameraId}/update로 수정할 때, 이 응답엔 비밀번호가 없으므로 편집 폼에서 비밀번호 칸을 비워 두면 그 필드를 요청에서 아예 생략해야 한다. 빈 문자열이나 null을 보내면 기존 비밀번호가 지워진다 — 생략 시 기존 값 유지 규칙은 update와 동일하다. POST /cameras/list 여러 Media Server의 CCTV를 통합해 반환한다. 필터가 없으면 body는 빈 객체다. groupId로 거르면 그 그룹의 카메라만 반환한다 — 화면의 "그룹 전체 선택" 같은 기능은 이 필터로 카메라 ID 목록을 얻어 구현한다(6.4 참고). 응답 { "result": true, "error": null, "data": { "total": 1, "items": [ { "cameraId": "00000038", "cameraName": "1F Lobby", "groupId": "00000003", "mediaAssignments": [ { "mediaServerId": "MEDIA-01", "status": "assigned", "ingestStatus": "online", "recordingStatus": "recording" }, { "mediaServerId": "MEDIA-02", "status": "assigned", "ingestStatus": "online", "recordingStatus": "recording" } ], "aiAssignments": [ { "aiServerId": "AI-01" } ], "permissions": { "playbackAllowed": true, "exportAllowed": false, "ptzAllowed": true, "aiEventsAllowed": false } } ] }, "serverTimeMs": 1780282800000 } groupId는 카메라 자체의 속성이라(9.1) 배정과 무관하게 하나뿐이며, mediaAssignments 항목에는 더 이상 없다. mediaAssignments/aiAssignments는 배정이 없으면 빈 배열이다. 각 항목의 ingestStatus/recordingStatus는 cameras.runtimeStatus.list에서 그 배정 기준으로 회수한 값이며 Web Server DB에 영구 저장하지 않는다. 9.1의 CCTV 설정은 이 응답에 싣지 않는다. 관제와 검색에 필요 없고, Client user도 호출하는 API라 접속 정보를 노출할 이유가 없다. 설정이 필요하면 POST /servers/{serverId}/cameras/fetch를 쓴다. Client user가 호출하면 소속 운영자 그룹에 배정된 카메라만 반환하고, 항목마다 이 계정이 속한 운영자 그룹의 permissions(6.3)를 동일하게 실어 화면에서 버튼을 미리 흐리게 표시할 수 있게 한다 — 그룹 하나에 권한 한 세트뿐이므로 모든 항목의 값이 같다. Client admin이 호출하면 배정과 무관하게 전체를 반환하며 제약이 없으므로 permissions는 싣지 않는다. POST /cameras CCTV를 카탈로그에 등록한다. Client admin 전용이며, MEDIA·AI 배정은 포함하지 않는다. 배정은 등록 후 9.4의 API로 따로 추가한다. 요청 { "cameraName": "1F Lobby", "groupId": "00000003", "ipAddress": "192.168.0.10", "httpPort": 80, "onvifPort": 80, "streams": [ { "trackType": 0, "rtspPort": 554, "rtspUrl": "/stream/main" }, { "trackType": 1, "rtspPort": 554, "rtspUrl": "/stream/sub" } ], "auth": { "deviceId": "admin", "devicePw": "Admin11!!" }, "encryptedRtsp": { "enabled": false, "id": "", "pw": "" } } 응답 { "result": true, "error": null, "data": { "cameraId": "00000038" }, "serverTimeMs": 1780282800000 } 9.1의 CCTV 설정을 받는다. 누락값을 빈 값, 80, 554, admin 등의 기본값으로 보충하지 않는다. 하나라도 빠졌거나 잘못되면 cameraId를 발급하기 전에 INVALID_PARAMS로 거절하며, 확인할 필드는 details.missingFields 또는 details.invalidFields에 반환한다. 예외는 onvifVideoSourceToken뿐이다 — 이 필드만 생략·null이 허용된다(9.1). 이 호출은 MEDIA·AI 어디에도 아직 아무것도 push하지 않는다. 배정을 만들기 전까지는 Web Server DB에만 존재하는 CCTV다. ipAddress 중복 등록을 막지 않는다. 같은 물리 카메라를 서로 다른 MEDIA가 각자 수신하는 이중화 구성, NVR 한 대 뒤의 채널들, 카메라 교체로 다른 장비가 같은 IP를 넘겨받는 경우가 전부 "같은 IP, 다른(또는 같은) 카메라"의 정상 상황이기 때문이다. 이미 같은 ipAddress로 등록된(삭제되지 않은) 카메라가 있어도 거부하지 않고 그대로 새 cameraId를 발급한다 — 중복 여부 판단은 관리자의 몫이며, 12.1의 검색 결과가 참고용으로 기존 cameraId를 함께 보여준다. cameraName은 삭제되지 않은 카메라 사이에서 전역 UNIQUE다(앞뒤 공백 제거, 대소문자 구분 안 함). 겹치면 CAMERA_NAME_CONFLICT(409)로 거부한다. 이 규칙이 카탈로그 단계에서 유일을 보장하므로 MEDIA 배정 때 따로 이름을 검사하지 않는다. groupId는 등록 시 필수다. 생략하거나 null이면 INVALID_PARAMS다. 존재하지 않는 groupId면 GROUP_NOT_FOUND다. [h3] POST /cameras/{cameraId}/update · POST /cameras/{cameraId}/delete update는 9.1의 CCTV 설정(카탈로그 필드) 중 바꿀 항목만 보낸다. 요청을 검증한 뒤 Web Server DB에 먼저 저장하고, 이 CCTV의 모든 MEDIA 배정에 camera-assignments.update로 즉시 반영을 시도한다 — 연결이 없거나 실패해도 저장은 그대로 유지되며 되돌리지 않는다. 반영은 재접속·주기적 동기화(9.3)가 이어서 맞추고, 실패 원인은 서버 로그에 남는다(화면에는 노출하지 않는다). delete는 body가 빈 객체다. 보내지 않은 필드는 기존 값을 유지한다. groupId에는 아래의 별도 규칙을 적용한다. 비밀번호를 바꾸지 않으려면 auth.devicePw를 아예 생략한다. auth.devicePw에 null을 보내면 빈 값으로 덮어쓴다. ipAddress를 다른 카메라(삭제되지 않은)와 겹치게 바꿔도 거부하지 않는다 — POST /cameras와 같은 규칙이다. cameraName을 삭제되지 않은 다른 카메라와 겹치게 바꾸면 CAMERA_NAME_CONFLICT다 — POST /cameras와 같은 전역 UNIQUE 규칙이다. 수정 시 groupId를 생략하면 기존 그룹을 유지한다. null은 INVALID_PARAMS, 존재하지 않는 그룹은 GROUP_NOT_FOUND다. 다른 groupId를 지정하면 그룹 이동이다 — 배정 자체(수신·녹화)는 끊기지 않고, 새 그룹이 그 카메라의 MEDIA 배정 중 처음 보는 그룹이면 Web Server가 camera-groups.create를 먼저 반영한 뒤 camera-assignments.update로 새 groupId를 전달한다(Media Server API 5장). 배정이 하나라도 남아있으면 delete는 CAMERA_HAS_ASSIGNMENTS로 거부된다. 먼저 9.4로 모든 MEDIA·AI 배정을 해제해야 카탈로그에서 지울 수 있다. 배정 하나만 없애려면 delete가 아니라 media-assignments/{mediaServerId}/delete(또는 ai-assignments/{aiServerId}/delete)를 쓴다. delete는 group_id도 함께 NULL로 비운다. 그렇지 않으면 삭제된(카탈로그에서 빠진) 카메라가 그룹을 계속 참조하는 채로 남아, 그 그룹이 비어 보여도 실제로는 GROUP_HAS_CAMERAS로 삭제되지 않는 문제가 생긴다. [h3] 카메라 연결 테스트9.5 CCTV를 카탈로그에 등록하기 전, 관리자가 입력한 접속 정보로 실제 영상이 들어오는지 선택한 MEDIA를 통해 미리 확인한다. 선택한 MEDIA가 대신 접속하고 WebRTC로 송출하며 Web Server는 결과만 중계한다. 등록·DB 저장·그룹 배정·녹화는 하지 않는다 — 관리자가 확인 후 별도로 POST /cameras(9.2)를 눌러야 정식 등록된다. Client admin 전용이다. {serverId}는 테스트에 쓸 MEDIA다 — cameras/discover·cameras/probe(12.1)와 같은 자리다. [h4] POST /servers/{serverId}/camera-tests/create camera-tests.create(Media Server API 5장)를 릴레이해 임시 연결을 시작한다. 요청 { "ipAddress": "192.168.30.100", "trackType": 0, "rtspPort": 554, "rtspUrl": "/stream/main", "auth": { "deviceId": "admin", "devicePw": "Admin11!!" }, "encryptedRtsp": { "enabled": false, "id": "", "pw": "" } } 응답 { "result": true, "error": null, "data": { "testId": "00000001", "state": "CONNECTING", "whepUrl": null, "webRtcUrl": null, "expiresAtWallMs": 1780282860000 }, "serverTimeMs": 1780282800000 } 필드는 POST /cameras(9.1)의 카메라 설정과 같은 이름을 쓴다. cameraId·groupId·cameraName은 받지 않는다 — 아직 카탈로그에 없는 카메라다. ONVIF 확인은 12.1의 몫이다. 한 번에 트랙 하나만 테스트한다. main/sub 전환은 기존 테스트를 닫고 새로 만든다. Media Server 연결이 없으면 SERVER_UNREACHABLE, 부트스트랩 중이면 SERVER_NOT_READY다. 선택한 MEDIA의 스트리밍 기능(10장)이 꺼져 있거나 송출 주소가 미확정(0.0.0.0)이면 이 테스트는 의미가 없다. Web Server는 이 호출을 따로 막지 않고 MEDIA에 그대로 중계하며, 판단은 MEDIA가 한다(Media Server API 5장). 화면은 테스트 창을 열기 전에 POST /servers/{serverId}/streaming-config로 상태를 조회해, 꺼져 있으면 창을 열지 않고 "선택한 미디어서버의 스트리밍 기능이 꺼져 있습니다. 스트리밍 기능을 켜거나 별도 도구로 카메라 연결을 직접 확인해 주세요."를 안내한다. 스트리밍을 자동으로 켜거나 Web Server가 카메라에 직접 접속하는 방식으로 대체하지 않는다. 그 MEDIA 하나에 동시 1개까지다. 두 번째부터는 CAMERA_TEST_LIMIT_EXCEEDED다(Media Server API 5장). 카메라 접속은 10초, 접속 후 첫 영상 수신은 추가 5초 안에 안 되면 각각 CAMERA_CONNECT_TIMEOUT/CAMERA_VIDEO_TIMEOUT으로 실패한다 — 둘 다 고정값이며, 아래 60초 TTL 안에 포함된다. [h4] POST /servers/{serverId}/camera-tests/get READY 후 10초 안에 WebRTC가 붙지 않거나, 연결됐던 마지막 시청자의 종료가 감지되면 MEDIA가 테스트를 종료한다. get의 closeReason으로 종료 이유를 전달한다(Media Server API 5장). 미리보기 창 닫기·시청 중지 시 Client는 Web Server의 camera-tests/close를 호출하고 Web Server는 MEDIA로 중계한다. 네트워크 단절 감지에는 지연이 있을 수 있다. 조회는 MEDIA에 보관된 상태를 중계한다. MEDIA는 최근 32개 테스트 상태만 보관하며, 오래된 항목이나 WSS/Media 서버 재시작 전 항목은 CAMERA_TEST_NOT_FOUND다. 요청 { "testId": "00000001" } 응답 { "result": true, "error": null, "data": { "testId": "00000001", "state": "READY", "videoReceived": true, "codec": "H264", "width": 1920, "height": 1080, "previewAvailable": true, "whepUrl": "http://192.168.30.42:8888/whep/ivpm/test_00000001_0?token=9f2c7a1e4b8d3a05e7", "webRtcUrl": "http://192.168.30.42:8888/index/api/webrtc?app=ivpm&stream=test_00000001_0&type=play&token=9f2c7a1e4b8d3a05e7", "expiresAtWallMs": 1780282860000, "error": null }, "serverTimeMs": 1780282800000 } state는 CONNECTING/READY/FAILED/CLOSED/EXPIRED다(Media Server API 5장 참고). videoReceived(카메라 수신 성공)와 previewAvailable(브라우저 송출 가능)은 다른 신호다 — 송출을 못 만드는 경우 state: FAILED, error.code: CAMERA_PREVIEW_UNSUPPORTED이며, 수신은 됐는데 코덱 때문이면 videoReceived: true, 스트리밍 꺼짐 등으로 카메라에 접속하지 않았으면 videoReceived: false다. whepUrl/webRtcUrl은 브라우저가 기존 실시간 뷰어(11장)와 같은 방식으로 SDP를 교환해 재생하는 협상용 주소이며, 외부 접속 주소 치환(8장)을 적용한다. [h4] POST /servers/{serverId}/camera-tests/close 요청 { "testId": "00000001" } 테스트를 종료하고 카메라 수신·임시 송출을 정리한다. 창을 닫거나 다시 테스트를 누를 때 호출한다. 이미 종료된 테스트에 다시 호출해도 성공으로 처리하고 기존 상태·종료 사유·오류를 유지한다. 응답의 state는 해당 테스트의 최종 상태다(멱등). get/close 두 API는 없는 testId면 CAMERA_TEST_NOT_FOUND다(create는 testId를 새로 발급하므로 해당 없음). 테스트를 만든 계정 본인이거나 admin만 조회·종료할 수 있다 — 다른 계정이 만든 testId를 건드리면 FORBIDDEN이다. 녹화재생 세션(14장)과 같은 계정 단위 소유권 규칙이다. Web Server는 계정을 토큰으로 식별할 뿐 로그인 세션이나 브라우저 탭을 구분하지 않으므로, 같은 계정으로 로그인한 다른 탭·다른 로그인은 서로의 테스트를 그대로 조회·종료할 수 있다. 60초 TTL은 create 시점부터 고정이며, 연장할 방법이 없다(정책은 Media Server API 5장). create·get 응답의 expiresAtWallMs도 처음 정해진 종료 예정 시각 그대로이며, 영상을 보고 있거나 get을 반복 호출해도 바뀌지 않는다. 예: - 10:00:00 create → expiresAtWallMs는 10:01:00으로 고정 - 10:00:04 영상 수신 시작 - 10:00:50 get 호출 — expiresAtWallMs는 그대로 10:01:00 - 10:01:00 영상 수신·송출 강제 종료, state: EXPIRED 화면은 만료되면 "연결 테스트 시간이 종료되었습니다. 다시 테스트해 주세요."를 보여주고 끝낸다 — 자동으로 새 테스트를 다시 만들지 않는다. 자동 재생성을 하면 1분 제한이 사실상 없어지기 때문이다. 다시 보려면 사용자가 테스트를 다시 눌러 create를 새로 호출해야 한다. 비밀번호는 응답·로그 어디에도 남기지 않는다. [h3] 공통 그룹 관리9.6 그룹은 전역이며 서버와 무관하다. 등록·수정·삭제는 Client admin 전용이고, 목록 조회만 user에게도 연다. [h4] POST /groups/list 요청 {} 응답 { "result": true, "error": null, "data": { "items": [ { "groupId": "00000003", "groupName": "1F", "sortOrder": 0 }, { "groupId": "00000004", "groupName": "2F", "sortOrder": 1 } ] }, "serverTimeMs": 1780282800000 } Client user가 호출하면 소속 운영자 그룹에 배정된 카메라가 하나라도 속한 카메라 그룹만 반환한다(6.4). Client admin이 호출하면 전체를 반환한다. [h4] POST /groups Client admin 전용. 요청 { "groupName": "3F", "sortOrder": 2 } 응답 { "result": true, "error": null, "data": { "groupId": "00000006", "groupName": "3F", "sortOrder": 2 }, "serverTimeMs": 1780282800000 } 그룹은 카메라와 별도로 등록하며 소속 카메라가 없어도 생성할 수 있다. groupId는 항상 새로 발급된다. groupName이 이미 있는(삭제되지 않은) 다른 그룹과 같으면 GROUP_NAME_CONFLICT다 — 그룹은 전역이라 이름도 전역에서 겹치지 않아야 한다. [h4] POST /groups/{groupId}/update Client admin 전용. 지정한 필드만 반영한다. 요청 { "groupName": "3F East", "sortOrder": 3 } 응답 { "result": true, "error": null, "data": { "groupId": "00000006", "groupName": "3F East", "sortOrder": 3 }, "serverTimeMs": 1780282800000 } 이 그룹의 카메라가 배정된 모든 Media Server에 camera-groups.update로 즉시 반영을 시도한다(Media Server API 5장) — 연결이 없거나 실패해도 이 저장 자체는 성공이며, 반영은 재접속·주기적 동기화(9.3)가 이어서 맞춘다. groupName을 다른(삭제되지 않은) 그룹과 겹치게 바꾸면 GROUP_NAME_CONFLICT, 없는 groupId면 GROUP_NOT_FOUND다. [h4] POST /groups/{groupId}/delete Client admin 전용. 필터가 없으므로 body는 빈 객체다. 이 그룹에 속한 카메라가 하나라도 남아있으면 GROUP_HAS_CAMERAS로 거부된다 — 먼저 소속 카메라들을 다른 그룹으로 옮겨야 한다. 그룹 삭제는 카메라를 함께 삭제하거나 미분류로 만들지 않는다. 삭제 여부는 삭제되지 않은 소속 카메라를 기준으로 판단한다. 없는 groupId면 GROUP_NOT_FOUND다. [h2] 스트리밍 설정10 /streaming-config/server·/streaming-config/channels는 Client admin 전용이다. MEDIA·AI·Client user가 호출하면 FORBIDDEN이다. Media Server는 이 설정을 웹에 보고하지 않고, 조회(POST /servers/{serverId}/streaming-config)와 bootstrap으로 받아 적용만 한다. [h3] streamingBindIp 결정10.1 Media Server가 스트리밍 엔진을 바인딩할 주소이자 streaming.streams.info 등이 반환하는 URL의 host로 쓰는 값이다(7.1) — 두 용도를 겸하므로 접속 가능한 구체 IPv4여야 한다. 0.0.0.0은 "미확정" sentinel이다. 전체 NIC 바인딩이라는 통상적 의미로 쓰지 않는다. ZLM에 전달하지 않고 스트리밍 엔진을 시작하지 않는다. Web Server 관측 주소(controlIp)도 여기에 쓰지 않는다(7.1). 스트리밍 IP를 확정하지 못해도 로그인과 부트스트랩은 실패하지 않는다. CCTV·그룹 동기화와 녹화는 그대로 진행되고 스트리밍만 꺼진 채 시작한다. Media Server가 보내는 값 | 순서 | 조건 | 값 | 1 | 로컬 DB 값이 있고 현재 PC에 존재 (사용자가 화면에서 고른 값 포함) | 로컬 DB 값 | 2 | Web Server로 나가는 경로의 로컬 NIC IP | 그 IP (라우팅 테이블 기준) | 3 | 둘 다 실패 | 0.0.0.0 Media Server NIC 192.168.30.42 / 10.10.0.15, Web Server 10.10.0.100 → Web Server로 나가는 경로가 10.10.0.15 이므로 후보는 10.10.0.15 Web Server가 확정하는 값 | 저장된 값 | 로그인 후보 | 결과 | 없음 | 정상 IP | 초기화한다 | 없음 | 0.0.0.0 | 저장하고 enabled를 false로 내린다 | 정상 IP | 0.0.0.0 | 덮어쓰지 않는다 | 정상 IP | 다른 정상 IP | 덮어쓰지 않는다 | 0.0.0.0 | 정상 IP | 새 IP로 갱신한다 (복구 경로) 0.0.0.0이 정상 IP를 덮어쓰면 감지가 회복돼도 이전 값을 알 수 없으므로 금지한다. 응답 규칙 0.0.0.0이면 bootstrap과 POST /servers/{serverId}/streaming-config가 enabled를 false로 내려 반환하고, 관리 화면도 꺼짐으로 표시한다. POST /servers/{serverId}/streaming-config/server로는 0.0.0.0을 넣을 수 없다(INVALID_PARAMS). 끄려면 enabled: false를 쓴다. Media Server 적용 0.0.0.0이면 스트리밍 엔진을 시작하지 않는다. 정상 IP면 로컬 NIC 목록으로 미리 차단하지 말고 실제 바인딩을 시도해 성공 여부로 판정한다. 실패하면 스트리밍만 끄고 나머지 기능은 계속하며, 서버가 못 뜬 경우는 STREAMING_SERVER_START_FAILED, 일부 프로토콜만 실패면 STREAMING_PROTOCOL_DOWN으로 보고한다. 내부 주소와 외부 접속 주소가 다른 NAT 환경은 서버 설정이 아니라 Client 계정의 externalAccess로 처리한다(8장). streamingBindIp는 ZLM이 항상 바인딩하는 주소다. POST /servers/{serverId}/streaming-config POST /servers/{serverId}/bootstrap 응답에서 streaming만 반환한다. 그룹과 CCTV는 포함하지 않는다. 자기 Media Server 또는 Client admin이 호출한다. 필터가 없으므로 body는 빈 객체다. 응답에는 Web Server가 보관하는 desired 설정 전체가 실린다. 스트리밍 엔진 enabled streamingBindIp, rtspOutputMode RTSP/HTTP/RTC 포트 등록된 모든 CCTV track의 cameraId, trackType, streamId, enabled 설정 revision: 스트리밍 엔진 설정(/streaming-config/server)이 바뀔 때만 오른다. streaming.server.settings.put에 실려 MEDIA가 그대로 저장·반환한다. 채널 송출 상태(/streaming-config/channels)는 번호를 올리지 않고 streaming.channels.list 값으로 대조한다. 실제 실행 상태와 영상 URL은 Media Server runtime 책임이므로 이 API에 포함하지 않는다. streamingBindIp가 0.0.0.0이면 스트리밍 IP가 미확정이라는 뜻이다. 이때는 저장된 값과 무관하게 enabled를 false로 내려서 반환하고, 관리 화면도 꺼짐으로 표시한다. 10.1 참조. streamId는 스트리밍 엔진/ZLM의 실제 생성 규칙과 같은 camera_{숫자 cameraId}_{trackType} 형식이다. 8자리 cameraId의 앞쪽 0은 제거한다. 예를 들어 cameraId: "00000038", trackType: 0은 camera_38_0이다. POST /servers/{serverId}/streaming-config/server Client admin 전용이다. MEDIA·AI·Client user 요청은 FORBIDDEN이다. 서버 설정을 Web Server의 desired 설정에 먼저 저장하며 지정한 필드만 바뀐다. 요청 { "enabled": true, "streamingBindIp": "192.168.30.42", "rtspOutputMode": "plain", "ports": { "rtsp": 8554, "http": 8888, "rtc": 8000 } } streamingBindIp는 비루프백 IPv4여야 하며 0.0.0.0을 받지 않는다. 어겼으면 INVALID_PARAMS다. Web Server는 그 IP가 Media Server에 실제로 존재하는지 판단할 수 없으므로 형식만 검사하고, 존재 여부는 Media Server가 적용 시점에 확인한다. 웹 관리자가 저장한 설정을 MEDIA에 반영한다. Media Server 연결이 없어도 설정은 바꿀 수 있다 — Media Server WSS가 연결되어 있으면 streaming.server.settings.put으로 즉시 push하고, 성공 직후 streaming.server.status로 실제 기동 여부와 바인딩 포트를 확인해 runtime에 담아 반환한다(streamingToken은 제외). 연결이 없거나 Media Server가 거부해도 desired 값은 저장한 채 runtime을 null로 반환하고, 반영은 다음 연결 때 재시도한다. 반영 대기 여부와 실패 원인은 응답에 싣지 않고 서버 로그와 Media Server가 보고하는 상태·장애로 확인한다(streaming.server.status, Media Server API 17장). 응답 { "result": true, "error": null, "data": { "saved": true, "runtime": { "running": true, "bindIp": "192.168.30.42", "ports": { "rtsp": 8554, "http": 8888, "rtc": 8000 } } }, "serverTimeMs": 1780282800000 } Media Server가 연결돼 있지 않으면 이렇게 반환한다. 응답 { "result": true, "error": null, "data": { "saved": true, "runtime": null }, "serverTimeMs": 1780282800000 } 응답 { "result": false, "error": { "code": "FORBIDDEN", "message": "Only a Client admin may change the streaming configuration.", "retryable": false, "details": null }, "data": null, "serverTimeMs": 1780282800000 } POST /servers/{serverId}/streaming-config/channels CCTV track별 송출 desired 상태를 일괄 저장한다. 서버 설정 API와 같이 Client admin 전용이다. 요청 { "items": [ { "cameraId": "00000038", "trackType": 0, "enabled": true }, { "cameraId": "00000038", "trackType": 1, "enabled": false } ] } cameraId와 trackType은 CCTV의 streams[]에 존재해야 한다. 생략된 채널은 기존 값을 유지한다. CCTV를 새로 등록해 아직 채널 값이 없으면, 그 Media Server 스트리밍 엔진의 desired enabled를 따라 초기값을 정한다 — 서버가 켜진 상태로 등록돼 있으면 true, 꺼진 상태면 false다. WSS 연결 후 status.get이 READY가 되면 CCTV·그룹 → 채널 상태 → 스트리밍 엔진 설정 순서로 적용한다. READY 전에는 SERVER_NOT_READY로 거절된다. Client admin 전용이며 다른 계정은 FORBIDDEN이다. 저장 후 연결된 Media Server에 streaming.channels.setEnabled로 즉시 push한다. Media Server 연결이 없어도 저장은 된다 — desired 값만 갱신하고 saved: true로 반환하며 다음 연결 때 적용한다. 반영 대기 여부는 응답에 싣지 않는다. Managed Media Server 화면에서는 채널 송출 설정을 변경할 수 없고 웹에서 내려온 설정만 반영한다. 응답 { "result": true, "error": null, "data": { "saved": true }, "serverTimeMs": 1780282800000 } [h3] 스트리밍 엔진 기동 후 동기화 Media Server가 스트리밍 엔진을 시작하거나 재시작할 때 streamingToken을 발급하고, streaming.streams.info가 반환하는 URL 뒤에 토큰을 붙인다. 토큰과 완성 URL은 재시작할 때 바뀌는 runtime 값이므로 Web Server DB에 동기화하거나 영구 저장하지 않는다. 동작 순서는 다음과 같다. Web Server가 status.get으로 READY를 확인한 뒤 desired 설정과 채널 상태를 WSS로 적용한다. Web Server가 streaming.server.status로 실제 실행 상태와 바인딩 포트를 확인한다. Client가 실시간 URL을 요청하면 Web Server가 그때 streaming.streams.info를 호출해 토큰이 붙은 최신 URL을 전달한다. 스트리밍 엔진 재시작 또는 MEDIA_TOKEN_ROTATED 이후 기존 URL이 401이면 Client는 Web Server의 live API를 다시 호출한다. 따라서 스트리밍 엔진이 기동할 때 토큰이나 URL을 Web Server REST API로 push하는 별도 동기화 API는 없다. 실행 상태는 WSS 조회, 접속 URL은 요청 시 조회로 나눈다. [h2] 실시간 스트리밍11 Client는 Media Server 제어 채널에 접근하지 않는다. Web Server가 사용자 토큰을 검증하고 자신의 서비스 토큰으로 Media Server를 호출한 뒤 결과만 전달한다. Client 계정(권한 무관)에 허용한다 — 조회이지 관리가 아니다(6.1 참고). user는 소속 운영자 그룹에 배정된 카메라(6.4)에 한해 호출할 수 있다 — 그룹 배정에 없으면 FORBIDDEN이다. 별도 liveAllowed 같은 필드는 없다 — 배정 자체가 실시간 허용이다. POST /cameras/{cameraId}/live streaming.streams.info를 중계해 실시간 접속 URL과 트랙별 startWallTimeMs, rtpClockRate를 함께 반환한다. Web Server는 시간 기준값을 변환하지 않는다. startWallTimeMs는 기준 프레임 수신 시각에서 그 프레임의 RTP 시간을 미리 뺀 계산 기준값이며, 수신측은 각 프레임의 RTP 시간을 더한다. 세부 계산과 wrap·재연결 계약의 미확정 범위는 Media Server API 7장 시간 기준을 따른다. URL에는 Media Server가 발급한 streamingToken이 이미 붙어 있다. 외부 접속 주소 치환은 8장을 따른다. 요청 { "mediaServerId": "MEDIA-01" } mediaServerId는 필수다 — CCTV가 MEDIA 여러 대에 배정될 수 있어 어느 MEDIA의 실시간 영상을 볼지 반드시 지정한다. 배정 목록에 없으면 ASSIGNMENT_NOT_FOUND다. Media Server가 재시작하면 streamingToken이 바뀌어 이전 URL이 401이 된다. Web Server는 MEDIA_TOKEN_ROTATED 로그를 받거나 Client가 401을 보고하면 재조회해 전달한다. POST /cameras/{cameraId}/ai-stream CCTV에 배정된 AI마다 실시간 AnchorBox를 WSS로 내려주는 접속 정보를 중계한다. CCTV 한 대에 AI가 여러 대 배정될 수 있으므로(9.4) 배열로 반환한다. Web Server는 AI가 발급한 URL·Token을 그대로 전달만 하고 내용을 해석하지 않으며, Client는 각 값으로 해당 AI 서버에 직접 WSS 연결한다(ARCH 3-2, 4-5 참고). 필터가 없으므로 body는 빈 객체다. 요청 {} 응답 { "result": true, "error": null, "data": { "items": [ { "aiServerId": "AI-01", "url": "wss://192.168.0.30:28100/anchorbox/v1", "token": "c93a1f77e2b6485d90" } ] }, "serverTimeMs": 1780282800000 } url·token의 실제 형식(경로, 발급·검증 방식)은 AI 서버 쪽 사양을 따른다. 이 문서는 Web Server가 무엇을 중계하는지만 정의한다. 외부 접속 주소 치환은 8장을 따른다. CCTV에 배정된 AI가 없으면 items가 빈 배열이다. AI_NOT_ASSIGNED는 반환하지 않는다 — 배정이 없는 것과 오류를 구분한다. [h2] ONVIF12 Media Server가 카메라와 ONVIF로 통신해 제공하는 세 기능 — 카메라 검색·기능 조회, PTZ, 이미징 — 을 중계한다. Media Server API 8장과 짝을 이룬다. requestedBy가 필요한 action은 없다 — 이 장은 전부 조작·조회이며 결과 기록은 감사 로그로 남는다. [h3] 카메라 검색 · 기능 조회12.1 POST /cameras로 등록하기 전 후보를 찾는 검색·프로브는 Client admin 전용이다(Media Server API 8.1). 이미 등록된 카메라의 기능 조회(capabilities, onvif/status)는 조회이므로 Client(권한 무관)에 허용한다. 다만 user는 소속 운영자 그룹에 배정된 카메라만 호출할 수 있고, 아니면 FORBIDDEN이다. cameraId가 카메라의 유일한 신원이다. ipAddress는 등록 여부를 확정하지 않고 참고용 힌트만 준다. 검색·재확인 결과의 ipAddress와 같은(삭제되지 않은) 등록 카메라가 있으면 그 cameraId를 함께 담아 화면에서 "이미 등록된 것 같습니다"로 보여준다 — 포트·자격증명·MAC·MEDIA 배정 여부는 이 힌트에 영향을 주지 않는다. 삭제된 카메라는 비교에서 제외한다. 이 힌트는 등록을 막지 않는다 — POST /cameras(9.1)는 같은 ipAddress로도 새 cameraId를 발급한다. 중복인지, 이중화·NVR 채널처럼 의도된 것인지는 관리자가 판단한다. [h4] POST /servers/{serverId}/cameras/discover cameras.discover를 중계해 그 MEDIA가 물린 네트워크에서 ONVIF 카메라를 찾고, 함께 입력한 공통 자격증명으로 각 장치의 인증·등록에 필요한 정보까지 한 번에 확인한다. NVR·스트리밍서버처럼 한 장치가 채널을 여러 개 가지면 장치 하나가 아니라 **채널 하나당 항목 하나**로 나온다 — 보통의 단일 채널 IP 카메라는 지금처럼 장치당 항목 하나다. 요청 { "timeoutMs": 3000, "auth": { "deviceId": "admin", "devicePw": "Admin11!!" } } 응답 { "result": true, "error": null, "data": { "items": [ { "ipAddress": "192.168.30.100", "onvifPort": 80, "xaddr": "http://192.168.30.100/onvif/device_service", "model": "XNO-6120R", "macAddress": "00:11:22:33:44:55", "videoSourceToken": null, "authStatus": "AUTHENTICATED", "streams": [ { "trackType": 0, "rtspPort": 554, "rtspUrl": "/stream/main" }, { "trackType": 1, "rtspPort": 554, "rtspUrl": "/stream/sub" } ], "error": null, "cameraId": "00000038" }, { "ipAddress": "192.168.30.101", "onvifPort": 80, "xaddr": "http://192.168.30.101/onvif/device_service", "model": null, "macAddress": null, "videoSourceToken": null, "authStatus": "AUTH_FAILED", "streams": [], "error": { "code": "AUTH_FAILED", "message": "카메라 인증에 실패했습니다." }, "cameraId": null } ] }, "serverTimeMs": 1780282800000 } Media Server 연결이 없으면 SERVER_UNREACHABLE이다. authStatus는 AUTHENTICATED/AUTH_FAILED/UNKNOWN(시간 초과 등으로 확인 못함) 중 하나다. 카메라 한 대가 실패해도 이 API 전체는 성공으로 응답하며, 그 항목의 error에만 원인이 담긴다. macAddress는 확인되면 값을, 못하면 null을 반환한다 — MAC을 못 얻은 것을 인증 실패로 취급하지 않는다. authStatus: "AUTHENTICATED"이면서 macAddress: null인 경우가 정상적으로 있을 수 있으며, 이때는 화면에 "알 수 없음"으로 표시한다. streams는 authStatus가 AUTHENTICATED이고 스트림 조회까지 성공했을 때만 채워진다. 인증은 성공했지만 스트림 조회가 실패하면 authStatus: "AUTHENTICATED"를 유지한 채 streams는 빈 배열, error.code는 STREAM_UNAVAILABLE이다 — 비밀번호가 바뀐 경우(AUTH_FAILED)와는 다른 원인이므로 화면에서 구분해 보여준다. videoSourceToken은 ONVIF VideoSource 토큰이며, 이 장치가 여러 채널을 가진 경우에만 값이 있고, 단일 채널 카메라는 null이다. 등록할 때 onvifVideoSourceToken(9.1)에 그대로 넣으면 Media Server가 PTZ·이미징 명령을 보낼 채널을 구분한다 — 등록 여부 판단에는 쓰지 않는다. cameraId는 Web Server가 채워 넣는 참고용 값이다. ipAddress가 같은 삭제되지 않은 등록 카메라가 있으면 그 cameraId를, 없으면 null을 담는다. 같은 IP로 여러 대가 등록돼 있으면 가장 먼저 등록된(createdAt이 가장 이른) 1대만 담는다 — 포트·자격증명·MAC·MEDIA 배정 여부와 무관하게 IP가 같은 카메라가 이미 있다는 힌트일 뿐이다(9.1). 인증 상태와는 별개 신호이므로 cameraId가 채워져 있으면서 authStatus: "AUTH_FAILED"(예: 카메라 비밀번호가 바뀐 경우)도 있을 수 있다. 화면에서 "등록되어 있음"을 안내만 하고, 그래도 등록을 막지는 않는다 — 기존 카메라로 가려면 cameraId로 편집 화면(9.4 /cameras/{cameraId}/get)으로, 새로 등록하려면 그대로 POST /cameras를 호출한다. 비밀번호는 응답에 싣지 않는다. 이 응답만으로는 등록되지 않는다 — 확인 후 POST /cameras를 따로 호출한다. [h4] POST /servers/{serverId}/cameras/probe 검색 결과 중 인증에 실패했거나 확인하지 못한 카메라 한 대를 고친 자격증명으로 다시 확인한다. cameras.probe를 중계하며, 응답은 cameras/discover 결과 항목 하나와 같은 형식이다. 요청 { "ipAddress": "192.168.30.101", "onvifPort": 80, "xaddr": "http://192.168.30.101/onvif/device_service", "videoSourceToken": null, "auth": { "deviceId": "admin", "devicePw": "Corrected11!!" } } 응답 { "result": true, "error": null, "data": { "ipAddress": "192.168.30.101", "onvifPort": 80, "xaddr": "http://192.168.30.101/onvif/device_service", "model": "XNO-6120R", "macAddress": "00:11:22:33:44:66", "videoSourceToken": null, "authStatus": "AUTHENTICATED", "streams": [ { "trackType": 0, "rtspPort": 554, "rtspUrl": "/stream/main" }, { "trackType": 1, "rtspPort": 554, "rtspUrl": "/stream/sub" } ], "error": null, "cameraId": null }, "serverTimeMs": 1780282800000 } xaddr은 선택 필드다. 검색 결과의 값을 그대로 넘기면 재탐색 없이 접속한다. 등록 설정에 새로 저장하는 필드가 아니다 — 재확인에만 쓰고 버린다. videoSourceToken은 선택 필드다. 다채널 장치의 특정 채널을 재확인할 때 검색 결과의 값을 그대로 넘긴다 — 생략하면 단일 채널 장치로 간주한다. authStatus가 AUTHENTICATED가 아니어도 이 API 자체는 성공으로 응답하며, 원인은 error에 담긴다 — 여전히 자격증명이 틀리면 AUTH_FAILED, 장치에 연결할 수 없으면 UNKNOWN(error.code: "SERVER_UNREACHABLE")이다. 인증엔 성공했지만 스트림 조회가 실패하면 authStatus: "AUTHENTICATED"에 streams: [], error.code: "STREAM_UNAVAILABLE"이다(discover와 같은 규칙). Media Server 연결 자체가 없으면 이 API 호출 자체가 SERVER_UNREACHABLE이다. cameraId는 cameras/discover와 같은 규칙(9.1)으로 채운다. Web은 이 응답으로 검색 결과의 해당 행만 교체하고, 최종 성공한 자격증명을 등록에 사용한다. [h4] POST /cameras/{cameraId}/capabilities/get cameras.capabilities.get을 중계해 등록된 카메라의 ONVIF 기능별 지원 여부를 반환한다. 요청 { "mediaServerId": "MEDIA-01" } 응답 { "result": true, "error": null, "data": { "ptz": { "status": "supported", "continuousMove": true, "pan": true, "tilt": true, "zoom": true }, "imaging": { "dayNight": { "status": "supported" }, "focus": { "status": "supported", "auto": true, "manual": true } }, "checkedAtWallMs": 1780282800000 }, "serverTimeMs": 1780282800000 } mediaServerId는 필수다 — 배정 목록에 없으면 ASSIGNMENT_NOT_FOUND다. [h4] POST /cameras/{cameraId}/capabilities/refresh cameras.capabilities.refresh를 중계해 장치에서 지원 기능을 다시 조회한다. 요청 { "mediaServerId": "MEDIA-01" } 응답 { "result": true, "error": null, "data": { "checkedAtWallMs": 1780282800000 }, "serverTimeMs": 1780282800000 } [h4] POST /cameras/{cameraId}/onvif/status/get cameras.onvif.status.get을 중계해 ONVIF 연결·인증 상태와 최근 오류를 반환한다. 요청 { "mediaServerId": "MEDIA-01" } 응답 { "result": true, "error": null, "data": { "connected": true, "authenticated": true, "lastCapabilitiesCheckAtWallMs": 1780282800000, "lastError": null }, "serverTimeMs": 1780282800000 } [h3] PTZ12.2 admin은 항상 열려 있다. user는 그 카메라가 소속 운영자 그룹에 배정돼 있고 그 그룹의 ptzAllowed(6.3)가 true일 때만 호출할 수 있고, 아니면 FORBIDDEN이다. [h4] POST /cameras/{cameraId}/ptz ptz.move(Media Server API 8.2절)를 그대로 릴레이한다. 연속 이동만 지원하며, 마우스를 누르는 동안 이동 요청을 짧은 간격으로 반복 전송하고, 떼는 순간 pan/tilt/zoom이 전부 0인 정지 요청을 보낸다. 요청 { "mediaServerId": "MEDIA-01", "pan": 0.5, "tilt": 0.0, "zoom": 0.0, "timeoutMs": 1000 } 응답 { "result": true, "error": null, "data": { }, "serverTimeMs": 1780282800000 } mediaServerId는 필수다 — CCTV가 MEDIA 여러 대에 배정될 수 있어 어느 MEDIA가 CCTV를 움직일지 반드시 지정한다. 배정 목록에 없으면 ASSIGNMENT_NOT_FOUND다. pan/tilt/zoom은 -1.0~1.0의 방향·속도값이며, 셋 다 0이면 정지 명령이다. 양수 pan은 오른쪽, 양수 tilt는 위쪽, 양수 zoom은 줌인 방향이다. 응답·에러는 MEDIA의 ptz.move 결과를 그대로 전달한다. timeoutMs는 안전장치다. 생략하면 1000(1초). 이 시간 안에 새 요청이 없으면 MEDIA가 자동으로 정지시킨다 — timeoutMs의 3분의 1 이하 간격으로 반복 호출한다. 그 MEDIA 연결이 없으면 SERVER_UNREACHABLE을, PTZ를 쓸 수 없으면 MEDIA가 반환한 PTZ_NOT_SUPPORTED(details.reason으로 원인 구분)나, 아직 지원 여부를 확인하지 못했으면 ONVIF_UNAVAILABLE을, 다채널 장치에서 대상 채널을 특정할 수 없으면 ONVIF_CHANNEL_UNRESOLVED를 그대로 전달한다. 이동 요청이 ONVIF_OPERATION_CANCELLED(retryable: false)로 취소돼도 마찬가지로 그대로 전달하며, Web Server는 취소된 이동을 자동으로 다시 보내지 않는다 — 계속 움직이려면 Client가 새 ptz 호출을 보낸다. Web Server API는 연속 이동(ptz.move)만 중계한다 — 프리셋·홈 포지션 중계 API는 아직 정의하지 않았고(MEDIA에는 확장 action이 있다, Media Server API 8.4), 위치·이동 상태 조회 API는 두지 않는다. [h3] 이미징12.3 주야간·포커스 조작도 PTZ와 같은 소속 운영자 그룹의 ptzAllowed(6.3)를 공유한다 — 별도 플래그를 두지 않는다. admin은 항상 열려 있고, user는 소속 그룹 배정과 ptzAllowed가 필요하다. [h4] POST /cameras/{cameraId}/imaging/dayNight/get imaging.dayNight.get을 릴레이해 주간·야간·자동 설정을 반환한다. 요청 { "mediaServerId": "MEDIA-01" } 응답 { "result": true, "error": null, "data": { "mode": "auto" }, "serverTimeMs": 1780282800000 } [h4] POST /cameras/{cameraId}/imaging/dayNight/set imaging.dayNight.set을 릴레이해 주간·야간·자동 설정을 변경한다. 요청 { "mediaServerId": "MEDIA-01", "mode": "night" } 응답 { "result": true, "error": null, "data": { "mode": "night" }, "serverTimeMs": 1780282800000 } [h4] POST /cameras/{cameraId}/imaging/focus/get imaging.focus.get을 릴레이해 포커스 모드와 현재 값을 반환한다. 요청 { "mediaServerId": "MEDIA-01" } 응답 { "result": true, "error": null, "data": { "mode": "auto", "position": 0.6 }, "serverTimeMs": 1780282800000 } [h4] POST /cameras/{cameraId}/imaging/focus/set imaging.focus.set을 릴레이해 포커스 모드를 바꾸거나 초점 위치를 직접 지정한다. 요청 { "mediaServerId": "MEDIA-01", "mode": "manual", "position": 0.4 } 응답 { "result": true, "error": null, "data": { "mode": "manual", "position": 0.4 }, "serverTimeMs": 1780282800000 } [h4] POST /cameras/{cameraId}/imaging/focus/move imaging.focus.move를 릴레이해 연속 포커스 이동을 시작한다. 요청 { "mediaServerId": "MEDIA-01", "speed": 0.5 } 응답 { "result": true, "error": null, "data": { }, "serverTimeMs": 1780282800000 } speed는 -1.0~1.0이며 0은 허용하지 않는다. 정지는 .../focus/stop을 쓴다. [h4] POST /cameras/{cameraId}/imaging/focus/stop imaging.focus.stop을 릴레이해 포커스 이동을 멈춘다. 요청 { "mediaServerId": "MEDIA-01" } 응답 { "result": true, "error": null, "data": { }, "serverTimeMs": 1780282800000 } 이 절의 모든 API는 mediaServerId가 필수다. 배정 목록에 없으면 ASSIGNMENT_NOT_FOUND다. 그 MEDIA 연결이 없으면 SERVER_UNREACHABLE을, 이미징을 쓸 수 없으면 MEDIA가 반환한 IMAGING_NOT_SUPPORTED(details.reason으로 원인 구분)나, 아직 지원 여부를 확인하지 못했으면 ONVIF_UNAVAILABLE을, 다채널 장치에서 대상 채널을 특정할 수 없으면 ONVIF_CHANNEL_UNRESOLVED를 그대로 전달한다. [h2] 녹화13 POST /cameras/{cameraId}/recordings recordings.ranges.get을 중계해 녹화 구간과 gap을 반환한다. Client 계정(권한 무관)에 허용한다 — user는 그 카메라가 소속 운영자 그룹에 배정돼 있고 그 그룹의 playbackAllowed(6.3)가 true일 때만 호출할 수 있고, 아니면 FORBIDDEN이다. mediaServerId와 trackType(0=main, 1=sub)은 필수이며 생략하면 INVALID_PARAMS다 — CCTV 한 대가 MEDIA 여러 대에 배정될 수 있어(9.4) MEDIA마다 녹화 구간이 다르므로, 어느 MEDIA의 녹화를 볼지 반드시 지정한다. mediaServerId가 이 CCTV의 배정 목록에 없으면 ASSIGNMENT_NOT_FOUND다. POST /servers/{serverId}/recording-schedules/fetch Web Server가 보관하는 CCTV별 녹화 스케줄 설정(desired)을 반환한다. Media Server에 묻지 않으므로 MEDIA가 꺼져 있어도 응답하며, bootstrap의 recordingSchedules와 같은 값이다. Client admin 전용이다. 필터가 없으므로 body는 빈 객체다. 요청 {} 응답 { "result": true, "error": null, "data": { "revision": 13, "timeZoneId": "Asia/Seoul", "items": [ { "cameraId": "00000001", "mode": "weekly", "periods": [ { "daysOfWeek": [1, 2, 3, 4, 5], "startMinuteOfDay": 480, "endMinuteOfDay": 1200 } ] } ] }, "serverTimeMs": 1788289200000 } POST /servers/{serverId}/recording-schedules CCTV별 녹화 스케줄을 변경한다. 설정과 revision은 Web Server가 소유한다. 관리자 화면이 보낸 expectedRevision을 Web Server의 revision과 비교해 다르면 SCHEDULE_REVISION_CONFLICT로 거절하고, 같으면 저장하며 revision을 올린다. 그 뒤 MEDIA에는 이 MEDIA의 스케줄 전체를 올린 번호와 함께 recording.schedules.put으로 push한다(Media Server API 10장). MEDIA에 남은 옛 스케줄도 이 전체 교체로 정리된다. Media Server 연결이 끊겨도 마지막으로 저장된 스케줄로 계속 동작한다. Client admin만 변경할 수 있으며 MEDIA·AI·Client user 요청은 FORBIDDEN이다. CCTV의 MEDIA 배정이 해제되면(9.4) Web Server는 그 MEDIA의 스케줄 설정에서 해당 CCTV 항목만 지우고 revision은 올리지 않는다 — 스케줄을 실제로 바꾼 게 아니라 대상이 없어진 것이기 때문이다. 배정 해제는 웹도 MEDIA도 번호를 올리지 않으므로 번호가 어긋나지 않는다. 그룹 삭제(9.6)는 카메라 배정과 무관하므로 이 규칙과 상관없다. 요청 { "expectedRevision": 12, "timeZoneId": "Asia/Seoul", "items": [ { "cameraId": "00000001", "mode": "weekly", "periods": [ { "daysOfWeek": [1, 2, 3, 4, 5], "startMinuteOfDay": 480, "endMinuteOfDay": 1200 } ] } ] } Media Server 연결이 없어도 스케줄은 바꿀 수 있다. Web Server DB에 desired로 먼저 저장한 뒤, Media Server WSS가 연결되어 있으면 recording.schedules.put으로 즉시 전달한다. 응답 { "result": true, "error": null, "data": { "saved": true, "revision": 13 }, "serverTimeMs": 1788289200000 } Media Server가 연결돼 있지 않아도 Web Server DB에 저장하고 같은 응답을 반환한다. 반영은 다음 연결 때 이어지며, 반영 대기 여부와 실패 원인은 응답에 싣지 않는다. MEDIA·AI·Client user가 호출하면 FORBIDDEN이다. 조회(/recording-schedules/fetch)도 Client admin 전용이다 — Media Server는 자기 스케줄을 로그인 직후 bootstrap과 WSS recording.schedules.list로 받으므로 이 조회 API를 쓰지 않는다. [h2] 녹화재생 · 내보내기14 [h3] 녹화재생 세션 CCTV 한 대의 녹화를 HLS로 녹화재생한다. 실제 HLS manifest·segment는 Web Server를 거치지 않는다 — hlsUrl에 recplayId가 이미 실려 있어서 브라우저가 MEDIA의 recplayHttpPort에 직접 붙는다. 외부 접속 주소 치환은 8장을 따른다. Client 계정(권한 무관)에 허용한다 — user는 그 카메라가 소속 운영자 그룹에 배정돼 있고 그 그룹의 playbackAllowed(6.3)가 필요하다. 이 검사는 세션을 새로 만들 때만 한다. .../get·.../refresh·.../close는 이미 아는 sessionId로 호출하는 것이므로 이 카메라 권한을 다시 보지 않는다 — 도중에 권한이 회수돼도 이미 시작한 녹화재생은 스스로 끄거나 만료될 때까지 이어진다. 카메라 권한을 재검사하지 않는 것과, 아무나 sessionId를 쓸 수 있는 것은 별개다. sessionId는 00000001처럼 MEDIA 안에서 순번으로 발급되는 값이라 추측·전수조사가 쉽다. Web Server는 create 성공 시 (mediaServerId, sessionId, cameraId, accountId)를 기록해 두고, .../get·.../refresh·.../close와 create의 replaceSessionId는 전부 그 세션을 만든 계정 본인이거나 admin일 때만 허용한다 — 아니면 FORBIDDEN이다(다른 사람의 세션인지 여부는 알려주지 않는다). Media Server 쪽에는 이 소유자 개념이 없으므로 Web Server가 자기 DB에서만 관리한다. mediaServerId는 네 호출 전부 필수다 — CCTV가 MEDIA 여러 대에 배정될 수 있고, sessionId는 MEDIA 안에서만 유일한 값이라 mediaServerId 없이는 Web Server가 어느 MEDIA에 물어야 할지 알 수 없다. 배정 목록에 없으면 ASSIGNMENT_NOT_FOUND, 연결이 없으면 SERVER_UNREACHABLE이다. [h4] POST /cameras/{cameraId}/recplay-sessions 녹화재생 세션을 만든다. 요청 { "mediaServerId": "MEDIA-01", "trackType": 0, "fromMs": 1780282800000, "timelineToMs": 1780283400000, "replaceSessionId": null } recplay-sessions.create(Media Server API 11장)를 릴레이하되, Web Server가 최대 2초 동안 MEDIA의 recplay-sessions.get을 대신 짧게 폴링해 PREPARING → READY 전이를 기다린 뒤 응답한다 — 대부분 1초 안팎에 끝나므로 Client가 매번 폴링할 필요가 없게 하기 위해서다. 2초 안에 끝나면 이미 녹화재생 가능한 상태로 내려온다. 응답 { "result": true, "error": null, "data": { "sessionId": "00000001", "state": "READY", "hlsUrl": "http://192.168.0.20:27001/recplay/c93a1f77e2b6485d90/index.m3u8", "windowFromMs": 1780282800000, "windowToMs": 1780283400000, "timelineToMs": 1780283400000, "seekOffsetMs": 0, "availableRanges": [ ], "gaps": [ ], "timelineMap": [ ], "expiresAtWallMs": 1780283460000 }, "serverTimeMs": 1780282800000 } 2초를 넘기면(드물게 디스크가 느리거나 MEDIA가 바쁠 때) 기다리지 않고 그 시점 상태 그대로 반환한다 — 이때는 state: "PREPARING"이며 Client가 .../get으로 마저 폴링한다. 응답 { "result": true, "error": null, "data": { "sessionId": "00000001", "state": "PREPARING", "hlsUrl": "...", "expiresAtWallMs": 0 }, "serverTimeMs": 1780282800000 } reused는 두 경우 모두 응답에 실리며, 나머지 필드 의미는 Media Server API 11장과 같다. [h4] POST /cameras/{cameraId}/recplay-sessions/get 세션 상태를 폴링한다. create가 2초를 못 채우고 PREPARING을 반환한 드문 경우에만 READY가 될 때까지 이걸로 확인한다 — 대부분은 create 응답이 이미 READY라 호출할 필요가 없다. 요청 { "mediaServerId": "MEDIA-01", "sessionId": "00000001" } [h4] POST /cameras/{cameraId}/recplay-sessions/refresh TTL을 연장한다. timelineToMs를 함께 보내면 녹화재생 가능 상한도 늘어난다(녹화 중인 CCTV를 새 세션 없이 이어보는 용도). 요청 { "mediaServerId": "MEDIA-01", "sessionId": "00000001", "timelineToMs": 1780283460000 } 일시정지 등으로 HLS 요청이 끊겼을 때만 Client가 30초 간격으로 호출한다(Media Server API 11장 참고). HLS가 계속 녹화재생 중이면 MEDIA가 자동으로 갱신하므로 호출할 필요 없다. [h4] POST /cameras/{cameraId}/recplay-sessions/close 세션을 종료한다. 요청 { "mediaServerId": "MEDIA-01", "sessionId": "00000001" } 없는 sessionId면 RECPLAY_SESSION_NOT_FOUND, PREPARING/EXPIRED/CLOSED/FAILED 상태에서 refresh를 부르면 RECPLAY_SESSION_STATE_INVALID다 — Media Server API 16장 에러코드를 그대로 전달한다. [h3] 내보내기 CCTV 최대 4대를 한 작업으로 묶어 MP4로 내보낸다. 한 MEDIA 안에서만 묶을 수 있어 카메라가 아니라 서버 밑에 둔다. requestedBy는 호출한 Client 계정의 accountId를 Web Server가 자동으로 채운다 — 요청 body에 넣지 않는다. Client 계정(권한 무관)에 허용하며 실제 MP4 다운로드는 downloadUrl로 MEDIA에 직접 접근한다(중계 없음). user는 estimate·생성 시 소속 운영자 그룹의 exportAllowed(6.3)가 true여야 하고 cameraIds 전부 소속 운영자 그룹에 배정돼 있어야 하며, 하나라도 아니면 FORBIDDEN이다. get·list·cancel은 카메라 권한을 다시 보지 않고 자기가 만든 작업(requestedBy가 자기 accountId)인지만 본다 — 작업 생성 뒤 권한이 바뀌거나 회수돼도 본인 작업은 계속 조회·취소할 수 있다. admin은 소유권과 무관하게 전부 조회·취소할 수 있다. [h4] POST /servers/{serverId}/recexports/estimate 요청 { "fromMs": 1780282800000, "toMs": 1780283100000, "trackType": 0, "cameraIds": ["00000038", "00000039"] } recexports.estimate를 릴레이해 estimatedBytes/estimatedElapsedMs/freeBytes를 그대로 반환한다. [h4] POST /servers/{serverId}/recexports 요청 { "fromMs": 1780282800000, "toMs": 1780283100000, "trackType": 0, "cameraIds": ["00000038", "00000039"], "encrypted": true, "password": "Export11!!", "validDays": 7 } 응답 { "result": true, "error": null, "data": { "recexportId": "00000001", "state": "QUEUED", "cameraCount": 2 }, "serverTimeMs": 1780282800000 } cameraIds는 전부 그 serverId에 배정된 CCTV여야 한다 — 하나라도 아니면 ASSIGNMENT_NOT_FOUND(MEDIA 배정 문제). user는 이에 더해 cameraIds 전부가 소속 운영자 그룹에 배정돼 있고 그 그룹의 exportAllowed가 true여야 하며, 아니면 FORBIDDEN(권한 문제)이다 — 검사 순서는 MEDIA 배정 확인이 먼저다. 나머지 검증(최대 4대, 구간 최대 5분 등)은 Media Server API 12장을 따른다. [h4] POST /servers/{serverId}/recexports/get 요청 { "recexportId": "00000001" } recexports.get을 릴레이해 state/progressPercent/items[](카메라·트랙별 downloadUrl)를 반환한다. 외부 접속 주소 치환은 8장을 따른다. [h4] POST /servers/{serverId}/recexports/list 진행 중이거나 아직 만료되지 않은 작업 목록. 필터가 없으므로 body는 빈 객체다. [h4] POST /servers/{serverId}/recexports/cancel 요청 { "recexportId": "00000001" } 없는 recexportId면 RECEXPORT_NOT_FOUND, 아직 완료 전 다운로드 시도는 RECEXPORT_NOT_READY다. [h2] AI 이벤트15 AI가 생성한 이벤트 정보는 Web Server DB에 저장하고, 이벤트 영상 파일은 AI Server에 남는다. Client는 Web Server에서 목록을 검색한 뒤 해당 AI의 재생 API를 직접 호출한다. POST /events AI가 생성한 이벤트를 저장한다. AI가 호출하는 API다. 요청 { "serverId": "AI-01", "events": [ { "eventId": "ai-gpu-01:20260819:000412", "mediaServerId": "MEDIA-01", "cameraId": "00000038", "ruleId": 7, "eventType": "intrusion", "detectedAtWallMs": 1780282795120, "score": 0.92, "clipUrl": "https://192.168.0.30:8443/events/000412/clip.mp4", "thumbnailUrl": "https://192.168.0.30:8443/events/000412/thumb.jpg", "detail": { "zone": "gate-a", "objectClass": "person" } } ] } 응답 { "result": true, "error": null, "data": { "accepted": 1, "duplicated": 0 }, "serverTimeMs": 1780282800000 } detectedAtWallMs는 Media Server 시간 기준으로 계산한 값이며 BIGINT epoch milliseconds 원본을 그대로 저장한다. AI 서버의 로컬 시계로 계산하지 않는다. eventId는 AI가 부여하며 전역 유일해야 한다. Web Server는 이 값으로 중복을 제거한다. cameraId는 mediaServerId와 함께 CCTV를 조회하는 키다 — 같은 CCTV가 여러 MEDIA에 배정될 수 있으므로 cameraId만으로 찾지 않는다. mediaServerId는 어느 MEDIA 스트림에서 감지했는지 남기는 감사용 값이다. 이 cameraId의 AI 배정 목록(9.4)에 요청의 serverId가 없으면 AI_NOT_ASSIGNED로 거부한다 — CCTV 한 대에 AI가 여러 대 배정될 수 있으므로 1:1 비교가 아니라 목록 포함 여부로 판단한다. 한 요청의 events는 최대 500건이다. clipUrl은 AI Server의 주소다. Web Server는 저장만 하고 파일을 가져오지 않는다. POST /events/list Client가 이벤트를 검색한다. 필터가 없으면 body는 빈 객체다. 요청 { "cameraId": "00000038", "fromMs": 1780196400000, "toMs": 1780282800000 } 응답 { "result": true, "error": null, "data": { "total": 1, "items": [ { "eventId": "ai-gpu-01:20260819:000412", "cameraId": "00000038", "cameraName": "1F Lobby", "aiServerId": "AI-01", "eventType": "intrusion", "detectedAtWallMs": 1780282795120, "score": 0.92, "clipUrl": "https://192.168.0.30:8443/events/000412/clip.mp4", "thumbnailUrl": "https://192.168.0.30:8443/events/000412/thumb.jpg" } ] }, "serverTimeMs": 1780282800000 } Client 계정(권한 무관)에 허용한다. AI 이벤트 검색·BBox·클립 조회는 관리 기능이 아니라 조회이므로 user도 호출할 수 있다. user는 소속 운영자 그룹의 aiEventsAllowed(6.3)가 true여야 하고, 결과는 소속 그룹에 배정된 카메라로만 제한된다 — 그룹이 없거나 aiEventsAllowed가 꺼져 있으면 결과는 항상 빈 목록(total: 0)이다. cameraId를 지정했는데 소속 그룹에 배정되지 않은 카메라면 에러 대신 빈 목록을 반환한다. 여러 카메라를 한 번에 훑는 검색이라 특정 카메라 하나 때문에 요청 전체를 막지 않는다. cameraId, aiServerId, eventType, fromMs, toMs, minScore로 거른다. 응답의 clipUrl은 AI Server를 직접 가리킨다. Client는 자기 토큰으로 그 주소를 호출하며 Web Server는 영상을 중계하지 않는다. 호출한 Client 계정에 externalAccess가 있으면 clipUrl·thumbnailUrl의 host:port를 외부 주소로 바꿔 내려준다(8장). 이벤트를 생성한 AI Server가 내려가 있으면 목록은 조회되지만 영상 재생은 실패한다. Web Server는 이 상태를 구분하지 않는다. [h2] 로그 수집16 Media Server와 AI가 주요 이벤트를 batch로 push한다. Media Server가 보내는 로그 코드는 Media Server API 명세서 17장에 정의한다. POST /logs 요청 { "serverId": "MEDIA-01", "sentAtWallMs": 1780282800000, "events": [ { "seq": 184423, "occurredAtWallMs": 1780282795120, "level": "ERROR", "category": "Recording", "code": "RECORDING_DISK_FULL", "cameraId": "00000038", "trackType": 0, "message": "Stopping recorder (camera-wide).", "count": 1, "detail": { "freeBytes": 1288490188, "minFreeSpacePercent": 10 } } ] } 응답 { "result": true, "error": null, "data": { "accepted": 1, "duplicated": 0, "lastSeq": 184423 }, "serverTimeMs": 1780282800000 } level은 INFO / WARN / ERROR / FATAL이다. FATAL은 프로세스가 정상 기동하지 못한 경우이므로 별도 알림 대상으로 다룬다. Web Server는 요청 토큰의 해시와 (serverId, seq)로 중복을 제거하고 duplicated에 개수를 반환한다. 토큰은 로그인마다 새로 발급되므로 재부팅·재로그인 범위가 자동으로 갈린다. 한 요청의 events는 최대 1000건이다. 초과하면 INVALID_PARAMS를 반환한다. detail은 코드마다 키가 다르므로 JSON 그대로 저장한다. occurredAtWallMs와 수신 시각을 함께 저장한다. 두 값이 크게 벌어지면 발신 서버의 시계 이상 신호다. 응답을 받지 못하면 발신 서버가 재전송하므로, Web Server는 저장을 커밋한 뒤에 응답한다. POST /logs/list 저장된 로그를 조회한다. Client admin 전용이다. serverId, level, code, category, cameraId, fromMs, toMs로 거른다. 필터가 없으면 body는 빈 객체다. 요청 { "serverId": "MEDIA-01", "level": "ERROR", "fromMs": 1780196400000, "toMs": 1780282800000 } 응답 { "result": true, "error": null, "data": { "total": 1, "items": [{ "logId": "1", "serverId": "MEDIA-01", "occurredAtWallMs": 1780282795120, "receivedAtWallMs": 1780282795200, "level": "ERROR", "category": "Recording", "code": "RECORDING_DISK_FULL", "cameraId": "00000038", "message": "Recording disk is full.", "detail": {} }] }, "serverTimeMs": 1780282800000 } logId는 Web Server DB의 로그 ID를 10진 문자열로 반환한 값이다. 정렬 시 숫자로 비교한다. cameraId는 서버 단위 로그이면 null이며, 나머지 로그 필드 의미는 Media Server API 17장을 따른다. 페이지 규칙은 4장을 따른다. POST /alerts 미해소 장애 목록이다. 로그 이력을 훑지 않고 현재 상태만 반환한다. 필터가 없으므로 body는 빈 객체다. MEDIA의 STREAMING_PROTOCOL_RECOVERED 로그를 받으면 동일 서버·동일 detail.protocol의 STREAMING_PROTOCOL_DOWN만 해제한다. 다른 프로토콜 장애는 유지한다. 상세 상태 쌍은 Media Server API 17.4를 따른다. 응답 { "result": true, "error": null, "data": { "items": [ { "serverId": "MEDIA-01", "code": "RECORDING_DISK_FULL", "cameraId": "00000038", "level": "ERROR", "openedAtWallMs": 1780282795120, "occurCount": 3, "lastMessage": "Stopping recorder (camera-wide)." } ] }, "serverTimeMs": 1780282800000 } 여는 코드와 닫는 코드의 쌍은 Media Server API 명세서 17.4를 따른다. [h2] 에러 코드17 | error.code | HTTP | 설명 | AUTH_FAILED | 401 | 아이디 없음 또는 비밀번호 오류. 둘을 구분하지 않는다. | ACCOUNT_DISABLED | 403 | 비밀번호는 맞지만 비활성화된 계정. | TOKEN_INVALID | 401 | 토큰이 없거나 폐기됨. | PASSWORD_CHANGE_REQUIRED | 403 | 기본 비밀번호를 변경하기 전이다. | FORBIDDEN | 403 | 다른 계정의 자원에 접근하거나 permission이 부족함. user가 소속 운영자 그룹에 배정되지 않은 카메라를 호출하거나, 배정됐어도 그 그룹의 playbackAllowed/exportAllowed/ptzAllowed/aiEventsAllowed(6.3)가 꺼져 있거나 소속 그룹이 없는 경우를 포함한다. | SERVER_ID_EXHAUSTED | 409 | 해당 actorType의 01~99 번호를 모두 사용함. | ACCOUNT_NOT_FOUND | 404 | accountId 없음. | ACCOUNT_CONFLICT | 409 | accountId 중복. 삭제된 값도 포함한다. | ADMIN_ALREADY_EXISTS | 409 | 추가 Client 관리자 생성 요청, 또는 계정을 관리자 그룹에 넣으려는 요청. 관리자는 최초 한 계정으로 고정(3.5). | LAST_ADMIN_PROTECTED | 409 | 고정 관리자 계정을 삭제·비활성화·강등하거나 다른 그룹으로 옮기려 함(3.5). | SERVER_NOT_FOUND | 404 | serverId 없음. | SERVER_UNREACHABLE | 503 | Media Server·AI 제어 채널 연결 없음. | SERVER_NOT_READY | 503 | Media Server가 부트스트랩·동기화 중이거나 부트스트랩에 실패해 아직 요청을 받을 수 없음. retryable: true. | CAMERA_NOT_FOUND | 404 | cameraId 없음. | AI_NOT_ASSIGNED | 409 | CCTV의 AI 배정 목록에 요청한 aiServerId가 없음. | EVENT_NOT_FOUND | 404 | eventId 없음. | ASSIGNMENT_CONFLICT | 409 | 이미 그 MEDIA(또는 AI)에 배정되어 있음. | ASSIGNMENT_NOT_FOUND | 404 | 해제·변경하려는 MEDIA(또는 AI) 배정이 없거나, 지정한 공급 MEDIA가 그 카메라의 MEDIA 배정 목록에 없음. | CAMERA_NOT_ASSIGNED_TO_MEDIA | 409 | 이 카메라가 어느 MEDIA에도 배정돼 있지 않아 AI 배정을 만들 수 없음. | AI_SUPPLY_MEDIA_IN_USE | 409 | 이 MEDIA 배정을 공급원으로 쓰는 AI 배정이 있어 해제할 수 없음. | GROUP_NOT_FOUND | 404 | 지정한 groupId가 없음. | GROUP_HAS_CAMERAS | 409 | 그룹에 속한 카메라가 남아있어 삭제할 수 없음. | GROUP_NAME_CONFLICT | 409 | groupName이 삭제되지 않은 다른 그룹과 겹침. | OPERATOR_GROUP_NOT_FOUND | 404 | 지정한 operatorGroupId가 없음. | OPERATOR_GROUP_NAME_CONFLICT | 409 | 운영자 그룹 groupName이 삭제되지 않은 다른 운영자 그룹과 겹침. | OPERATOR_GROUP_HAS_OPERATORS | 409 | 소속된 운영자 계정이 남아있어 운영자 그룹을 삭제할 수 없음. | OPERATOR_GROUP_PROTECTED | 409 | 관리자 그룹(시스템 고정)의 이름·권한·카메라를 바꾸거나 삭제하려 함(6.3). | CAMERA_HAS_ASSIGNMENTS | 409 | 배정이 남아있어 카탈로그에서 삭제할 수 없음. | CAMERA_NAME_CONFLICT | 409 | cameraName이 삭제되지 않은 다른 카메라와 겹침(전역 UNIQUE, 앞뒤 공백 제거·대소문자 구분 안 함). 카메라 등록·수정 시 거부. | ONVIF_UNAVAILABLE | 409 | 이 카메라의 ONVIF 지원 여부를 아직 확인하지 못함(연결·인증 실패 포함). retryable: true. Media Server가 반환한 값을 그대로 중계. | ONVIF_BUSY | 409 | 같은 카메라의 동시 ONVIF 요청 한도 초과 또는 설정 변경 정리 중. retryable: true. Media Server가 반환한 값을 그대로 중계. | ONVIF_CONFIGURATION_CHANGED | 409 | 요청을 처리하는 도중 카메라 설정이 변경됨. 다시 요청한다. Media Server가 반환한 값을 그대로 중계. | ONVIF_STOP_FAILED | 409 | PTZ·포커스 자동 정지 재시도가 실패함. 카메라가 계속 움직일 수 있으니 다시 정지시킨다. Media Server가 반환한 값을 그대로 중계. | ONVIF_OPERATION_CANCELLED | 409 | 정지·이동 제한시간 만료 또는 후속 제어로 이전 이동 요청이 취소됨. retryable: false. 취소된 이동은 자동으로 다시 보내지 않는다. Media Server가 반환한 값을 그대로 중계. | ONVIF_CHANNEL_UNRESOLVED | 409 | 다채널 장치에서 대상 채널을 특정할 수 없음(onvifVideoSourceToken이 없거나 해당 VideoSource를 찾지 못함). 임의로 첫 번째 채널을 제어하지 않는다. Media Server가 반환한 값을 그대로 중계. | PTZ_NOT_SUPPORTED | 409 | 카메라가 PTZ를 지원하지 않는 것으로 확인됨. details.reason: PTZ_UNSUPPORTED_BY_DEVICE. | IMAGING_NOT_SUPPORTED | 409 | 카메라가 이미징을 지원하지 않는 것으로 확인됨. details.reason: IMAGING_UNSUPPORTED_BY_DEVICE. | IMAGING_FOCUS_MODE_INVALID | 409 | 수동 포커스 모드가 아닌데 포커스 이동을 요청함. | SCHEDULE_REVISION_CONFLICT | 409 | 녹화 스케줄 expectedRevision이 Web Server의 현재 revision과 다름. retryable: true. | INVALID_PARAMS | 400 | 필수 파라미터 누락 또는 제한 초과. | CAMERA_TEST_NOT_FOUND | 404 | testId 없음. | CAMERA_TEST_LIMIT_EXCEEDED | 429 | 동시 테스트 개수 제한 초과(MEDIA 하나당 1개). | CAMERA_PREVIEW_UNSUPPORTED | 409 | 브라우저용 송출을 만들 수 없음. videoReceived: true면 코덱 미지원 등, false면 스트리밍 꺼짐·송출 주소 미확정으로 카메라에 접속하지 않은 경우(Media Server API 5장). | MEDIA_ERROR | 502 | 응답을 기다려 그대로 돌려주는 중계 API(PTZ·이미징·녹화 조회·녹화재생·내보내기·카메라 연결 테스트·ONVIF 검색 등)에서, Media Server가 retryable: false로 거부한 오류를 그대로 중계. details.mediaError에 원본을 싣는다. 카메라·그룹 등록·배정처럼 저장 후 비동기로 반영하는 API는 이 코드를 쓰지 않는다 — 연결 없음·반영 실패는 9.3의 동기화 규칙을 따르며 서버 로그에만 남는다. | INTERNAL_ERROR | 500 | Web Server 내부 오류. 녹화재생 세션·내보내기 API는 위 표에 없는 에러코드도 반환할 수 있다 — RECPLAY_SESSION_NOT_FOUND, RECPLAY_SESSION_STATE_INVALID, RECEXPORT_NOT_FOUND, RECEXPORT_NOT_READY, NO_RECORDING_IN_RANGE, INVALID_TIME_RANGE, INSUFFICIENT_STORAGE는 새로 정의하지 않고 Media Server API 16장의 코드를 그대로 전달한다. [h2] 전체 목록18 호출 자격은 6.2의 권한표를 따른다. 모든 엔드포인트는 POST다. | # | 분류 | Path | 요약 | 상세 | 1 | 인증 | /auth/login | 로그인·토큰 발급. | 3, 5 | 2 | 인증 | /auth/logout | 자기 토큰 폐기. | 5 | 3 | 인증 | /auth/password | 비밀번호 변경. | 5 | 4 | 토큰 | /tokens/introspect | 토큰 유효성 검사. Media Server가 호출. | 5 | 5 | 토큰 | /tokens/revoke | 토큰 폐기 및 Media Server 전파. | 5 | 6 | 계정 | /accounts/list | 계정 목록. | 6 | 7 | 계정 | /accounts | 계정 등록. | 6 | 8 | 계정 | /accounts/{accountId}/update | 활성·비밀번호·소속 운영자 그룹·외부 접속 주소 변경. | 6 | 9 | 계정 | /accounts/{accountId}/delete | 계정 삭제. | 6 | 10 | 서버 | /servers/list | Media Server·AI 목록과 접속 상태. | 7 | 11 | 서버 | /servers/{serverId}/update | 접속 정보 갱신. | 7 | 12 | 서버 | /servers/{serverId}/status | 상태·저장소·제어채널·스트리밍 실행 상태 통합 조회. | 7 | 13 | CCTV | /cameras/list | 통합 CCTV 목록. 배정 배열 포함, 접속 설정은 제외. | 9 | 14 | CCTV | /cameras | CCTV 카탈로그 등록. 배정은 포함하지 않음. | 9 | 15 | CCTV | /cameras/{cameraId}/update | CCTV 설정 변경. | 9 | 16 | CCTV | /cameras/{cameraId}/delete | CCTV 카탈로그 삭제. 배정이 남아있으면 거부. | 9 | 17 | 배정 | /cameras/{cameraId}/media-assignments | 지정 MEDIA에 CCTV 배정. | 9.4 | 18 | 배정 | /cameras/{cameraId}/media-assignments/{mediaServerId}/delete | MEDIA 배정 해제. | 9.4 | 19 | 배정 | /cameras/{cameraId}/ai-assignments | 지정 AI에 CCTV 배정. | 9.4 | 20 | 배정 | /cameras/{cameraId}/ai-assignments/{aiServerId}/delete | AI 배정 해제. | 9.4 | 21 | 녹화 | /cameras/{cameraId}/recordings | 녹화 구간 조회 중계. | 13 | 22 | 녹화재생 | /cameras/{cameraId}/recplay-sessions | 녹화재생 세션 생성. | 14 | 23 | 녹화재생 | /cameras/{cameraId}/recplay-sessions/get | 녹화재생 세션 상태 폴링. | 14 | 24 | 녹화재생 | /cameras/{cameraId}/recplay-sessions/refresh | 녹화재생 세션 TTL·상한 연장. | 14 | 25 | 녹화재생 | /cameras/{cameraId}/recplay-sessions/close | 녹화재생 세션 종료. | 14 | 26 | 내보내기 | /servers/{serverId}/recexports/estimate | 내보내기 용량·시간 추정. | 14 | 27 | 내보내기 | /servers/{serverId}/recexports | 내보내기 작업 생성. | 14 | 28 | 내보내기 | /servers/{serverId}/recexports/get | 내보내기 진행률·다운로드 URL 조회. | 14 | 29 | 내보내기 | /servers/{serverId}/recexports/list | 내보내기 목록. | 14 | 30 | 내보내기 | /servers/{serverId}/recexports/cancel | 내보내기 취소. | 14 | 31 | 스트리밍 | /cameras/{cameraId}/live | 실시간 접속 URL 중계. | 11 | 32 | AI | /cameras/{cameraId}/ai-stream | AnchorBox WSS 접속정보 중계. | 11 | 33 | PTZ | /cameras/{cameraId}/ptz | 팬·틸트·줌 연속 이동 중계. | 12 | 34 | 그룹 | /groups | 전역 그룹 등록. | 9.6 | 35 | CCTV | /servers/{serverId}/cameras/fetch | 담당 CCTV 설정 조회. Media Server가 호출. | 9 | 36 | ONVIF | /servers/{serverId}/cameras/discover | 네트워크 ONVIF 카메라 검색+공통 자격증명 인증 중계. | 12.1 | 37 | ONVIF | /servers/{serverId}/cameras/probe | 검색에서 실패한 카메라 한 대 재확인 중계. | 12.1 | 38 | 로그인 | /servers/{serverId}/bootstrap | 그룹·CCTV 비밀 설정·스트리밍 desired·녹화 스케줄 단일 스냅샷. | 5 | 39 | 스트리밍 | /servers/{serverId}/streaming-config | 스트리밍 desired 설정 조회. | 10 | 40 | 스트리밍 | /servers/{serverId}/streaming-config/server | 서버 enabled·bind IP·방식·포트 저장/push. | 10 | 41 | 스트리밍 | /servers/{serverId}/streaming-config/channels | CCTV track별 송출 상태 저장/push. | 10 | 42 | 녹화 | /servers/{serverId}/recording-schedules/fetch | CCTV별 녹화 스케줄 조회. | 13 | 43 | 녹화 | /servers/{serverId}/recording-schedules | CCTV별 녹화 스케줄 변경. | 13 | 44 | 이벤트 | /events | AI 이벤트 저장. AI가 호출. | 15 | 45 | 이벤트 | /events/list | AI 이벤트 검색. | 15 | 46 | 로그 | /logs | 로그 batch 수집. | 16 | 47 | 로그 | /logs/list | 로그 조회. | 16 | 48 | 로그 | /alerts | 미해소 장애 목록. | 16 | 49 | ONVIF | /cameras/{cameraId}/capabilities/get | 기능별 지원 여부 조회 중계. | 12.1 | 50 | ONVIF | /cameras/{cameraId}/capabilities/refresh | 장치에서 지원 기능 재조회 중계. | 12.1 | 51 | ONVIF | /cameras/{cameraId}/onvif/status/get | ONVIF 연결·인증 상태 조회 중계. | 12.1 | 52 | 이미징 | /cameras/{cameraId}/imaging/dayNight/get | 주간·야간·자동 설정 조회 중계. | 12.3 | 53 | 이미징 | /cameras/{cameraId}/imaging/dayNight/set | 주간·야간·자동 설정 변경 중계. | 12.3 | 54 | 이미징 | /cameras/{cameraId}/imaging/focus/get | 포커스 모드·현재 값 조회 중계. | 12.3 | 55 | 이미징 | /cameras/{cameraId}/imaging/focus/set | 포커스 모드·초점 위치 변경 중계. | 12.3 | 56 | 이미징 | /cameras/{cameraId}/imaging/focus/move | 연속 포커스 이동 중계. | 12.3 | 57 | 이미징 | /cameras/{cameraId}/imaging/focus/stop | 포커스 이동 정지 중계. | 12.3 | 58 | CCTV | /cameras/{cameraId}/get | CCTV 단건 상세 조회(비밀번호 제외). | 9 | 59 | 계정 | /operator-groups/list | 운영자 그룹 목록·권한·카메라 배정·소속 운영자 수 조회. | 6.3 | 60 | 계정 | /operator-groups | 운영자 그룹 생성. | 6.3 | 61 | 계정 | /operator-groups/{operatorGroupId}/update | 운영자 그룹 이름·권한 변경. | 6.3 | 62 | 계정 | /operator-groups/{operatorGroupId}/delete | 운영자 그룹 삭제(소속 운영자가 없어야 함). | 6.3 | 63 | 계정 | /operator-groups/{operatorGroupId}/camera-access/put | 운영자 그룹의 카메라 배정 전체 교체. | 6.4 | 64 | 배정 | /cameras/{cameraId}/ai-assignments/{aiServerId}/update | AI 배정의 공급 MEDIA 변경. | 9.4 | 65 | 배정 | /servers/{serverId}/analysis-assignments/fetch | AI 자신의 분석 대상·공급 MEDIA 조회. | 9.4 | 66 | CCTV | /servers/{serverId}/camera-tests/create | 등록 전 카메라 연결 테스트 시작. | 9.5 | 67 | CCTV | /servers/{serverId}/camera-tests/get | 연결 테스트 상태·미리보기 URL 조회. | 9.5 | 68 | CCTV | /servers/{serverId}/camera-tests/close | 연결 테스트 종료. | 9.5 | 69 | 그룹 | /groups/list | 전역 그룹 목록 조회. | 9.6 | 70 | 그룹 | /groups/{groupId}/update | 전역 그룹 이름·정렬 변경. | 9.6 | 71 | 그룹 | /groups/{groupId}/delete | 전역 그룹 삭제. | 9.6