Media Server API 명세서 Media Server API 명세서 1문서 목적 및 범위 2핵심 용어 3연결 및 인증 4메시지 규격 5CCTV · 그룹 6CCTV 상태 7스트리밍 8ONVIF 9녹화 설정 10녹화 스케줄 11녹화재생 12내보내기 13영상 접속 14토큰 관리 15서버 상태 · 저장소 16에러 코드 17로그 전송 18전체 목록 [h1] Media Server API 명세서 Version 2.0WebSocket 제어 채널Opaque Token [h2] 문서 목적 및 범위1 Media Server는 CCTV 영상 수신, 녹화, 송출, AI relay를 담당한다. 이 문서는 Media Server가 제공하는 WebSocket 제어 프로토콜과 영상 엔드포인트 계약, 그리고 Media Server가 Web Server로 전송하는 로그 규격을 정의한다. Web Server와 AI Server는 Opaque Token으로 Media Server의 WSS 제어 채널에 연결한다. View Client는 이 채널에 연결하지 않고 영상 엔드포인트에만 직접 접근하며, 인증 정책은 13장에서 정의한다. 서버 역할 분리와 통신 구조는 별도 아키텍처 정의서를 따른다. Web Server의 계정·토큰 발급 구현과 AI Server의 분석·이벤트 처리는 범위 밖이다. Web Server API 중에서는 Media Server가 호출하는 토큰 유효성 검사 API의 응답 계약(3.5)과 로그 레코드 형식(17장)만 포함하며, 수집 경로·인증·배치 규칙은 Web Server API 명세를 따른다. Media Server는 운영 모드가 둘이다. standalone은 제어 채널을 무인증으로 연다. Web Server가 없어 토큰을 검증할 상대가 없기 때문이며, 포트에 닿을 수 있으면 누구나 모든 action을 호출할 수 있다. 내부망 배포와 방화벽 제한을 전제로 한다. | 모드 | Web Server | 제어 채널 인증 | standalone | 사용하지 않음 | 없음. 전면 개방 | managed | 등록됨 | Web Server 발급 Opaque Token Media Server는 Web Server와 연동해 동작하는 매니저 모드(managed)와 단독으로 동작하는 로컬 모드(standalone)가 있다. 이 문서의 서술은 별도 표기가 없으면 매니저 모드 기준이다. [h2] 핵심 용어2 | 용어 | 정의 | 비고 | cameraId | CCTV의 유일 식별자. 8자리 zero-padding 문자열이며 Web Server가 등록 시 한 번만 발급한다. 같은 CCTV가 여러 Media Server에 배정돼도 값은 바뀌지 않는다 — 전역 유일하다. | 예: 00000038 | cameraName | CCTV 표시 이름. | 예: 1F Lobby | trackType | 스트림 종류. 0 = main, 1 = sub. 요청과 응답 모두 이 값만 쓴다. | streams 배열의 각 항목이 trackType을 갖는다 | streamId | 스트리밍 엔진/ZLM 내부 스트림 식별자. cameraId를 10진수 숫자로 변환해 앞쪽 zero-padding을 제거한다. | camera_{숫자 cameraId}_{trackType}. 예: 00000038 → camera_38_0 | Opaque Token | Web Server가 계정에 발급하는 난수 Bearer 토큰. 계정·권한 정보를 담지 않는다. | 발급은 Web Server만, 검증은 Media Server | accountId | Web Server 로그인 ID. actorType과 관계없이 전역 유일하며 변경·재사용하지 않는다. | 토큰 캐시 키 · tokens.revoke 대상 단위 | serverId | Web Server가 계정 등록 시 actor별로 발급한 영구 식별자. Media Server는 로그인 응답에서 받아 세션 메모리에 보관한다. | 예: MEDIA-01 | actorType | 계정 유형. MEDIA / AI / Client. Web Server 자신의 서비스 토큰은 MANAGER다. 감사 로그용이며 권한 판단에 쓰지 않는다. | 3.5 | connectionId | Media Server가 WSS 연결마다 부여하는 내부 식별자. 로그인·재접속마다 새로 생성된다. API 응답에는 나오지 않는다. | 로그·audit용 | requestId | 호출자가 요청마다 붙이는 값. 응답을 짝짓는 용도이며 CCTV와 무관하다. | 4장 | startWallTimeMs | 송출 RTP timestamp 0에 대응하는 스트리밍 엔진 wall 시각. 스트림 타임라인의 0 지점이다. | 시간 계산 기준 | API time | UTC epoch milliseconds. 절대 시각은 ...AtWallMs, 기간·오프셋은 ...Ms로 끝난다. | expiresAtWallMs / fromMs 식별자(cameraId, groupId, sessionId, recexportId)는 모두 8자리 zero-padding 문자열이다. | 식별자 | 발급자 | 범위 | cameraId | Web Server | 전역. CCTV 등록 시 한 번만 발급하며 삭제 전까지 불변이다. 어느 Media Server에 배정되든 값은 같다. | groupId | Web Server | 전역. 어느 Media Server가 공유하든 값은 같다. 최초 발급값은 00000001이며 삭제된 번호는 재사용하지 않는다. | sessionId / recexportId | Media Server | Media Server 내부에서만 유효한 임시 식별자다. Web Server를 사용하지 않는 standalone 구성에서는 Media Server가 cameraId와 groupId를 로컬에서 발급한다. Web Server에 등록된 managed 구성에서는 CCTV·그룹의 등록·수정·삭제와 MEDIA·AI 배정 전부 Web Server 관리자 전용이다 — Media Server UI에는 이걸 요청하는 기능 자체가 없고, Web Server가 push하는 결과를 반영만 한다. [h2] 연결 및 인증3 [h3] 연결 정보3.1 제어 채널과 영상 엔드포인트는 서로 다른 포트에서 서비스하는 별개의 서버다. 제어 채널(WSS)만 HTTPS이고 나머지 영상 엔드포인트는 전부 평문이다. 영상 엔드포인트는 둘로 나뉜다 — 녹화재생·내보내기 전용 Kestrel HTTP 서버(recplayHttpPort)와 WebRTC/WHEP signaling을 처리하는 ZLM 기반 스트리밍 엔진(httpPort)다. 하나의 포트로 TLS(wss)와 평문(http)을 동시에 열 수 없어서 나눴다. recplayHttpPort 서버는 WSS 제어 채널과 기동·종료가 함께 묶여 있다. 제어 채널이 꺼져 있으면 이 서버도 뜨지 않으며, 녹화재생·내보내기 요청 전부 실패한다. ZLM 스트리밍 엔진(httpPort/rtspPort/rtcPort)은 이 묶임과 무관하게 독립적으로 켜지고 꺼진다. | 구분 | 값 | 기본 포트 | 정책 | Control Channel (WSS) | wss://{host}:{controlPort}/ws | — | HTTPS 위의 WebSocket. Web Server와 AI Server가 연결한다. standalone에서는 AI도 토큰 없이 붙는다. | 녹화재생 엔드포인트 (HTTP) | http://{host}:{recplayHttpPort}/... | 27001 | HLS manifest·segment, 내보내기 MP4 다운로드. View Client가 직접 접근한다. 별도 Kestrel 서버이며 WSS 제어 채널과 함께 켜지고 꺼진다. | 스트리밍 엔드포인트 (HTTP, WebRTC 시그널링) | http://{host}:{httpPort}/... | 8888 | WebRTC/WHEP signaling. View Client가 직접 접근한다. ZLM 스트리밍 엔진이 처리한다. | 스트리밍 엔드포인트 (RTSP) | rtsp://{host}:{rtspPort}/ivpm/{streamId} | 8554 | AI Server 전용. 브라우저 Client에는 제공하지 않는다. | 스트리밍 토큰 | streamingToken | — | 스트리밍 엔진 기동 시 1회 발급. RTSP·WHEP·WebRTC signaling에 ?token=으로 전달한다. HLS·내보내기는 별개의 recplayId/downloadToken을 쓴다. 13장 참조. | WebRTC 전송 (UDP) | {host}:{rtcPort} | 8000 | ICE/DTLS/SRTP 전송. signaling 결과로 사용된다. | 인증 (managed) | Web Server 발급 Opaque Token | — | 제어 채널에 사용. 영상 엔드포인트는 13장 참조. | 인증 (standalone) | 없음 | — | 토큰을 검사하지 않는다. 3.3 참조. | 토큰 원문 로그 | 금지 | — | 토큰 해시, accountId, connectionId만 기록한다. 포트는 설정으로 바꿀 수 있다. 소비자는 값을 가정하지 말고 streaming.streams.info가 반환하는 URL을 그대로 사용한다. hlsUrl과 내보내기 다운로드 URL의 host는 controlIp다. streamingBindIp의 변경·미확정 또는 ZLM 중지에 영향을 받지 않는다. Client는 응답으로 받은 URL을 사용하며 직접 조합하지 않는다. [h3] 인증서3.2 Media Server가 서버 인증서를 보유하고 https와 wss로 서비스한다. Web Server와 AI Server는 WSS 클라이언트에서 이 서버 인증서의 검증을 생략하도록 구현해야 한다 — 인증서 파일이 필요 없다는 뜻이지, 기본 TLS 설정으로 자동 연결된다는 뜻은 아니다. 내부망 운영을 전제로 공인 CA 신뢰 여부를 따지지 않고 어떤 인증서든 그대로 수락하므로, 접속하는 쪽에 클라이언트 인증서나 루트 CA 설치 같은 사전 준비가 없다. 이 정책은 개발·테스트에만 적용되는 임시 구성이 아니라 현재 운영 구조에도 동일하게 적용된다. 적용 범위는 제어 채널뿐이다. 영상 엔드포인트(WebRTC/WHEP, HLS, MP4, RTSP)는 Media Server가 별도 포트에서 평문으로 서비스하며, 제어 채널 쪽이 이를 HTTPS로 프록시하지 않는다. 내부망 배포를 전제로 한 구성이다. WSS 서버 인증서는 controlIp를 IP SAN에 포함하도록 생성한다. controlIp가 바뀌면 인증서의 SAN도 확인하고 필요하면 재발급한다. 녹화재생·내보내기는 같은 controlIp를 쓰지만 별도 포트의 평문 HTTP라 이 인증서를 적용하지 않는다. [h3] 연결 수립3.3 WebSocket 연결은 HTTP Upgrade 요청으로 시작한다. 별도의 애플리케이션 레벨 handshake 메시지는 사용하지 않는다. 인증은 운영 모드에 따라 다르다. | 모드 | Authorization 헤더 | Upgrade 처리 | managed | Web Server 발급 Opaque Token 필수 | 수락 전에 토큰을 검증한다. 무효면 401, 검증할 수 없으면(로그인 응답 전 포함) 503, 유효하면 101 | standalone | 보내지 않는다. 보내도 무시한다 | 검증 없이 바로 101로 전환한다 GET /ws HTTP/1.1 Host: media-01 Upgrade: websocket Connection: Upgrade Authorization: Bearer 9f2c7a1e4b8d... Authorization 헤더는 managed에서만 쓴다. standalone은 이 줄이 없어도 연결된다. Media Server는 연결마다 connectionId를 부여해 로그와 audit에 기록한다. 호출자에게는 반환하지 않는다. [h3] Heartbeat / 재연결3.4 서버는 60초 주기로 애플리케이션 heartbeat ping을 보내고 client는 같은 pingId의 pong으로 응답한다. 2회 연속 pong 누락 시 서버는 연결을 끊고 해당 연결이 만든 recplay session을 정리한다. { "type": "ping", "pingId": "3f2b8c10-1", "serverTimeMs": 1780282800000 } { "type": "pong", "pingId": "3f2b8c10-1" } 연결이 끊긴 client는 지수 backoff(최소 500ms, 최대 10s)로 재연결한다. 재연결 후에는 control.server.status로 재기동 여부를 확인하고, status.get이 READY가 되면 CCTV 목록과 cameras.runtimeStatus.list를 다시 동기화한다. AI는 streaming.streams.info도 재조회한다. Web Server는 이 연결의 유지 여부를 Media Server 생존 판정으로 사용한다. Media Server가 별도의 Heartbeat를 Web Server로 보내지 않는 것은 이 때문이다. [h3] 토큰 검증3.5 이 절은 managed 모드에만 해당한다. standalone에서는 토큰을 검사하지 않으므로 Web Server 질의도, 캐시도, 폐기 통지도 동작하지 않는다. Media Server는 요청에 실린 토큰을 직접 해석하지 않는다. 토큰은 계정 정보나 권한을 담지 않는 불투명 문자열이므로, Web Server의 유효성 검사 API에 물어봐야 유효 여부를 알 수 있다. 질의는 제어 채널이 아니라 Media Server가 직접 여는 HTTPS 연결로 보낸다. 제어 채널로 물으면 검증 대상인 상대에게 검증을 위임하는 순환이 되어 인증이 성립하지 않는다. Authorization: Bearer 9f2c7a1e4b8d... 토큰을 처음 받은 시점에 한 번만 호출하고, 이후에는 캐시한 결과로 계속 허용한다. { "active": true, "accountId": "ai-gpu-01", "actorType": "AI", "serverId": "AI-01", "permission": "admin", "reason": null } | 필드 | 설명 | active | 토큰이 유효한지 여부. false이면 거부한다. | accountId | 계정 식별자. 감사 로그에 기록하고 토큰 캐시 키로 쓴다. | actorType | MEDIA / AI / Client. Web Server 자신은 MANAGER다. Client는 제어 채널에 연결하지 않으므로 Media Server가 보는 값은 MANAGER와 AI뿐이다. | serverId | 발급된 영구 식별자. MEDIA-01 / AI-01 / Client-01 형식이다. Web Server 서비스 토큰이면 null이다. | permission | admin / user. | reason | active: false일 때만 채운다. revoked(폐기·계정 비활성) → TOKEN_REVOKED, unknown(존재하지 않음) → TOKEN_INVALID. 검증 결과는 연결 단위가 아니라 프로세스 전체 캐시에 보관한다. 토큰에 만료가 없으므로 주기적 재검증은 하지 않는다. 캐시 조회는 action 요청마다 수행한다. 있으면 바로 처리하고, 없으면 그 요청을 처리하기 전에 재질의한다. 정상 운영 중에는 연결당 최초 1회만 질의가 나가고, 캐시를 비운 뒤에는 이미 열려 있던 연결도 다음 요청에서 걸러진다. 재질의 결과가 무효면 그 요청을 TOKEN_REVOKED로 거부하고 연결을 닫는다. 캐시된 토큰은 Media Server 프로세스 종료, 명시적 캐시 삭제 또는 Web Server의 토큰 폐기 통지를 받기 전까지 유효한 것으로 취급한다. 폐기 통지와 캐시 삭제는 14장의 tokens.revoke / tokens.flushCache로 수행한다. 재질의를 위해 연결마다 토큰 원문을 메모리에 보관한다. 디스크에 쓰거나 로그에 남기지 않는다. Web Server에 질의할 수 없으면 요청을 거부하고 INTROSPECTION_UNAVAILABLE을 반환한다. 로그인 응답을 받기 전에는 Media Server에 자기 토큰이 없어 질의할 수 없다. 이때 도착한 WSS 접속은 503으로 거절되므로 접속하는 쪽이 재시도한다. 질의할 때 Media Server 자신의 토큰을 Authorization 헤더에 실어 호출자 인증을 받는다. 검사 대상 토큰은 body에만 싣고 쿼리스트링에 넣지 않는다. Web Server 인증서를 검증하지 않는다. 자체서명 인증서를 쓰는 내부망 배포를 전제로 어떤 인증서든 수락한다. managed에서 유효한 토큰으로 연결되면, standalone에서는 연결만 되면 모든 action을 호출할 수 있다. 내부망 구성이므로 계정 유형이나 CCTV 단위로 호출 범위를 나누지 않는다. 응답의 actorType과 permission은 감사 로그 기록용이며, Media Server는 이 값으로 호출을 거부하지 않는다. 사용자 권한 검사는 Web Server가 수행한다. 영상 엔드포인트의 인증은 이와 별개이며 13장에서 정의한다. [h3] Media Server가 Web Server를 호출하는 경로3.6 제어 채널은 Web Server와 AI가 Media Server에 접속하는 inbound 경로다. 반대로 Media Server가 Web Server를 호출할 때는 항상 Media Server가 여는 HTTPS 연결을 쓴다. 계약은 Web Server API 명세서를 따르며, 이 문서는 호출 목적만 정리한다. | 목적 | 시점 | 로그인 · 토큰 발급 | Media Server 기동 후 1회. 실패하면 backoff 후 재시도한다. | 토큰 유효성 검사 | 처음 보는 토큰을 받았을 때 (3.5) | 담당 CCTV 목록 조회 | Media Server 기동 직후와 주기적 보정 | 로그 전송 | 17장 Web Server 주소와 Media Server 계정은 설치 시 로컬 설정으로 주입한다. 이 부트스트랩 절차는 API 범위 밖이다. Web Server에 연결할 수 없으면 Media Server는 이미 보유한 로컬 DB로 ingest·녹화·송출을 계속한다. 다만 새 CCTV 배정이나 그룹은 반영되지 않는다. 등록·배정은 전부 Web Server 관리자가 처리하고 WSS로 push하는 방식이라, 연결이 없으면 그 push 자체를 받을 수 없기 때문이다. [h2] 메시지 규격4 모든 메시지는 JSON 한 줄이며 요청과 응답 두 종류다. 시간은 UTC epoch milliseconds만 쓴다. | 필드 | 방향 | 설명 | requestId | 요청 → 응답 | 호출하는 쪽이 요청마다 붙이는 값. Media Server는 해석하지 않고 응답에 그대로 되돌려준다. 응답 대기 중인 요청끼리 겹치지만 않으면 형식은 자유다. | action | 요청 | 호출할 기능 이름. | params | 요청 | 기능별 파라미터. 없으면 {}. | ok | 응답 | 성공 여부. | result | 응답 | 성공 시 반환값. | error | 응답 | 실패 시 code, message, retryable, details. | serverTimeMs | 응답 | Media Server 시각. requestId는 응답을 요청에 짝지어주는 용도이며 CCTV·스트림과 무관하다. 한 연결로 여러 요청을 동시에 보낼 수 있고 응답 순서가 보장되지 않으므로 필요하다. 대상 CCTV는 params.cameraId로 지정한다. 대상 지정 파라미터는 단수형(cameraId, groupId, trackType)이면 값 하나 또는 "all", 복수형(cameraIds, trackTypes)이면 배열 또는 "all"을 받는다. 생략은 "all"과 같고, 응답이 여러 건이면 items 배열로 감싼다. 요청 { "requestId": "req-001", "action": "camera-assignments.update", "params": { } } 응답 { "requestId": "req-001", "ok": true, "result": { }, "serverTimeMs": 1780282800000 } 오류 { "requestId": "req-001", "ok": false, "error": { "code": "STREAM_UNAVAILABLE", "message": "RTSP source is unavailable.", "retryable": true, "details": { "cameraId": "00000038" } }, "serverTimeMs": 1780282800000 } 매니저 모드에서는 그룹·카메라·배정·스트리밍 설정·녹화 스케줄을 Web Server의 Client admin만 변경한다. MEDIA 계정으로 변경을 요청하는 경로는 없고, MEDIA는 push받은 설정을 반영하고 상태만 보고한다. 적용에 실패해도 웹의 desired 설정을 유지하며, MEDIA가 이전 값이나 enabled: false를 웹에 저장해 덮어쓰지 않는다. 실시간 보기·녹화 조회·재생·내보내기와 장비 로컬 설정(저장 경로·보관 기간·파일 분할·최소 여유 공간, WSS/Media 서버 사용 여부·포트·인증서)은 이 제한 대상이 아니며, 로컬 모드에서는 기존 직접 설정을 유지한다. 변경 범위의 전체 목록은 ARCH 문서의 "설정 변경과 그룹 소속 정책"을 따른다. [h2] CCTV · 그룹5 CCTV 설정의 주인은 Web Server다. cameraId는 Web Server 관리자가 CCTV를 등록할 때 한 번만 발급하고, groupId도 Web Server 관리자가 그룹을 만들 때 발급한다. Media Server는 로컬 DB를 유지해 Web Server 없이도 ingest·녹화·송출을 계속하지만, CCTV·그룹의 등록·수정·삭제·배정은 전부 Web Server 관리자 전용이라 Media Server는 스스로 아무것도 만들거나 요청할 수 없고 push받은 결과를 반영만 한다. 등록된 카메라는 반드시 하나의 그룹에 소속된다. 조회·bootstrap·일괄 동기화의 카메라에는 유효한 groupId가 필수이며 미분류는 허용하지 않는다. groupId는 전역 값이다. Web Server가 그룹을 한 번만 만들고, 그 그룹에 속한 카메라가 배정된 Media Server들에 같은 groupId를 그대로 내려준다. Media Server는 로컬에서 새 groupId를 발급하지 않으며, 받은 값을 그대로 저장한다. 그룹의 이름·정렬순서는 자기가 담당하는 카메라가 속한 그룹만 안다 — 담당 카메라가 하나도 없는 그룹은 push받지 않는다. | 시점 | 수단 | Web Server가 제어 채널에 (재)접속한 직후 | control.server.status로 재기동 여부를 확인하고, status.get이 READY가 되면 camera-assignments.list로 Media Server의 CCTV 목록을 읽어 Web Server DB와 대조한다. 준비 전에는 SERVER_NOT_READY다(15장). | Web Server에서 CCTV·그룹·설정을 변경할 때 | 아래 action으로 즉시 push한다. 연결이 없으면 다음 접속에 밀어 넣는다. | 보정 | Media Server가 기동 직후와 주기적으로 담당 CCTV 목록을 조회해 누락을 메운다. 3.6 참조. [h4] camera-groups.list CCTV 그룹 목록을 반환한다. 자기 담당 카메라가 하나라도 있는 그룹만 보인다 — 전역 그룹 전체가 아니다. groupId는 Web Server가 전역으로 발급한 값을 그대로 받아 저장할 뿐, Media Server가 스스로 새 그룹을 만들거나 번호를 매기지 않는다. 요청 { "requestId": "req-090", "action": "camera-groups.list", "params": { } } 응답 { "requestId": "req-090", "ok": true, "result": { "items": [ { "groupId": "00000003", "groupName": "1F", "sortOrder": 0, "cameraCount": 12 } ] } } [h4] camera-groups.create 이 Media Server에 이 그룹의 카메라가 처음 배정될 때 push된다 — 그룹 자체는 이미 다른 Media Server도 알고 있을 수 있는 전역 그룹이며, 이 action은 "이제부터 너도 이 그룹을 알아야 한다"는 뜻이다. groupId는 Web Server가 발급해 요청에 실어 보낸다. 요청 { "requestId": "req-091", "action": "camera-groups.create", "params": { "groupId": "00000004", "groupName": "2F", "sortOrder": 1 } } 응답 { "requestId": "req-091", "ok": true, "result": { "groupId": "00000004", "groupName": "2F", "sortOrder": 1 } } 응답은 저장된 값을 그대로 돌려준다. Web Server는 별도 조회 없이 자기 DB에 반영한다. 이미 로컬에 있는 groupId면 GROUP_CONFLICT, groupName이 로컬에서 이미 아는 다른 그룹과 겹치면 GROUP_NAME_CONFLICT다 — Web Server에서도 공통 그룹 이름 중복을 거부한다. [h4] camera-groups.update 그룹 이름·정렬순서가 바뀌면 push된다. Web Server의 공통 그룹이 바뀐 것이라, 이 그룹을 아는 모든 Media Server에 동시에 전달된다. 지정한 필드만 반영한다. 요청 { "requestId": "req-092", "action": "camera-groups.update", "params": { "groupId": "00000004", "groupName": "2F East", "sortOrder": 2 } } 응답 { "requestId": "req-092", "ok": true, "result": { "groupId": "00000004", "groupName": "2F East", "sortOrder": 2 } } create와 마찬가지로 반영된 값을 그대로 돌려준다. 생략한 필드는 기존 값이 그대로 담긴다. [h4] camera-groups.delete 소속 카메라가 남아 있으면 GROUP_HAS_CAMERAS로 거절하고 카메라·그룹을 변경하지 않는다. Web Server는 카메라 이동 또는 배정 해제의 반영을 먼저 완료한 뒤 그룹 삭제를 재시도한다. 이 action은 로컬 모드 화면의 그룹·카메라 연쇄 삭제와 구분한다. 이 Media Server가 이 그룹을 더는 몰라도 되는 상태다 — 이 그룹에 속했던 카메라가 전부 다른 그룹으로 옮겨졌거나 이 MEDIA에서 배정 해제돼, 이 MEDIA에 남은 소속 카메라가 하나도 없을 때 push된다. 카메라 배정·녹화에는 아무 영향이 없다 — 그 카메라의 배정 해제나 그룹 이동은 이미 camera-assignments.delete/.update로 따로 처리됐고, 이 action은 그 뒤에 남은 빈 그룹 항목을 로컬에서 지우는 순수 정리 작업이다. 그룹 자체가 Web Server에서 전역 삭제될 때도 같은 이유로 push된다 — 전역 삭제는 소속 카메라가 전부 없어야 가능하므로(Web Server_API 9.1), 이 시점엔 이미 어느 Media Server도 이 그룹에 속한 카메라를 갖고 있지 않다. 요청 { "requestId": "req-093", "action": "camera-groups.delete", "params": { "groupId": "00000004" } } 응답 { "requestId": "req-093", "ok": true, "result": { "groupId": "00000004" } } [h4] camera-assignments.list Media Server에 등록된 CCTV 목록을 반환한다. Web Server는 여기서 얻은 cameraId로 이후의 조회·변경·삭제를 지정한다. 요청 { "requestId": "req-100", "action": "camera-assignments.list", "params": { "groupId": "all", "cameraIds": "all" } } 응답 { "requestId": "req-100", "ok": true, "result": { "items": [ { "cameraId": "00000038", "cameraName": "1F Lobby", "groupId": "00000003", "groupName": "1F", "enabled": true, "status": "assigned", "configRevision": 3 } ] } } groupId와 cameraIds는 함께 쓰면 교집합이다. 둘 다 all이면 전체를 반환한다. groupName을 함께 주므로 목록을 그리기 위해 camera-groups.list를 따로 부를 필요가 없다. enabled는 항상 true다. 등록된 CCTV는 언제나 녹화 대상이며, 트랙 송출을 잠시 끄더라도(streaming.channels.setEnabled) 해당 스트리밍만 꺼지고 녹화에는 영향이 없다 — Media Server를 재시작하면 원래대로 돌아온다. 녹화를 끄는 방법은 6장 참고. [h4] camera-assignments.get CCTV 한 건을 등록 설정까지 포함해 조회한다. 요청 { "requestId": "req-100b", "action": "camera-assignments.get", "params": { "cameraId": "00000038" } } 응답 { "requestId": "req-100b", "ok": true, "result": { "cameraId": "00000038", "cameraName": "1F Lobby", "groupId": "00000003", "enabled": true, "ipAddress": "192.168.0.10", "httpPort": 80, "onvifPort": 80, "onvifVideoSourceToken": null, "configRevision": 3, "streams": [ { "trackType": 0, "rtspPort": 554, "rtspUrl": "/stream/main", "enabled": true, "codec": "H264", "fps": 30.0 }, { "trackType": 1, "rtspPort": 554, "rtspUrl": "/stream/sub", "enabled": true, "codec": "H264", "fps": 15.0 } ], "auth": { "deviceId": "admin" }, "encryptedRtsp": { "enabled": false, "id": "" }, "status": "assigned" } } codec과 fps는 수신 패킷에서 검출한 실제 값이다. 아직 검출 전이거나 스트림이 끊겨 있으면 생략한다. rtspUrl은 스트림 경로이며 비밀번호는 반환하지 않는다. configRevision은 이 CCTV 설정을 마지막으로 반영했을 때 받은 번호다(list에도 같은 필드가 실린다). 비밀번호는 응답에 싣지 않으므로, Web Server는 이 번호가 자기 값과 같은지로 비밀번호를 포함한 반영 여부를 확인한다. 번호가 없거나 다르면 Web Server가 전체 설정을 다시 보낸다. [h4] camera-assignments.create CCTV를 Media Server에 새로 등록한다. cameraId는 Web Server가 발급해 요청에 실어 보낸다. 이미 존재하는 값이면 CAMERA_CONFLICT를 반환한다. 요청 { "requestId": "req-101", "action": "camera-assignments.create", "params": { "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": "" } } } 응답 { "requestId": "req-101", "ok": true, "result": { "cameraId": "00000038", "applied": true, "configRevision": 3, "status": "assigned", "ingestStatus": "connecting", "recordingStatus": "starting" } } 등록과 동시에 main·sub 수신과 녹화가 시작된다. 등록된 CCTV는 항상 녹화하므로 요청에 enabled를 넣지 않으며 Media Server가 true로 저장한다. 응답은 요청한 cameraId를 그대로 돌려주고 초기 상태를 함께 싣는다. Web Server는 발급 시점에 이미 값을 알고 있으므로 응답을 기다려 자기 DB를 채울 필요가 없다. cameraName은 Web Server가 전역 UNIQUE를 보장하므로 정상 경로에서는 겹치지 않는다. 그래도 겹치면(Web Server 복구 직후 등 비정상 상태) CAMERA_NAME_CONFLICT를 반환하는 안전장치로 남긴다. 비교 규칙은 Web Server와 같다(앞뒤 공백 제거, 대소문자 구분 안 함). 등록 시 groupId는 필수이며 생략·null은 INVALID_PARAMS다. 지정한 그룹은 이 Media Server가 이미 아는 그룹이어야 하며, 모르는 값이면 GROUP_NOT_FOUND다. Web Server는 이 카메라의 그룹을 이 Media Server가 처음 보는 경우 camera-groups.create를 먼저 push한 뒤에 이 action을 보낸다 — 순서가 바뀌면 GROUP_NOT_FOUND가 난다. devicePw와 encryptedRtsp.pw는 평문 필드로 전달한다. WSS가 전송 구간을 보호하며 Media Server는 DB 저장 시 기존 DB 암호화 로직을 적용한다. secret URI 저장소는 사용하지 않고 비밀번호는 조회 응답과 로그에 포함하지 않는다. 비밀번호가 반영됐는지는 configRevision으로 확인한다. encryptedRtsp.enabled=true이면 encryptedRtsp.id와 encryptedRtsp.pw를 암호화 RTSP 연결 자격증명으로 사용한다. ONVIF 연결은 ipAddress·onvifPort(생략하면 80)와 auth의 자격증명으로 Media Server가 자동 시도한다. 켜고 끄는 스위치도, 실패했을 때의 영향도 8.1에 있다. configRevision은 필수 정수(1 이상)다. Web Server가 카메라 설정이 바뀔 때마다 올리는 번호이며, Media Server는 이 값을 CCTV 설정과 같은 트랜잭션으로 저장한다(설정만 저장되고 번호가 누락되는 일이 없어야 한다). 없으면 INVALID_PARAMS다. camera-assignments.update·.sync도 같다. 저장된 값보다 작은 configRevision이 오면(늦게 도착한 이전 요청) 설정을 바꾸지 않고 ok: true에 applied: false와 현재 저장된 configRevision을 돌려준다. 이전 설정이 최신 설정을 덮어쓰지 않게 하기 위해서다. 같거나 큰 값이면 적용한다. onvifVideoSourceToken은 선택 필드다. NVR·스트리밍서버처럼 여러 카메라가 같은 ipAddress·onvifPort를 공유하는 장치에서만 채운다(8.1 cameras.discover/.probe의 videoSourceToken을 그대로 받는다) — VideoSource 토큰(프로필의 VideoSourceConfiguration이 가리키는 SourceToken)이며, PTZ·이미징 명령을 보낼 채널을 구분하는 용도일 뿐 등록 여부와는 무관하다. Media Server는 이 값으로 VideoSource → 그 소스를 쓰는 main/sub 프로필 → 그 프로필의 PTZ 설정을 찾아 명령을 보낸다. 생략하면(보통의 단일 채널 카메라) ipAddress·onvifPort만으로 자동 연결한다. 다채널 장치인데 이 값이 없거나 해당 소스를 찾지 못하면 임의로 첫 번째 채널을 제어하지 않고 ONVIF_CHANNEL_UNRESOLVED를 반환한다(8.2). 보관기간·분할길이는 CCTV별 설정이 아니라 서버 전역 값이며, Media Server 로컬 환경설정에서만 바꾼다(9장 settings.recording.get 참고). CCTV별로 켤 요일·시간대는 10장 녹화 스케줄로 별도 관리한다. cameraId는 Web Server가 등록 시 한 번만 발급하는 전역 유일값이다. 같은 물리 CCTV가 여러 Media Server에 배정되면 이 값을 그대로 공유하며, 서로 다른 CCTV끼리는 절대 겹치지 않는다. Media Server는 CCTV를 스스로 등록하거나 배정받을 수 없다. 카탈로그 등록도 MEDIA 배정도 전부 Web Server UI에서 관리자가 처리하며, Media Server는 배정된 결과를 camera-assignments.create push로 받기만 한다. [h4] camera-assignments.update 등록된 CCTV의 설정을 변경한다. 지정한 필드만 반영하고 생략한 필드는 기존 값을 유지한다. groupId도 생략하면 기존 그룹을 유지하지만 null은 INVALID_PARAMS, 모르는 그룹은 GROUP_NOT_FOUND다. 등록된 CCTV는 항상 녹화 대상이므로 enabled는 변경할 수 없다. groupId가 바뀔 때는 카메라를 배정받은 모든 Media Server에 동시에 push된다 — 그룹은 이제 카메라 하나의 속성이라, 배정마다 따로 바꿀 수 없다(Web Server_API 9.4). 이때도 이 Media Server가 그 새 그룹을 처음 본다면 Web Server가 camera-groups.create를 먼저 push한다. 요청 { "requestId": "req-102", "action": "camera-assignments.update", "params": { "cameraId": "00000038", "cameraName": "1F Lobby (East)", "groupId": "00000005", "configRevision": 4, "streams": [ { "trackType": 1, "rtspUrl": "/stream/sub2" } ], "encryptedRtsp": { "enabled": true, "id": "secure-user", "pw": "Secure11!!" } } } 응답 { "requestId": "req-102", "ok": true, "result": { "cameraId": "00000038", "applied": true, "configRevision": 4, "status": "assigned", "ingestStatus": "online", "recordingStatus": "recording" } } 없는 cameraId면 CAMERA_NOT_FOUND를 반환한다. devicePw와 encryptedRtsp.pw를 생략하면 기존 값을 유지한다. 빈 문자열로 명시하면 저장된 값을 지운다. [h4] camera-assignments.sync Web Server DB 기준으로 Media Server가 가져야 할 CCTV 전체를 전달해 drift를 복구한다. 각 항목에는 cameraId가 반드시 있다. Media Server에 있으면 갱신하고 없으면 새로 등록하며, 등록한 목록을 created 배열로 돌려준다. 요청 { "requestId": "req-106", "action": "camera-assignments.sync", "params": { "syncId": "sync-20260619-001", "removeMissing": false, "cameras": [ ] } } 응답 { "requestId": "req-106", "ok": true, "result": { "syncId": "sync-20260619-001", "updated": 1, "removed": 0, "unchanged": 0, "created": [ { "cameraId": "00000039", "cameraName": "2F Hall" } ], "rejected": [ { "cameraId": "00000077", "code": "CAMERA_NAME_CONFLICT", "message": "name already exists" } ] } } created 배열로 새로 등록된 CCTV를 확인하고, rejected 배열로 반영되지 않은 CCTV를 골라 camera-assignments.create / .update로 재시도한다. removeMissing: true면 여기 없는 기존 CCTV는 배정 해제로 정리되어 removed 카운트에 반영된다 — 이때도 스케줄 항목만 지우고 revision은 올리지 않는다(10장). cameras의 각 항목은 camera-assignments.create와 같은 필드를 쓰며 cameraId와 configRevision이 필수다. 저장된 값보다 작은 configRevision의 항목은 적용하지 않고 unchanged로 센다. [h4] camera-assignments.delete CCTV 배정을 해제한다. 녹화 데이터는 삭제하지 않는다. 이 CCTV의 녹화 스케줄 항목도 함께 지우며, revision은 올리지 않는다(10장). recordingAction은 현재 stop_keep만 지원한다. 녹화 중이면 중지하고 저장된 파일은 남긴다. Media Server는 녹화 파일을 지우는 수단을 제공하지 않으며, 삭제는 settings.recording의 보관기간 정책으로만 이루어진다. 요청 { "requestId": "req-103", "action": "camera-assignments.delete", "params": { "cameraId": "00000038", "recordingAction": "stop_keep" } } 응답 { "requestId": "req-103", "ok": true, "result": { "cameraId": "00000038", "status": "unassigned", "recordingStatus": "stopped", "preservedRecording": true } } [h3] 카메라 연결 테스트 (등록 전) CCTV를 카탈로그에 등록하기 전, 입력한 접속 정보로 실제 영상이 들어오는지 미리 확인한다. cameras.probe(8.1)가 ONVIF로 RTSP 경로·기능을 알아내는 것과 달리, 이 action은 그 값으로 실제 RTSP 수신과 WebRTC 송출까지 해본다. 카메라 등록·DB 저장·그룹 배정·녹화는 하지 않으며, 정식 카메라와 완전히 분리된 임시 리소스로 처리한다. [h4] camera-tests.create 요청 { "requestId": "req-201", "action": "camera-tests.create", "params": { "ipAddress": "192.168.30.100", "trackType": 0, "rtspPort": 554, "rtspUrl": "/stream/main", "auth": { "deviceId": "admin", "devicePw": "Admin11!!" }, "encryptedRtsp": { "enabled": false, "id": "", "pw": "" } } } 응답 { "requestId": "req-201", "ok": true, "result": { "testId": "00000001", "state": "CONNECTING", "whepUrl": null, "webRtcUrl": null, "expiresAtWallMs": 1780282860000 } } 한 번에 트랙 하나만 테스트한다. main/sub를 바꾸려면 기존 테스트를 .close하고 새로 만든다. cameraId·groupId는 받지 않는다 — 아직 카탈로그에 없는 카메라이기 때문이다. 이미 등록된 카메라와 겹치지 않는 별도 네임스페이스의 임시 스트림 ID를 쓴다(예: test_00000001_0). 이 Media Server 하나에 동시에 1개까지만 허용한다. 두 번째를 만들려 하면 CAMERA_TEST_LIMIT_EXCEEDED다. 계정별이 아니라 MEDIA 하나 전체 기준이다. 카메라 접속(TCP 연결·인증)은 10초 안에 되지 않으면 CAMERA_CONNECT_TIMEOUT으로 FAILED 전이한다. 접속 후 추가로 5초 안에 첫 영상 패킷이 오지 않으면 CAMERA_VIDEO_TIMEOUT으로 FAILED 전이한다. 두 시간 모두 고정값이며 요청으로 바꿀 수 없고, 아래 60초 TTL 안에 포함된다 — 접속·수신 준비에 시간을 많이 쓸수록 실제 영상을 볼 수 있는 시간은 줄어든다. [h4] camera-tests.get 요청 { "requestId": "req-202", "action": "camera-tests.get", "params": { "testId": "00000001" } } 응답 { "requestId": "req-202", "ok": true, "result": { "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 } } state 값: | 값 | 의미 | CONNECTING | 카메라 접속·영상 수신·WebRTC 송출 준비 중 | READY | 영상 수신 및 WebRTC 송출 준비 완료 | FAILED | 실패. error에 원인 | CLOSED | .close 또는 정책에 의한 정상 종료 | EXPIRED | TTL 만료로 자동 종료 기본 전이: CONNECTING → READY → CLOSED. 오류 시 CONNECTING/READY → FAILED, TTL 만료 시 CONNECTING/READY → EXPIRED. videoReceived(카메라가 실제로 영상을 보냄)와 previewAvailable(그 영상을 브라우저용으로 송출할 수 있음)은 서로 다른 신호다. CAMERA_PREVIEW_UNSUPPORTED는 두 경우에 쓰이며 videoReceived로 가른다. 카메라 접속·수신엔 성공했지만 코덱 등의 이유로 송출을 못 만들면 videoReceived: true, previewAvailable: false와 함께 state: FAILED다. 스트리밍 엔진이 꺼져 있거나 송출 주소가 미확정이라 카메라에 접속하지 않았으면 videoReceived: false다 — 카메라 접속 실패로 오인하지 않도록, 화면은 이 값으로 안내 문구를 구분한다. READY가 된 시점부터 10초 안에 WebRTC 연결이 실제 성립하지 않으면 CLOSED, closeReason: "WEBRTC_CONNECT_TIMEOUT"으로 종료한다. SDP 응답만 받은 상태는 시청 연결로 보지 않는다. 한 번 연결된 뒤 마지막 WebRTC 시청자의 연결 종료가 감지되면 CLOSED, closeReason: "VIEWER_DISCONNECTED"로 종료한다. 네트워크 단절은 감지 지연이 있을 수 있으므로 Web Server는 미리보기 창 닫기·시청 중지 시 camera-tests.close도 호출한다. get 응답의 closeReason은 REQUESTED(명시적 close), WEBRTC_CONNECT_TIMEOUT, VIEWER_DISCONNECTED, MAX_DURATION(생성 후 60초), SERVER_STOPPED(스트리밍 서버 중지·재시작)이며 진행 중이거나 실패 원인을 error로 제공할 때는 null이다. WSS/Media 서버 재시작으로 조회 항목 자체가 삭제되면 CAMERA_TEST_NOT_FOUND다. get 호출은 만료 시각(expiresAtWallMs)에 전혀 영향을 주지 않는다. 영상을 계속 보고 있거나 get을 반복 호출해도 expiresAtWallMs는 create 시점에 정해진 값 그대로다 — 연장 수단 자체가 없다. [h4] camera-tests.close 요청 { "requestId": "req-204", "action": "camera-tests.close", "params": { "testId": "00000001" } } 응답 { "requestId": "req-204", "ok": true, "result": { "testId": "00000001", "state": "CLOSED" } } 카메라 수신과 임시 WebRTC 송출을 즉시 정리한다. 이미 종료된 테스트(CLOSED·EXPIRED·FAILED)에 다시 호출해도 성공 처리하며 기존 상태·종료 사유·오류를 유지한다. 응답의 state는 해당 테스트의 최종 상태다(멱등). get/close 두 action은 없는 testId면 CAMERA_TEST_NOT_FOUND다(create는 testId를 새로 발급하므로 해당 없음). 종료된 테스트는 연결·송출 자원 없이 상태만 보관한다. 생성 순서 기준 최근 32개까지 조회할 수 있으며 더 오래된 항목과 WSS/Media 서버 재시작 전 항목은 CAMERA_TEST_NOT_FOUND다. 종료된 테스트는 동시 1개 제한에 포함하지 않되, 실제 자원 정리가 완료된 후 다음 테스트를 허용한다. 정책: TTL 60초, create 시점부터 고정이며 연장 수단이 없다. 영상을 보고 있거나 get 요청이 계속 들어와도 바뀌지 않는다. 60초가 되면 영상을 보고 있는 중이어도 수신·송출을 무조건 끊고 EXPIRED로 전이한다 — 다시 보려면 create로 새 테스트를 만들어야 한다. 비밀번호는 응답·로그 어디에도 남기지 않는다. 스트리밍 엔진(ZLM)이 켜져 있고 WebRTC 송출이 가능할 때만 카메라 접속을 시작한다(streamingBindIp가 0.0.0.0 미확정이면 불가로 본다). 조건을 만족하지 못하면 카메라에는 아예 접속하지 않고 즉시 state: FAILED, error.code: CAMERA_PREVIEW_UNSUPPORTED, videoReceived: false로 응답한다 — 어차피 미리보기를 못 만들 상황에서 카메라 접속·인증 시도를 낭비하지 않는다. 테스트 때문에 저장된 스트리밍 desired 설정(7장)을 바꾸지 않는다. status.get이 READY가 아니면 SERVER_NOT_READY다. [h2] CCTV 상태6 CCTV 응답에 실리는 상태 필드의 값은 아래가 전부다. ingestStatus와 recordingStatus는 트랙별 값이며, CCTV 레벨에 실릴 때는 트랙들을 묶은 요약이다. | 필드 | 값 | 뜻 | status | assigned / unassigned | CCTV가 이 Media Server에 등록되어 있는지. CCTV 단위. | ingestStatus | connecting / online / offline / disabled | Media Server가 CCTV에서 영상을 받고 있는지. | recordingStatus | stopped / starting / recording / failed | 그 트랙을 녹화 중인지. 송출 상태는 별개이며 streaming.streams.list의 state로 조회한다(7장). CCTV 레벨 요약값은 이렇게 정한다. ingestStatus — 트랙 중 하나라도 online이면 online, 하나라도 connecting이면 connecting, 전부 disabled면 disabled, 그 외 offline. recordingStatus — 하나라도 recording이면 recording, 아니고 하나라도 starting이면 starting, 아니고 하나라도 failed면 failed, 그 외 stopped. [h4] cameras.runtimeStatus.list 담당 CCTV의 트랙별 수신·녹화 상태와 수신 지표를 한 번에 조회한다. Media Server는 상태 변화를 push하지 않으므로 Web Server가 주기적으로 호출해 갱신한다. | 필드 | 설명 | ingestStatus / recordingStatus | 트랙별 수신·녹화 상태. CCTV 레벨 ingestStatus는 트랙을 묶은 요약이다. | lastFrameAtWallMs / bitrateKbps / fps / codec | CCTV에서 받는 쪽 지표다. | lastError | CCTV 단위의 마지막 오류. 없으면 null. streaming.streams.list의 codec / fps는 스트리밍 엔진이 내보내는 값이다. 입력을 볼 때는 이 action, 송출을 볼 때는 7장을 쓴다. 요청 { "requestId": "req-104", "action": "cameras.runtimeStatus.list", "params": { "cameraIds": "all" } } 응답 { "requestId": "req-104", "ok": true, "result": { "items": [ { "cameraId": "00000038", "ingestStatus": "online", "streams": [ { "trackType": 0, "ingestStatus": "online", "recordingStatus": "recording", "lastFrameAtWallMs": 1780282799900, "bitrateKbps": 4096, "fps": 29.8, "codec": "H264" }, { "trackType": 1, "ingestStatus": "offline", "recordingStatus": "stopped", "lastFrameAtWallMs": 1780281000000, "bitrateKbps": 0, "fps": 0.0, "codec": "H264" } ], "lastError": null } ] } } bitrateKbps는 누적 송출 바이트 수의 증분과 경과 시간으로 계산한 값이다. 등록된 CCTV는 기본적으로 항상 녹화 대상이다. 배정을 유지한 채 영구히 끄려면 10장 recording.schedules를 mode: "disabled"로 설정한다. 배정 자체를 해제해도 멈춘다. 녹화는 등록과 동시에 시작되고 트랙 단위로 독립 동작한다. 들어오는 트랙만 기록되며, 한쪽 소스가 끊기면 그 트랙만 멈췄다가 다시 들어오면 재개된다. 따라서 상태와 구간은 항상 트랙별로 조회한다. [h2] 스트리밍7 송출 stream의 채널 제어, 식별, 접근 URL, 시간 기준을 다루는 action이다. 영상 전달 자체는 13장이 담당한다. streaming.channels.list CCTV별 트랙 송출 허용 여부를 조회한다. CCTV 자체의 enabled와 별개이며, 둘 다 켜져야 송출한다. 요청 { "requestId": "req-110", "action": "streaming.channels.list", "params": { "cameraIds": "all" } } 응답 { "requestId": "req-110", "ok": true, "result": { "items": [ { "cameraId": "00000038", "channels": [ { "trackType": 0, "enabled": true }, { "trackType": 1, "enabled": false } ] } ] } } streaming.channels.setEnabled 트랙 단위로 송출을 켜고 끈다. trackType 1만 꺼서 대역폭을 줄이는 용도다. 요청 { "requestId": "req-111", "action": "streaming.channels.setEnabled", "params": { "cameraId": "00000038", "trackType": 1, "enabled": false } } 응답 { "requestId": "req-111", "ok": true, "result": { "cameraId": "00000038", "trackType": 1, "enabled": false } } 끄면 해당 채널 stream은 Disabled로 전이하고 시청 중인 세션은 끊긴다. 녹화에는 영향이 없다. streaming.streams.list 송출 중인 stream을 CCTV 단위로 반환한다. streams 배열에 트랙을 함께 담되 각각 독립 활성화되므로 state는 따로 표시한다. 접속 URL과 시간 기준은 아래 streams.info에서 조회한다. 요청 { "requestId": "req-302", "action": "streaming.streams.list", "params": { "cameraIds": "all", "trackTypes": "all" } } 응답 { "requestId": "req-302", "ok": true, "result": { "items": [ { "cameraId": "00000038", "cameraName": "1F Lobby", "streams": [ { "trackType": 0, "streamId": "camera_38_0", "state": "Publishing", "codec": "H264", "width": 1920, "height": 1080, "fps": 30.0, "lastPacketWallMs": 1783665092990 }, { "trackType": 1, "streamId": "camera_38_1", "state": "Disabled" } ] } ] } } | 파라미터 | 값 | 동작 | cameraIds | "all" | 담당 CCTV 전체를 반환한다. | cameraIds | ["00000001", "00000038"] | 지정한 CCTV만 반환한다. | trackTypes | "all" 또는 [0] | 스트림 종류를 거른다. 생략하면 all과 동일. | 필드 | 설명 | state | Idle / WaitingKeyframe / Publishing / SourceLost / Disabled. Publishing만 시청 가능. | codec / width / height / fps | state가 Publishing이 아니면 값이 없어 생략한다. | lastPacketWallMs | 마지막 패킷 수신 시각. state는 세션 오류로 전이하므로, 연결은 살아 있는데 프레임만 멈춘 경우는 이 값의 정체로 판단한다. streaming.streams.info CCTV 단위로 접속 URL과 시간 기준을 반환한다. AI는 rtspUrl, Client는 webRtcUrl을 쓴다. cameraId는 필수이며 "all"이면 전체를 items 배열로 반환한다. 요청 { "requestId": "req-305", "action": "streaming.streams.info", "params": { "cameraId": "00000038" } } 응답 { "requestId": "req-305", "ok": true, "result": { "cameraId": "00000038", "streams": [ { "trackType": 0, "streamId": "camera_38_0", "state": "Publishing", "rtspUrl": "rtsp://192.168.0.20:8554/ivpm/camera_38_0?token=9f2c7a1e4b8d3a05e7", "whepUrl": "http://192.168.0.20:8888/whep/ivpm/camera_38_0?token=9f2c7a1e4b8d3a05e7", "webRtcUrl": "http://192.168.0.20:8888/index/api/webrtc?app=ivpm&stream=camera_38_0&type=play&token=9f2c7a1e4b8d3a05e7", "startWallTimeMs": 1783665091525, "rtpClockRate": 90000 }, { "trackType": 1, "streamId": "camera_38_1", "state": "Publishing", "rtspUrl": "rtsp://192.168.0.20:8554/ivpm/camera_38_1?token=9f2c7a1e4b8d3a05e7", "whepUrl": "http://192.168.0.20:8888/whep/ivpm/camera_38_1?token=9f2c7a1e4b8d3a05e7", "webRtcUrl": "http://192.168.0.20:8888/index/api/webrtc?app=ivpm&stream=camera_38_1&type=play&token=9f2c7a1e4b8d3a05e7", "startWallTimeMs": 1783665091530, "rtpClockRate": 90000 } ] } } URL의 host는 스트리밍 엔진 설정의 streamingBindIp다. 어떤 경우에도 0.0.0.0이나 루프백 주소를 싣지 않는다. whepUrl은 WHEP 표준 signaling, webRtcUrl은 스트리밍 엔진의 JSON signaling이다. 둘 다 같은 스트림을 가리키며 클라이언트가 지원하는 쪽을 고르면 된다. 두 URL 모두 평문 HTTP다. 반환 URL에는 계정 자격증명을 포함하지 않는다. 대신 스트리밍 엔진이 기동 시 발급한 streamingToken이 token 쿼리 파라미터로 이미 붙어 있으므로 소비자는 이 URL을 그대로 사용한다. 토큰은 스트리밍 엔진이 재시작하면 바뀌므로, 401을 받으면 이 action을 다시 호출해 URL을 갱신한다. 상세는 13장 참조. Media Server는 압축 영상의 전달 순서를 유지하며, 이 시간 처리 과정에서 디코딩·재인코딩하거나 프레임을 PTS 순서로 다시 배치하지 않는다. 원본 PTS의 프레임 간 시간 간격을 유지하고, DTS가 없거나 유효하지 않으면 필요한 시간값을 보완한다. 구체적인 보완 도구는 구현에서 결정한다. [h3] 시간 기준 [h4] startWallTimeMs 정의 startWallTimeMs는 프레임 시각 계산용 기준값이며, 해당 RTP 구간의 timestamp 0에 대응하는 epoch milliseconds다. Plus는 기준 프레임의 수신 시각에서 해당 송출 RTP 값에 해당하는 시간을 빼서 제공한다. 첫 송출 RTP timestamp를 0으로 강제하지 않는다. startWallTimeMs가 0이면 아직 기준이 확립되지 않은 상태이므로 계산하지 않는다. startWallTimeMs = referenceReceiveWallTimeMs - referenceRtpTimestamp * 1000 / rtpClockRate referenceReceiveWallTimeMs와 referenceRtpTimestamp는 동일한 기준 프레임의 값이다. 위 두 이름은 계산 설명용이며 API 필드를 추가하는 것은 아니다. 수신 시각은 해당 프레임에 연결된 Plus 수신 시각을 사용하며, 내부 버퍼에서 나오는 시각으로 바꾸지 않는다. 이후 프레임 시각은 매번 수신 시각으로 다시 만들지 않고 원본 PTS 시간 간격을 따른다. [h4] 절대시각 계산 AI·Client는 해당 프레임에 적용되는 기준값으로 계산한다. absoluteMs = startWallTimeMs + rtpTimestamp * 1000 / rtpClockRate 예: 기준 프레임 수신 시각이 10:00:00.000이고 송출 RTP가 5940, clock rate가 90000이면 startWallTimeMs는 09:59:59.934에 해당하는 epoch milliseconds다. 수신측은 66ms를 더해 첫 프레임 시각 10:00:00.000을 얻는다. 이는 영상 내용을 변경하는 것이 아니라 계산 기준을 제공하는 것이다. rtpTimestamp는 실제 수신 영상의 32-bit 값이며 영상 PTS에 대응한다. 같은 프레임의 RTSP·WebRTC(WHEP 포함)가 동일한 RTP 시간축을 제공해야 이 공통 기준을 사용할 수 있다. 이는 구현 검증 조건이며, 검증 없이 프로토콜별 offset이 없다고 가정하지 않는다. AI는 디코딩 후 실제 분석한 프레임에 연결된 RTP timestamp를 사용한다. 마지막 수신 패킷이나 추론 완료 시각을 대신 사용하지 않는다. Client는 WebRTC 표시 프레임의 requestVideoFrameCallback 메타데이터에서 rtpTimestamp를 얻는다. 메타데이터를 얻지 못한 경우 도착 시각으로 대체해 정확한 프레임 매칭이라고 취급하지 않는다. 같은 스트림·타임라인의 계산 시각으로 영상과 AnchorBox를 매칭한다. [h4] 특성과 한계 | 항목 | 영향 | 원본 시간축 | 원본 PTS 간격을 유지하므로 원본 clock drift·시각 불연속의 영향을 받을 수 있다. | 촬영 시각 | 기준 프레임은 Plus 수신 시각에 연결되며 실제 촬영 시각을 보장하지 않는다. 카메라 인코딩·전송 지연이 포함된다. | 수신 지터 | 매 프레임의 수신 지터로 PTS를 다시 만들지 않는다. 기준 프레임의 수신 지연과 실제 표시 지연은 별도로 남는다. | 정밀도 | API 절대시각 단위는 ms이며 밀리초 미만은 버린다. 90kHz tick 자체가 촬영 시각 정확도를 보장하지 않는다. [h4] RTP 감소 · wrap · 재연결 압축 영상의 전달 순서와 표시 순서는 다를 수 있으므로 RTP timestamp가 이전 값보다 작아질 수 있다. Media Server가 프레임 순서를 바꾸는 것은 아니다. 수신측은 값의 감소만으로 wrap을 확정하지 않는다. 32-bit timestamp의 한 주기는 90kHz 기준 약 13.25시간이다. 최초 값이 0이 아니면 첫 wrap까지의 시간은 더 짧을 수 있다. wrap·소스 재연결·시간 불연속 전후에는 각 프레임에 맞는 시간 기준을 사용해야 하며, 이전 버퍼 프레임에 새 기준을 적용하거나 이전 타임라인의 AnchorBox를 새 영상에 매칭하면 안 된다. wrap·재연결 전후의 기준을 구분하는 식별 정보와 적용 경계 계약은 아직 미확정이다. 최신 startWallTimeMs 재조회만으로 이전·새 프레임을 구분할 수 있다고 보장하지 않는다. AI는 streaming.streams.info, Client는 Web Server의 /api/cameras/{cameraId}/live로 기준을 조회하며, 장시간·재연결 시 프레임 매칭의 완료 조건은 이 계약 확정과 실제 송출 검증이다. streaming.server.status 스트리밍 엔진 자체의 상태와 실제 바인딩된 포트를 조회한다. 소비자는 포트를 가정하지 말고 이 값을 확인한다. 요청 { "requestId": "req-402", "action": "streaming.server.status", "params": { } } 응답 { "requestId": "req-402", "ok": true, "result": { "enabled": true, "running": true, "streamingBindIp": "192.168.30.42", "rtspOutputMode": "plain", "startedAtWallMs": 1783665000000, "protocols": { "rtsp": { "enabled": true, "boundPort": 8554 }, "http": { "enabled": true, "boundPort": 8888 }, "rtc": { "enabled": false, "boundPort": 0 } }, "streamCount": 128, "publishingCount": 126, "viewerCount": 14, "streamingToken": "9f2c7a1e4b8d3a05e7", "streamingTokenIssuedAtWallMs": 1783665000000, "lastError": "rtc server start failed" } } 프로토콜은 개별적으로 실패할 수 있다. RTC 바인딩이 실패해도 running은 true, status.get은 READY다. AI의 RTSP는 정상인데 Client WebRTC만 전부 실패하는 상태이므로 원인 판단에는 protocols를 봐야 한다. 이 응답의 streamingBindIp는 진단값이다. 접속 URL은 streaming.streams.info가 반환하는 것을 그대로 쓴다. 값이 0.0.0.0이면 IP 미확정으로 서버를 시작하지 않은 상태이며 enabled·running이 모두 false다. 오류가 아니라 정상적인 미설정 상태이고, IP를 감지하면 다음 로그인에서 복구된다. streaming.server.settings.get 스트리밍 엔진의 저장된 설정을 조회한다. streaming.server.status가 실제 실행 결과를 반환하는 것과 달리 이 action은 다음 시작 또는 재시작에 적용할 설정값을 반환한다. 요청 { "requestId": "req-403", "action": "streaming.server.settings.get", "params": { } } 응답 { "requestId": "req-403", "ok": true, "result": { "enabled": true, "streamingBindIp": "192.168.0.20", "rtspOutputMode": "plain", "ports": { "rtsp": 8554, "http": 8888, "rtc": 8000 }, "revision": 7 } } revision은 put으로 받은 번호를 그대로 돌려준 값이다. 아직 받은 적이 없으면 0이다. streaming.server.settings.put 스트리밍 엔진 설정을 변경한다. 지정한 필드만 반영한다. 실행 중 포트, 바인딩 IP 또는 RTSP 송출 방식을 변경하면 스트리밍 엔진을 재시작하므로 모든 RTSP/WebRTC 연결이 끊긴다. ingest와 녹화에는 영향이 없다. 요청 { "requestId": "req-404", "action": "streaming.server.settings.put", "params": { "revision": 7, "enabled": true, "streamingBindIp": "192.168.0.20", "rtspOutputMode": "plain", "ports": { "rtsp": 8554, "http": 8888, "rtc": 8000 } } } 응답 { "requestId": "req-404", "ok": true, "result": { "saved": true, "restarted": true, "restartedAtWallMs": 1783665200000 } } revision은 Web Server가 올린 설정 번호다. Media Server는 설정과 같은 트랜잭션으로 저장해 settings.get에 그대로 돌려주며, 저장된 값보다 작은 revision(늦게 도착한 이전 요청)은 적용하지 않고 "applied": false로 응답한다. rtspOutputMode는 plain 또는 encrypted다. 포트는 1~65535 범위다. streamingBindIp는 스트리밍 엔진에 실제 할당된 비루프백 IPv4여야 하며 이 action으로는 0.0.0.0을 받지 않는다. 바인딩 주소이자 URL host로 그대로 쓰이므로 지정되지 않은 주소가 들어가면 접속 불가능한 URL이 나간다. 스트리밍 엔진 내부에서 0.0.0.0은 "스트리밍 IP 미확정" sentinel이다. 이 값이면 스트리밍 엔진을 시작하지 않으며 ingest·녹화·재생은 영향받지 않는다. managed 구성에서 이 action은 웹 관리자가 저장한 설정을 Web Server가 반영할 때 호출한다. Media Server 자신의 UI에서는 스트리밍 설정을 변경하거나 웹에 저장 요청할 수 없다. 실행 실패는 상태·오류로 보고하고 웹의 desired 설정은 덮어쓰지 않는다. streaming.clients.list 현재 스트리밍 엔진에 접속한 RTSP/WebRTC client 세션을 반환한다. 동일 IP에서 여러 트랙 또는 여러 player가 접속하면 각각 별도 항목으로 반환한다. 요청 { "requestId": "req-405", "action": "streaming.clients.list", "params": { "cameraId": "all" } } 응답 { "requestId": "req-405", "ok": true, "result": { "items": [ { "sessionId": "rtsp:7f18...", "cameraId": "00000038", "trackType": 0, "protocol": "rtsp", "transport": "tcp", "clientIp": "192.168.0.51", "clientPort": 53120, "connectedAtWallMs": 1783665100000 }, { "sessionId": "rtc:9a31...", "cameraId": "00000038", "trackType": 0, "protocol": "webrtc", "transport": "udp", "clientIp": "192.168.0.51", "clientPort": 54832, "connectedAtWallMs": 1783665110000 } ] } } 여기의 sessionId는 스트리밍 엔진이 발급하는 값이라 8자리 형식을 따르지 않는다. recplay-sessions의 sessionId와는 별개다. RTSP와 WebRTC의 세션 종료 감지는 ZLMediaKit session/transport lifecycle을 기준으로 한다. viewerCount와 목록 개수는 조회 시점 차이로 순간적으로 다를 수 있다. streaming.server.restart 스트리밍 하위 시스템만 재시작한다. Media Server 프로세스 전체를 내리지 않고 송출 계층만 되살릴 때 사용한다. 요청 { "requestId": "req-401", "action": "streaming.server.restart", "params": { "reason": "port_rebind" } } 응답 { "requestId": "req-401", "ok": true, "result": { "restarted": true, "restartedAtWallMs": 1783665200000 } } 재시작 중 모든 송출이 끊긴다. WebRTC 시청과 AI의 RTSP 수신은 재접속해야 하나, ingest와 녹화는 별도 계층이라 영향받지 않는다. 재시작 후 스트림은 Idle에서 시작하고 키프레임 수신 시점에 startWallTimeMs가 새로 설정되므로, 소비자는 시간 기준을 다시 조회한다. 재시작 시 streamingToken을 새로 발급하며 이전 토큰은 즉시 무효가 된다. 이전 URL로 오는 요청은 401로 거부되므로, 소비자는 streaming.streams.info를 다시 호출해 URL을 갱신한다. 운영 시간대를 피해 호출하며, requester와 reason을 audit에 기록한다. 요청된 RTSP·HTTP·RTC 포트가 모두 정상 기동한 경우에만 성공한다. RTC만 실패한 경우에도 STREAMING_SERVER_UNAVAILABLE(retryable: true)을 반환하고 STREAMING_RESTART_FAILED를 기록한다. 정상 기동한 RTSP·HTTP와 저장된 설정은 유지하며, 실제 부분 실행 상태는 streaming.server.status로 조회한다. 매니저 모드의 주기 동기화에서 RTC를 재시도하며 복구되면 STREAMING_PROTOCOL_RECOVERED를 전송한다. [h2] ONVIF8 Media Server가 카메라와 통신할 때 ONVIF, 제조사 SDK 등을 내부에서 알아서 골라 쓴다. 이 장은 그 결과로 제공하는 세 기능 — 카메라 검색·기능 조회, PTZ 제어, 이미징 — 을 다룬다. 공통 규칙 기능 지원 여부는 supported / unsupported / unknown 셋 중 하나로 답한다. 인증 실패나 통신 실패는 unsupported로 취급하지 않는다 — 실제로 기능이 없는 것과 지금 확인할 수 없는 것을 구분해야 재시도할지 포기할지 판단할 수 있기 때문이다. PTZ·주야간·포커스는 각각 독립적으로 판정한다. 하나가 unsupported여도 나머지엔 영향 없다. 이동 요청(ptz.move)의 응답은 카메라가 명령을 수락한 시점이다. 이동이 실제로 끝났다는 뜻은 아니지만, 이번 범위는 위치·이동 상태 조회를 두지 않으므로 완료 여부를 별도로 확인할 방법은 없다. 카메라 한 대의 기능 조회 실패가 Media Server 전체 READY를 막지 않는다 — cameras.capabilities.*는 카메라별 독립 조회다. ONVIF 요청 처리 중에도 같은 WebSocket에서 정지 요청을 수신·처리한다. 응답은 요청 순서와 다르게 도착할 수 있으므로 requestId로 대응시킨다. 연결당 일반 ONVIF 처리 중 요청은 최대 32개이며 정지 요청을 위한 여유를 포함해 최대 64개까지 허용한다. [h3] 카메라 검색 · 기능 조회8.1 네트워크의 ONVIF 카메라를 찾아 등록을 돕고, 이미 등록된 카메라가 어떤 ONVIF 기능을 지원하는지 조회한다. ONVIF는 켜고 끄는 스위치가 없다. Media Server는 등록된 모든 카메라에 ipAddress·onvifPort와 등록된 자격증명으로 ONVIF 연결을 자동으로 시도하고, 서비스 주소·미디어 프로필은 그 결과로 내부적으로 확인·보관한다 — Web Server는 onvifVideoSourceToken(아래 참고) 외에는 이 값을 모르며 저장하지도 않는다. 연결·인증에 실패해도 RTSP 수신·녹화에는 영향이 없고, 실패 원인은 cameras.onvif.status.get으로 조회한다. 기능 조회 action(cameras.capabilities.*, cameras.onvif.status.get)은 이미 등록된 카메라를 대상으로 한다. NVR·스트리밍서버처럼 여러 카메라가 같은 ipAddress·onvifPort를 공유하는 장치는, 등록 시 받은 onvifVideoSourceToken(camera-assignments.create/.update, 9.1 참고)으로 그 카메라가 가리키는 VideoSource(영상이 실제로 나오는 채널 하나)를 구분해 PTZ·이미징 명령을 그 채널로 보낸다. onvifVideoSourceToken이 없으면(보통의 단일 채널 카메라) 지금처럼 ipAddress·onvifPort만으로 자동 연결한다. [h4] cameras.discover Media Server의 모든 로컬 NIC로 ONVIF WS-Discovery 프로브를 보내 장치를 찾고, 찾은 장치마다 공통 자격증명으로 바로 인증·정보 조회까지 시도한다. 검색과 개별 인증을 한 번에 끝내 등록 화면에서 곧바로 대부분의 카메라를 확인할 수 있게 하기 위해서다. 장치 하나가 채널을 여러 개 가질 수 있다(NVR·스트리밍서버처럼 하나의 ONVIF 장치 뒤에 카메라가 여러 대 물린 경우). GetProfiles로 받은 프로필들을 각 프로필의 VideoSourceConfiguration이 가리키는 SourceToken(= VideoSource 토큰) 기준으로 묶어, 같은 소스를 쓰는 프로필(main/sub)은 한 항목으로, 다른 소스의 프로필은 별도 항목으로 반환한다 — 항목 수는 "장치 수"가 아니라 "채널 수"다. 보통의 단일 채널 IP 카메라는 지금과 똑같이 장치당 항목 하나가 나온다. videoSourceToken은 설정 객체(VideoSourceConfiguration)의 token이 아니라 이 VideoSource 토큰이다. 요청 { "requestId": "req-170", "action": "cameras.discover", "params": { "timeoutMs": 3000, "auth": { "deviceId": "admin", "devicePw": "Admin11!!" } } } 응답 { "requestId": "req-170", "ok": true, "result": { "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 }, { "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": "카메라 인증에 실패했습니다." } } ] } } timeoutMs는 네트워크 탐색 대기시간이다. 생략하면 3000(3초). 그 시간 동안 응답한 장치 목록을 먼저 모은다. 목록을 모은 뒤, 발견된 장치 전체에 동시에(병렬로) auth로 인증·GetProfiles/GetStreamUri를 시도한다. 장치 한 대당 최대 5초까지 기다리며, 병렬 처리라 발견 대수가 늘어도 전체 소요 시간은 timeoutMs + 5초 안팎으로 유지된다. authStatus는 셋 중 하나다: AUTHENTICATED(입력한 자격증명으로 인증 성공), AUTH_FAILED(장치가 인증을 거절), UNKNOWN(시간 초과 등으로 확인 자체를 못함). 카메라 한 대가 실패해도 이 action 전체는 ok: true로 응답하며, 실패는 그 항목의 error에만 담는다. model은 장치가 알려줄 때만 채워지는 선택 정보다. macAddress도 확인되면 값을, 확인하지 못하면 null을 반환한다 — MAC을 못 얻었다고 인증 실패로 취급하지 않는다. 즉 authStatus: "AUTHENTICATED"이면서 macAddress: null인 경우가 정상적으로 있을 수 있다. streams는 authStatus가 AUTHENTICATED이고 GetProfiles/GetStreamUri까지 성공했을 때만 채워진다. 인증에는 성공했지만 스트림 조회 자체가 실패하면 authStatus는 AUTHENTICATED를 유지하고 streams는 빈 배열, error에 STREAM_UNAVAILABLE을 담는다 — 인증 실패(AUTH_FAILED)와는 다른 원인이므로 구분한다. 그 외(AUTH_FAILED/UNKNOWN)에는 streams가 빈 배열이다. videoSourceToken은 VideoSource 토큰이며, 이 장치가 여러 채널을 가진 경우에만 값이 있고, 단일 채널 장치는 null이다. 예를 들어 16채널 NVR 한 대는 같은 ipAddress·xaddr로 videoSourceToken이 "VideoSource_1", "VideoSource_2"... 인 항목이 최대 16개 나올 수 있다. 비밀번호는 응답 어디에도 싣지 않는다. 특정 NIC로 제한하지 않는다 — 모든 인터페이스로 프로브를 보내며, 다른 네트워크의 장치가 섞여 나와도 인증 단계에서 접속이 안 되면 authStatus: "UNKNOWN"(또는 AUTH_FAILED)으로 걸러진다. [h4] cameras.probe 검색 결과 중 인증에 실패하거나 확인하지 못한 카메라 한 대를, 고친 자격증명으로 다시 확인한다. 응답 형식은 cameras.discover 결과 항목 하나와 같다 — 등록 여부 판단(Web Server API 12.1)에 그대로 재사용한다. 요청 { "requestId": "req-171", "action": "cameras.probe", "params": { "ipAddress": "192.168.30.101", "onvifPort": 80, "xaddr": "http://192.168.30.101/onvif/device_service", "videoSourceToken": null, "auth": { "deviceId": "admin", "devicePw": "Corrected11!!" } } } 응답 { "requestId": "req-171", "ok": true, "result": { "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 } } xaddr은 선택 필드다. cameras.discover 응답의 값을 그대로 넘기면 재탐색 없이 바로 그 장치에 접속한다. 생략하면 ipAddress/onvifPort로 ONVIF Device 서비스 주소를 직접 구성한다. 등록 설정에 새로 저장하는 필드가 아니다 — 재확인에만 쓰고 버린다(discover와 마찬가지로 실제 등록 후에는 Media Server가 다시 자체적으로 확인해 내부에 보관한다, 8.1 인트로 참고). videoSourceToken도 선택 필드다. 다채널 장치의 특정 채널을 재확인할 때 cameras.discover 응답의 값을 그대로 넘긴다 — 생략하면 단일 채널 장치로 간주한다. authStatus가 AUTHENTICATED가 아니어도 이 action 자체는 ok: true이며, 실패 내용은 error에 담긴다 — 자격증명이 여전히 틀리면 authStatus: "AUTH_FAILED", 장치에 연결할 수 없으면 authStatus: "UNKNOWN"(error.code: "SERVER_UNREACHABLE")이다. 인증엔 성공했지만 스트림 조회가 실패하면 authStatus: "AUTHENTICATED"에 streams: [], error.code: "STREAM_UNAVAILABLE"이다(discover와 같은 규칙). 이 응답만으로는 CCTV가 등록되지 않는다. 관리자가 확인한 뒤 Web Server API POST /cameras를 별도로 호출해야 등록된다. [h4] cameras.capabilities.get 등록된 카메라의 ONVIF 기능별 지원 여부·허용 범위와 마지막 조회 시각을 반환한다. 캐시된 값을 반환하며, 최신 값이 필요하면 cameras.capabilities.refresh를 먼저 호출한다. 요청 { "requestId": "req-172", "action": "cameras.capabilities.get", "params": { "cameraId": "00000038" } } 응답 { "requestId": "req-172", "ok": true, "result": { "cameraId": "00000038", "ptz": { "status": "supported", "continuousMove": true, "pan": true, "tilt": true, "zoom": true, "home": "supported", "setHome": "supported", "maxPresets": 16, "panTiltSpaces": ["Velocity"], "zoomSpaces": ["Velocity"] }, "imaging": { "dayNight": { "status": "supported", "modes": ["day", "night", "auto"] }, "focus": { "status": "supported", "auto": true, "manual": true, "continuous": true, "absolute": true, "speed": { "min": -1.0, "max": 1.0 }, "position": { "min": 0.0, "max": 1.0 } } }, "checkedAtWallMs": 1780282800000 } } 아직 한 번도 확인하지 못했거나(등록 직후) ONVIF 연결·인증에 계속 실패하는 카메라는 ptz/imaging 전부 status: "unknown"이다 — unsupported가 아니다(8장 공통 규칙). [h4] cameras.capabilities.refresh 장치에서 지원 기능을 다시 조회해 캐시를 갱신한다. 요청 { "requestId": "req-173", "action": "cameras.capabilities.refresh", "params": { "cameraId": "00000038" } } 응답 { "requestId": "req-173", "ok": true, "result": { "cameraId": "00000038", "checkedAtWallMs": 1780282800000 } } 갱신된 값은 이후 cameras.capabilities.get으로 조회한다. 장치와 통신할 수 없으면 결과 항목별 status가 unknown으로 남고, 이 action 자체는 SERVER_UNREACHABLE이 아니라 ok: true로 응답한다 — 카메라 한 대의 조회 실패를 전체 실패로 키우지 않기 위해서다. [h4] cameras.onvif.status.get 카메라의 ONVIF 연결·인증·기능 조회 상태와 최근 오류를 반환한다. 요청 { "requestId": "req-174", "action": "cameras.onvif.status.get", "params": { "cameraId": "00000038" } } 응답 { "requestId": "req-174", "ok": true, "result": { "cameraId": "00000038", "connected": true, "authenticated": true, "lastCapabilitiesCheckAtWallMs": 1780282800000, "lastError": null } } connected/authenticated가 false면 lastError에 사유가 담긴다(예: 인증 실패, 접속 시간 초과) — Media Server는 실패해도 주기적으로 재시도하며, RTSP 수신·녹화에는 영향을 주지 않는다. [h3] PTZ 제어8.2 CCTV의 팬·틸트·줌은 연속 이동(ContinuousMove)으로 제어한다. 홈·프리셋 제어는 8.4의 MEDIA 확장 action을 사용한다. 위치·이동 상태 조회 action은 두지 않는다. 카메라가 PTZ를 지원하지 않는 것으로 확인됐으면(status: "unsupported") details.reason: "PTZ_UNSUPPORTED_BY_DEVICE"로 PTZ_NOT_SUPPORTED를 반환한다. 아직 지원 여부를 확인하지 못했으면(status: "unknown") PTZ_NOT_SUPPORTED가 아니라 ONVIF_UNAVAILABLE(retryable: true)을 반환한다 — 연결·인증 실패를 미지원으로 단정하지 않기 위해서다. 원인은 cameras.onvif.status.get의 lastError로 확인한다. 다채널 장치(VideoSource가 둘 이상)인데 등록된 onvifVideoSourceToken이 없거나 그 소스를 찾지 못하면, 임의로 첫 번째 채널을 제어하지 않고 ONVIF_CHANNEL_UNRESOLVED(retryable: false)를 반환한다. 소스를 찾으면 그 소스를 쓰는 프로필의 PTZ 설정으로 명령을 보낸다. [h4] ptz.move 연속 이동 명령이다. Client는 조작 버튼(마우스)을 누르는 동안 이동 명령을 짧은 간격으로 반복 전송하고, 떼는 순간 전부 0인 명령으로 정지시킨다. Media Server는 내부적으로 이 명령을 ONVIF ContinuousMove(0이 아닌 값)와 Stop(전부 0)으로 변환해 카메라에 보낸다. 요청 { "requestId": "req-160", "action": "ptz.move", "params": { "cameraId": "00000038", "pan": 0.5, "tilt": 0.0, "zoom": 0.0, "timeoutMs": 1000 } } 응답 { "requestId": "req-160", "ok": true, "result": { } } pan/tilt/zoom은 -1.0~1.0의 방향·속도값이다. 셋 다 0이면 정지 명령(Stop)이다. 양수 pan은 오른쪽, 양수 tilt는 위쪽, 양수 zoom은 줌인(망원) 방향이다. timeoutMs는 안전장치다. 생략하면 1000(1초), 범위는 200~5000이며 벗어나면 INVALID_PARAMS다. 이 시간 안에 같은 카메라로 새 ptz.move가 안 오면 Media Server가 자동으로 Stop을 호출한다 — 정지 명령이 유실돼도 카메라가 계속 움직이지 않게 하기 위해서다. Client는 누르고 있는 동안 timeoutMs의 3분의 1 이하 간격(기본값 기준 약 300ms)으로 반복 전송한다. 전부 0인 정지 명령은 이 타이머를 즉시 취소한다. 별도의 정지 action은 없다 — 이동을 멈추려면 전부 0으로 다시 호출한다. [h3] 이미징8.3 카메라의 주야간 모드와 포커스를 제어한다. 카메라가 지원하지 않는 것으로 확인됐으면(status: "unsupported") details.reason: "IMAGING_UNSUPPORTED_BY_DEVICE"로 IMAGING_NOT_SUPPORTED를 반환한다. 아직 지원 여부를 확인하지 못했으면(status: "unknown") PTZ와 같은 이유로 IMAGING_NOT_SUPPORTED 대신 ONVIF_UNAVAILABLE(retryable: true)을 반환한다(8.2 참고). 다채널 장치에서 대상 채널을 특정할 수 없을 때의 ONVIF_CHANNEL_UNRESOLVED도 PTZ와 같다(8.2 참고). [h4] imaging.dayNight.get 주간·야간·자동 설정을 조회한다. 요청 { "requestId": "req-180", "action": "imaging.dayNight.get", "params": { "cameraId": "00000038" } } 응답 { "requestId": "req-180", "ok": true, "result": { "mode": "auto" } } mode는 day / night / auto 중 하나다. [h4] imaging.dayNight.set 주간·야간·자동 설정을 변경한다. 요청 { "requestId": "req-181", "action": "imaging.dayNight.set", "params": { "cameraId": "00000038", "mode": "night" } } 응답 { "requestId": "req-181", "ok": true, "result": { "mode": "night" } } [h4] imaging.focus.get 포커스 모드와 현재 값을 조회한다. 요청 { "requestId": "req-182", "action": "imaging.focus.get", "params": { "cameraId": "00000038" } } 응답 { "requestId": "req-182", "ok": true, "result": { "mode": "auto", "position": 0.6 } } mode는 auto/manual이다. position은 현재 초점 위치(0.0~1.0)이며, 카메라가 값을 안 주면 null이다. [h4] imaging.focus.set 포커스 모드를 바꾸거나(수동일 때) 초점 위치를 직접 지정한다. 요청 { "requestId": "req-183", "action": "imaging.focus.set", "params": { "cameraId": "00000038", "mode": "manual", "position": 0.4 } } 응답 { "requestId": "req-183", "ok": true, "result": { "mode": "manual", "position": 0.4 } } mode: "auto"로 보내면 position은 무시한다. [h4] imaging.focus.move 연속 포커스 이동을 시작한다. 요청 { "requestId": "req-184", "action": "imaging.focus.move", "params": { "cameraId": "00000038", "speed": 0.5 } } 응답 { "requestId": "req-184", "ok": true, "result": { } } speed는 -1.0~1.0이며 0은 허용하지 않는다(정지는 imaging.focus.stop을 쓴다). 양수는 원거리(far), 음수는 근거리(near) 방향이다. 수동 포커스 모드가 아니면 IMAGING_FOCUS_MODE_INVALID를 반환한다. iVPM Plus는 연속 포커스 요청이 5초간 갱신되지 않으면 정지를 시도한다. 누르고 있는 동안 1초 이하 간격으로 갱신하고, 버튼을 놓으면 imaging.focus.stop을 즉시 호출한다. [h4] imaging.focus.stop 포커스 이동을 멈춘다. 요청 { "requestId": "req-185", "action": "imaging.focus.stop", "params": { "cameraId": "00000038" } } 응답 { "requestId": "req-185", "ok": true, "result": { } } [h3] 홈·프리셋 (iVPM Plus MEDIA 확장)8.4 아래 action은 기존 WSS 인증 및 READY 조건을 따른다. Web Server의 카메라·PTZ 권한 정책은 유지하며, Web Server 중계 구현은 별도다. 모두 cameraId 8자리 문자열이 필수다. | action | 추가 params | 성공 result | ptz.home.set | 없음 | {} | ptz.home.goto | 없음 | {} | ptz.presets.list | 없음 | { "items": [{ "token": "opaque-token", "name": "정문" }], "presetToken": "" } | ptz.presets.set | presetToken 선택(null/빈 값이면 생성), name 선택 | 재조회한 items, CCTV가 반환한 presetToken | ptz.presets.delete | presetToken 필수 | 재조회한 items, presetToken: "" | ptz.presets.goto | presetToken 필수 | {} 요청 { "requestId": "preset-save", "action": "ptz.presets.set", "params": { "cameraId": "00000038", "presetToken": null, "name": "iVPM 01" } } 프리셋은 CCTV에 저장한다. MEDIA DB에 좌표나 프리셋 번호를 별도로 저장하지 않는다. 토큰은 불투명 문자열이다. UI 1~8번을 토큰으로 가정하지 않는다. 저장·삭제 후 GetPresets를 다시 호출하며, 생성 시 카메라가 반환한 토큰을 사용한다. 없는 토큰은 PRESET_NOT_FOUND, 생성 한도 초과는 PRESET_LIMIT_REACHED다. 기능을 확인하지 못하면 ONVIF_UNAVAILABLE, 확인된 미지원은 PTZ_NOT_SUPPORTED다. 쓰기 성공 후 재조회 실패는 오류 응답의 details.applied: true로 구분한다. details.dispatched는 변경 요청을 시도했는지 나타낸다. 쓰기 요청을 보낸 뒤 오류가 났으면 자동으로 같은 저장/삭제를 반복하지 않고 목록을 재조회한다(retryable: false). 기능 조회에는 ptz.home/ptz.setHome(supported/unsupported/unknown), ptz.maxPresets(-1이면 미확인), ptz.panTiltSpaces/ptz.zoomSpaces, imaging.dayNight.modes, imaging.focus.continuous/absolute 및 speed/position 범위가 추가된다. ONVIF_BUSY는 동시 요청 한도 또는 설정 변경 정리 중, ONVIF_CONFIGURATION_CHANGED는 처리 중 카메라 설정 변경, ONVIF_STOP_FAILED는 자동 정지 재시도 실패다. 카메라 미지원·통신 실패는 다른 스트림의 RTSP/녹화 상태를 변경하지 않는다. [h2] 녹화 설정9 [h3] 상태 · 제어 [h4] recordings.status.get CCTV의 트랙별 녹화 상태를 조회한다. 요청 { "requestId": "req-120", "action": "recordings.status.get", "params": { "cameraId": "00000038" } } 응답 { "requestId": "req-120", "ok": true, "result": { "cameraId": "00000038", "streams": [ { "trackType": 0, "ingestStatus": "online", "recordingStatus": "recording", "currentSegmentStartedAtWallMs": 1780282800000, "lastWriteAtWallMs": 1780283390000, "lastError": null }, { "trackType": 1, "ingestStatus": "offline", "recordingStatus": "stopped", "currentSegmentStartedAtWallMs": null, "lastWriteAtWallMs": 1780281000000, "lastError": "source lost" } ] } } recordingStatus는 recording / starting / stopped / failed 중 하나다. 등록만 되고 아직 소스가 들어온 적 없는 트랙도 항목으로 반환하며 lastWriteAtWallMs가 null이 된다. [h4] recordings.start recordings.stop으로 일시 중지한 녹화를 다시 시작한다. 등록과 Media Server 재시작 시에는 자동으로 시작되므로 그때는 필요 없다. main과 sub가 함께 대상이며 트랙을 골라 시작할 수 없고, 이미 녹화 중이면 성공으로 처리하고 상태만 반환한다. 요청 { "requestId": "req-121", "action": "recordings.start", "params": { "cameraId": "00000038" } } 응답 { "requestId": "req-121", "ok": true, "result": { "cameraId": "00000038", "streams": [ { "trackType": 0, "recordingStatus": "starting" }, { "trackType": 1, "recordingStatus": "starting" } ] } } 소스가 아직 들어오지 않은 트랙은 starting에 머물다가 첫 패킷이 도착하면 recording이 된다. [h4] recordings.stop CCTV의 녹화를 일시 중지한다. 트랙을 골라 멈출 수 없고 main과 sub가 함께 멈춘다. 저장된 파일은 지우지 않는다. 중지 상태는 유지되지 않는다. Media Server를 재시작하면 등록된 CCTV의 녹화가 다시 시작된다. 요청 { "requestId": "req-122", "action": "recordings.stop", "params": { "cameraId": "00000038" } } 응답 { "requestId": "req-122", "ok": true, "result": { "cameraId": "00000038", "streams": [ { "trackType": 0, "recordingStatus": "stopped" }, { "trackType": 1, "recordingStatus": "stopped" } ] } } [h3] 녹화 조회 [h4] recordings.days.get 달력 UI용. 지정 기간에서 녹화가 존재하는 날짜만 반환한다. 요청 { "requestId": "req-123", "action": "recordings.days.get", "params": { "cameraId": "00000038", "fromMs": 1780272000000, "toMs": 1782864000000, "trackTypes": "all" } } 응답 { "requestId": "req-123", "ok": true, "result": { "items": [ { "trackType": 0, "days": ["2026-06-01", "2026-06-02", "2026-06-05"] }, { "trackType": 1, "days": ["2026-06-01", "2026-06-05"] } ] } } 날짜는 Media Server 로컬 타임존 기준이다. 트랙마다 녹화 여부가 다를 수 있으므로 트랙별로 반환한다. 구간 단위 조회는 recordings.ranges.get을 쓴다. [h4] recordings.ranges.get 녹화 가능 구간과 gap을 트랙 하나에 대해 반환한다. 녹화재생이 트랙 단위이므로 trackType은 필수다. gap이 있으면 녹화재생 위치만으로 절대시각을 구할 수 없으므로 recplay-sessions.create의 timelineMap과 같이 쓴다. 요청 { "requestId": "req-105", "action": "recordings.ranges.get", "params": { "cameraId": "00000038", "fromMs": 1780282800000, "toMs": 1780283400000, "trackType": 0 } } 응답 { "requestId": "req-105", "ok": true, "result": { "ranges": [ { "fromMs": 1780282800000, "toMs": 1780283130000 } ], "gaps": [ { "fromMs": 1780283130000, "toMs": 1780283170000, "reason": "no_recording" } ] } } gap threshold 기본값은 1000ms. 파일 간 overlap은 merge한다. corrupt file은 range에서 제외하고 reason=corrupt로 표시한다. reason 우선순위: no_recording > deleted > corrupt > camera_disconnected > unknown Client는 응답의 ranges를 타임라인에 표시하고, 화면에 실제로 그려진 마지막 ranges[].toMs를 timelineToMs로 기억해 둔다. recplay-sessions.create는 이 값을 넘지 않는다(11장 참고). Client는 보통 1분 간격으로 이 action을 다시 호출해 타임라인과 timelineToMs를 최신 상태로 갱신한다. [h3] 전역 설정 [h4] settings.recording.get 서버 전역 녹화 설정을 조회한다. CCTV별 설정이 아니다. 읽기 전용이다 — retentionDays/segmentMinutes/minFreeSpacePercent/recordAudio/recordBasePath는 디스크 용량·녹화 엔진과 직결된 로컬 운영값이라 Media Server 로컬 환경설정에서만 바꾼다. 외부에서 원격으로 바꾸는 API는 없다. 요청 { "requestId": "req-130", "action": "settings.recording.get", "params": { } } 응답 { "requestId": "req-130", "ok": true, "result": { "retentionDays": 30, "segmentMinutes": 60, "minFreeSpacePercent": 10, "recordAudio": false, "recordBasePath": "D:\\record" } } [h2] 녹화 스케줄10 CCTV별로 녹화를 켤 요일·시간대를 정한다. 정책은 다음과 같다. 스케줄이 없는 CCTV는 기존처럼 24시간 녹화한다. 스케줄은 CCTV 단위이며 main/sub에 함께 적용된다. 시간 판단은 Web Server가 아니라 Media Server 자신의 시각을 기준으로 한다. 요일은 ISO 기준 1=월요일 ~ 7=일요일이다. 구간은 [start, end)로 처리하며 자정을 넘는 구간은 두 구간으로 나눠 받는다. revision은 Web Server가 소유하고 올린다. Web Server가 recording.schedules.put에 새 revision을 실어 보내면 Media Server는 스케줄과 같은 트랜잭션으로 저장하고 list에 그대로 돌려준다. Media Server는 이 번호를 따로 올리지 않으므로 웹과 어긋나지 않는다. 저장된 값보다 작은 revision(늦게 도착한 이전 요청)은 적용하지 않고 무시한다. 동시 수정 충돌 검사(SCHEDULE_REVISION_CONFLICT)는 번호의 주인인 Web Server가 한다. 배정 해제로 스케줄 항목이 없어지는 경우(camera-assignments.delete, camera-assignments.sync의 누락 CCTV 정리)는 스케줄을 실제로 바꾼 게 아니므로 항목만 지우고 revision은 올리지 않는다. camera-groups.delete는 그룹 정리일 뿐 카메라 배정에는 영향이 없으므로(5장) 이 규칙과 무관하다. 로컬 모드에서 카메라를 직접 지울 때만 Media Server가 스스로 번호를 올린다. recordings.start/.stop(9장 참고)은 스케줄 자체를 바꾸지 않는 일시적 수동 제어다. weekly에서는 다음 스케줄 경계에서 스케줄 상태로 되돌아가고, always에는 다음 경계가 없으므로 재시작하거나 recordings.start를 다시 부를 때까지 멈춘 채로 남는다. Web Server 연결이 끊겨도 마지막으로 저장된 스케줄은 Media Server DB에서 계속 실행된다. 주간 반복만 지원한다. [h4] recording.schedules.list 요청 { "requestId": "req-schedule-001", "action": "recording.schedules.list", "params": { "cameraIds": "all" } } 특정 CCTV만 조회하려면 "cameraIds": ["00000001", "00000002"]처럼 배열로 보낸다. 응답 { "requestId": "req-schedule-001", "ok": true, "result": { "revision": 12, "timeZoneId": "Asia/Seoul", "items": [ { "cameraId": "00000001", "mode": "weekly", "periods": [ { "daysOfWeek": [1, 2, 3, 4, 5], "startMinuteOfDay": 480, "endMinuteOfDay": 1200 }, { "daysOfWeek": [6, 7], "startMinuteOfDay": 0, "endMinuteOfDay": 1440 } ], "shouldRecordNow": true, "nextTransitionAtWallMs": 1788298800000, "updatedAtWallMs": 1788230000000 } ] }, "serverTimeMs": 1788289200000 } mode는 셋 중 하나다. | 값 | 의미 | always | 항상 녹화 | weekly | periods에 해당할 때만 녹화 | disabled | 항상 녹화하지 않음 always·disabled는 periods가 빈 배열이다. startMinuteOfDay/endMinuteOfDay는 현지시각 00:00부터 잰 분 단위이며 시작은 포함·종료는 미포함이다(480=08:00, 1200=20:00, 1440=다음 날 00:00). [h4] recording.schedules.put 이 Media Server의 전체 녹화 스케줄을 한 요청으로 원자적으로 교체한다. items에 없는 CCTV는 스케줄이 없는 상태(24시간 녹화)가 되고, 빈 배열이면 모든 스케줄이 지워진다. 요청 { "requestId": "req-schedule-002", "action": "recording.schedules.put", "params": { "revision": 13, "timeZoneId": "Asia/Seoul", "items": [ { "cameraId": "00000001", "mode": "weekly", "periods": [ { "daysOfWeek": [1, 2, 3, 4, 5], "startMinuteOfDay": 480, "endMinuteOfDay": 1200 } ] }, { "cameraId": "00000002", "mode": "always", "periods": [] }, { "cameraId": "00000003", "mode": "disabled", "periods": [] } ] } } 응답 { "requestId": "req-schedule-002", "ok": true, "result": { "revision": 13, "applied": 3, "items": [ { "cameraId": "00000001", "shouldRecordNow": true, "nextTransitionAtWallMs": 1788298800000 }, { "cameraId": "00000002", "shouldRecordNow": true, "nextTransitionAtWallMs": null }, { "cameraId": "00000003", "shouldRecordNow": false, "nextTransitionAtWallMs": null } ] }, "serverTimeMs": 1788289200000 } 검증 순서: revision 비교(저장된 값보다 작으면 무시) → CCTV 존재 확인 → 시간대·구간 중복 확인 → 스케줄과 revision을 같은 트랜잭션으로 저장 → 현재 시각으로 shouldRecordNow 계산 → 기존 상태와 다르면 녹화 시작/중지. 하나라도 실패하면 요청 전체를 적용하지 않는다. 별도 삭제 action은 없다. 24시간 녹화로 되돌리려면 해당 CCTV를 items에서 빼거나 { "mode": "always", "periods": [] }로 보낸다. 저장된 값보다 작은 revision(늦게 도착한 이전 요청)은 오류가 아니다. 스케줄을 바꾸지 않고 현재 저장된 번호를 돌려준다. 응답 { "requestId": "req-schedule-002", "ok": true, "result": { "revision": 13, "applied": 0, "ignored": true }, "serverTimeMs": 1788289200000 } [h2] 녹화재생11 [h4] recplay-sessions.create 녹화재생 세션을 만든다. fromMs(녹화재생 시작)와 timelineToMs(Client가 recordings.ranges.get에서 마지막으로 확인한 녹화 상한)를 받는다. 세션은 fromMs~timelineToMs 전체를 유효 구간으로 갖지만, HLS 파일은 이 구간을 미리 다 만들지 않는다 — 초기 segment 몇 개만 먼저 만들어 READY로 전이하고, 이후 녹화재생이 진행되는 동안 manifest에 계속 segment를 추가하는 progressive 방식이다. timelineMap은 gap을 건너뛴 녹화재생 위치와 절대시각의 대응표다. 요청 { "requestId": "req-107", "action": "recplay-sessions.create", "params": { "cameraId": "00000038", "trackType": 0, "fromMs": 1780282800000, "timelineToMs": 1780283400000, "replaceSessionId": null } } 응답 { "requestId": "req-107", "ok": true, "result": { "sessionId": "00000001", "reused": false, "state": "PREPARING", "hlsUrl": "http://192.168.0.20:27001/recplay/c93a1f77e2b6485d90/index.m3u8", "windowFromMs": 1780282800000, "windowToMs": 1780283400000, "timelineToMs": 1780283400000, "seekOffsetMs": 0, "availableRanges": [ { "fromMs": 1780282800000, "toMs": 1780283100000 }, { "fromMs": 1780283160000, "toMs": 1780283400000 } ], "gaps": [ { "fromMs": 1780283100000, "toMs": 1780283160000, "reason": "no_recording" } ], "timelineMap": [ { "recplayOffsetMs": 0, "absoluteFromMs": 1780282800000, "absoluteToMs": 1780283100000 }, { "recplayOffsetMs": 300000, "absoluteFromMs": 1780283160000, "absoluteToMs": 1780283400000 } ], "expiresAtWallMs": 0 } } hlsUrl 경로의 토큰은 sessionId가 아니라 별도로 발급하는 recplayId다(13장 참고) — 추측이 어렵도록 충분한 엔트로피를 갖는 값이며, sessionId는 제어 채널에서만 쓰는 식별자다. windowFromMs는 고정이고 windowToMs(timelineToMs와 같은 값)는 recplay-sessions.refresh로 늘어날 수 있다. seekOffsetMs는 create에서만 의미가 있고(get/refresh에서는 항상 0) 요청한 fromMs가 HLS 타임라인상 몇 ms 지점인지를 나타낸다. reused는 같은 fromMs·timelineToMs로 같은 replaceSessionId를 다시 요청했을 때만 true다 — 구간 안 어디로든 재사용된다는 뜻이 아니라 중복 요청을 막기 위한 것이며, 그 외의 절대시각 이동은 항상 새 세션을 만든다. 필드: | 필드 | 필수 | 설명 | cameraId | O | 녹화재생할 CCTV | trackType | O | 0=main, 1=sub | fromMs | O | 녹화재생 시작 절대시각 | timelineToMs | O | Client가 마지막 recordings.ranges.get에서 확인한 녹화 상한. 세션이 다루는 유효 구간의 끝이다 | replaceSessionId | X | 기존 세션을 닫고 새 절대시각부터 다시 시작할 때 기존 sessionId를 전달 검증 순서: fromMs >= 0 → timelineToMs > fromMs → cameraId 존재 → trackType이 0/1 → fromMs~timelineToMs 사이에 녹화 데이터 존재. 하나라도 어긋나면 INVALID_PARAMS 또는 NO_RECORDING_IN_RANGE. state는 생성 직후 PREPARING이며 초기 segment를 만드는 중이다. hlsUrl은 이 시점에 이미 확정되어 내려오지만, recplay-sessions.get으로 READY 전이를 확인한 뒤 녹화재생을 시작한다. PREPARING 동안 expiresAtWallMs는 0이며, READY가 되어야 TTL(11장)이 시작되어 값이 채워진다. sessionId는 Media Server가 생성해 응답으로만 내려준다. Client는 녹화재생 화면(플레이어 인스턴스)마다 이를 보관한다. 다른 절대시각으로 이동할 때는 그 sessionId를 replaceSessionId로 넘겨 기존 세션을 닫고 새 세션을 만든다. 생략하면 기존 세션은 그대로 둔 채 새 세션을 추가로 만든다 — 서로 다른 화면(또는 다른 Client)이 각자 요청한 경우가 그렇다. Seek 규칙은 둘뿐이다. 현재 HLS manifest에 이미 올라온 구간: 플레이어가 API 호출 없이 자체적으로 이동한다(HLS 표준 seek). manifest 밖의 절대시각으로 이동: recplay-sessions.create를 fromMs=이동할 시각, replaceSessionId=기존 sessionId로 다시 호출한다. 기존 세션을 닫고 그 시각부터 새 progressive 세션을 만든다 — 재사용하지 않는다. 같은 세션이 manifest를 계속 갱신하며 다음 segment를 공급하므로, 녹화재생이 진행되는 동안 다음 구간을 미리 만들어 둘 필요가 없다. timelineToMs에 닿아 더 만들 데이터가 없으면, recordings.ranges.get을 다시 호출해 새로 확인한 상한을 recplay-sessions.refresh의 timelineToMs로 보낸다(11장 참고) — 세션을 새로 만들지 않고 같은 세션으로 이어본다. 새 세션이 필요한 건 절대시각을 건너뛰는 seek뿐이다. 녹화재생은 HLS만 지원한다. 원본이 H.264 호환이면 재인코딩 없이 stream-copy로 HLS segment를 만들고, H.265거나 stream-copy가 안 되면 복호화한 뒤 H.264/AAC로 재인코딩한다(OSD는 어느 경우든 붙지 않는다). 파일로 내려받는 것은 recexports가 담당한다. 세션 하나는 CCTV 하나를 담당한다. 웹 Client의 다채널 조회는 세션을 여러 개 만들어 처리하며 최대 16개 녹화재생 세션까지 동시에 만들 수 있다. 세션은 마지막 활동으로부터 60초 안에 갱신되지 않으면 EXPIRED로 전이하고 녹화재생 엔드포인트 접근이 막힌다. 갱신 규칙은 11장을 참고한다. [h3] 녹화재생 세션 처리 흐름 | 상태 | 의미 | PREPARING | 생성 요청이 수락되어 HLS manifest를 만드는 중. 녹화재생 접근 전. | READY | manifest 준비 완료. | ACTIVE | Client가 HLS endpoint에 접근 중. | EXPIRED | TTL 만료. | CLOSED | Web Server 요청 또는 Media Server 정책에 의해 정상 종료. | FAILED | storage/index 오류, 내부 오류로 생성 또는 유지 실패. 기본 전이는 PREPARING → READY → ACTIVE → CLOSED. 오류 시 PREPARING/READY/ACTIVE → FAILED. TTL 만료 시 READY/ACTIVE → EXPIRED (마지막 활동으로부터 60초 경과). PREPARING 동안은 TTL이 시작되지 않는다(11장 참고). 세션 하나는 fromMs~timelineToMs 전체를 유효 구간으로 갖고 progressive하게 segment를 생성한다(11장 참고). 저장 공간 부족이나 최대 session 수(16개) 초과 시 신규 생성을 거부한다. TTL 갱신 규칙 세션은 마지막 활동 시각을 기준으로 60초 TTL을 유지한다. 활동은 두 가지 중 하나로 인정하며, 어느 쪽이든 expiresAtWallMs를 호출·요청 시점 + 60초로 갱신한다. HLS 녹화재생 중에는 Client가 .m3u8 / .ts를 요청할 때마다 Media Server가 자동으로 활동 시각을 갱신하므로, 별도로 recplay-sessions.refresh를 호출할 필요가 없다. 일시정지 등으로 HLS 요청이 멈추면 자동 갱신이 일어나지 않으므로, Client가 30초 간격으로 recplay-sessions.refresh를 호출한다. TTL의 절반인 30초 주기를 쓰는 건 한 번의 호출이 유실되거나 지연돼도 다음 호출 전에 만료되지 않도록 여유를 두기 위해서다. 녹화재생을 끝내면 recplay-sessions.close를 호출해 명시적으로 종료한다. 60초 동안 HLS 요청과 refresh가 모두 없으면 EXPIRED로 전이함과 동시에 세션이 점유하던 임시 HLS 파일도 함께 삭제한다. recplay-sessions.refresh는 TTL만 연장하는 것과 별개로, timelineToMs를 함께 보내면 세션의 녹화재생 가능 상한 자체를 늘릴 수 있다 — 녹화 중인 CCTV를 계속 녹화재생할 때 새 세션 없이 이어보는 용도다. 상세 규칙은 해당 action 정의를 참고한다. recplay-sessions.get 세션 상태와 남은 TTL을 조회한다. create 직후 PREPARING이면 이 action으로 READY 전이를 폴링해 확인한다. PREPARING 동안 expiresAtWallMs는 0이다. 요청 { "requestId": "req-140", "action": "recplay-sessions.get", "params": { "sessionId": "00000001" } } 응답 { "requestId": "req-140", "ok": true, "result": { "sessionId": "00000001", "reused": false, "state": "ACTIVE", "hlsUrl": "http://192.168.0.20:27001/recplay/c93a1f77e2b6485d90/index.m3u8", "windowFromMs": 1780282800000, "windowToMs": 1780283400000, "timelineToMs": 1780283400000, "seekOffsetMs": 0, "availableRanges": [ { "fromMs": 1780282800000, "toMs": 1780283100000 }, { "fromMs": 1780283160000, "toMs": 1780283400000 } ], "gaps": [ { "fromMs": 1780283100000, "toMs": 1780283160000, "reason": "no_recording" } ], "timelineMap": [ { "recplayOffsetMs": 0, "absoluteFromMs": 1780282800000, "absoluteToMs": 1780283100000 }, { "recplayOffsetMs": 300000, "absoluteFromMs": 1780283160000, "absoluteToMs": 1780283400000 } ], "expiresAtWallMs": 1780283700000 } } 필드는 recplay-sessions.create 응답과 같다(reused/seekOffsetMs는 여기서 항상 false/0). recplay-sessions.refresh 마지막 활동 시각을 갱신해 60초 TTL을 연장한다. HLS 요청이 계속되는 동안은 자동으로 갱신되므로 호출할 필요가 없고, 일시정지 등으로 HLS 요청이 끊겼을 때만 30초 간격으로 호출한다. timelineToMs를 함께 보내면 세션의 녹화재생 가능 상한도 늘릴 수 있다. 녹화 중인 CCTV를 녹화재생하다가 timelineToMs에 도달했을 때, 새 세션을 만들지 않고 같은 세션·같은 hlsUrl로 계속 이어보기 위한 용도다. 요청 { "requestId": "req-141", "action": "recplay-sessions.refresh", "params": { "sessionId": "00000001", "timelineToMs": 1780283460000 } } 응답 { "requestId": "req-141", "ok": true, "result": { "sessionId": "00000001", "reused": false, "state": "ACTIVE", "hlsUrl": "http://192.168.0.20:27001/recplay/c93a1f77e2b6485d90/index.m3u8", "windowFromMs": 1780282800000, "windowToMs": 1780283460000, "timelineToMs": 1780283460000, "seekOffsetMs": 0, "availableRanges": [ { "fromMs": 1780282800000, "toMs": 1780283100000 }, { "fromMs": 1780283160000, "toMs": 1780283460000 } ], "gaps": [ { "fromMs": 1780283100000, "toMs": 1780283160000, "reason": "no_recording" } ], "timelineMap": [ { "recplayOffsetMs": 0, "absoluteFromMs": 1780282800000, "absoluteToMs": 1780283100000 }, { "recplayOffsetMs": 300000, "absoluteFromMs": 1780283160000, "absoluteToMs": 1780283460000 } ], "expiresAtWallMs": 1780283760000 } } 필드는 recplay-sessions.create 응답과 같다(reused/seekOffsetMs는 여기서 항상 false/0). timelineToMs는 선택 필드다. | 요청값 | 동작 | 생략 | 기존과 동일 — TTL만 60초 연장 | 기존 세션의 timelineToMs보다 큼 | 녹화재생 가능 상한을 그 값까지 늘린다 | 기존 값과 같음 | TTL만 연장 | 기존 값보다 작음 | INVALID_PARAMS — 상한은 줄어들 수 없다 | 실제 녹화된 최신 시각보다 큼 | 실제 최신 시각까지만 적용한다 (요청값 그대로 반영하지 않는다) sessionId와 hlsUrl은 그대로 유지된다. 응답의 timelineToMs는 요청값이 아니라 실제로 적용된 값이므로, Client는 이 값을 확인해서 자신의 타임라인 상태를 맞춘다. PREPARING/EXPIRED/CLOSED/FAILED 상태면 RECPLAY_SESSION_STATE_INVALID를 반환하며 details.state에 현재 상태가 담긴다 — READY/ACTIVE에서만 호출할 수 있다. PREPARING이면 아직 준비 중이므로 .../get으로 기다렸다가 READY 전이 후 다시 호출한다. EXPIRED/CLOSED/FAILED면 세션이 끝난 것이므로 새로 만든다. recplay-sessions.close 세션을 종료하고 녹화재생 엔드포인트 접근을 차단한다. 요청 { "requestId": "req-142", "action": "recplay-sessions.close", "params": { "sessionId": "00000001" } } 응답 { "requestId": "req-142", "ok": true, "result": { "sessionId": "00000001", "state": "CLOSED" } } TTL 만료를 기다리지 않고 즉시 자원을 회수한다. 없는 세션이면 RECPLAY_SESSION_NOT_FOUND를 반환한다. [h2] 내보내기12 [h4] recexports.estimate 내보내기 전에 예상 용량과 소요 시간을 계산한다. 파라미터 제약은 recexports.create와 같다. 요청 { "requestId": "req-150", "action": "recexports.estimate", "params": { "fromMs": 1780282800000, "toMs": 1780283100000, "trackType": 0, "cameras": [ { "cameraId": "00000038" }, { "cameraId": "00000039" } ] } } 응답 { "requestId": "req-150", "ok": true, "result": { "estimatedBytes": 306000000, "estimatedElapsedMs": 12000, "freeBytes": 2200000000000 } } estimatedBytes는 생성될 파일 크기 합계, estimatedElapsedMs는 내보내기 작업에 걸릴 시간이다. 영상 길이가 아니다. [h4] recexports.create 내보내기 작업을 만든다. 비동기이며 즉시 recexportId를 반환한다. 진행률과 완료 여부는 recexports.get으로 확인한다. 요청 { "requestId": "req-151", "action": "recexports.create", "params": { "fromMs": 1780282800000, "toMs": 1780283100000, "trackType": 0, "cameras": [ { "cameraId": "00000038" }, { "cameraId": "00000039" } ], "encrypted": true, "password": "Export11!!", "validDays": 7, "requestedBy": "operator01" } } 응답 { "requestId": "req-151", "ok": true, "result": { "recexportId": "00000001", "state": "QUEUED", "cameraCount": 2 } } cameras는 최대 4개, 구간(toMs - fromMs)은 최대 5분이다. 초과하면 INVALID_PARAMS를 반환한다. 출력은 MP4이며 CCTV·트랙마다 파일이 하나씩 생성된다. 원본을 복호화·디코딩한 뒤 H.264/AAC로 재인코딩해 담는다. encrypted가 true면 password와 validDays가 필수다. 암호화 출력은 validDays가 지나면 열리지 않는다. false면 두 필드를 생략한다. 저장 공간이 부족하면 INSUFFICIENT_STORAGE를 반환한다. requestedBy는 내보내기를 요청한 Client 사용자의 accountId(loginId)다. Web Server는 자기 토큰으로 Media Server를 호출하므로 최종 사용자를 이 필드로 전달한다. Media Server는 audit에 기록하고 recexports.get / recexports.list 응답에 되돌려준다. 계정 확인은 Web Server가 수행하며 Media Server는 기록만 하고 거부하지 않는다. [h4] recexports.get 작업 상태와 진행률을 조회한다. 출력 파일은 CCTV·트랙마다 하나씩 생성되므로 downloadUrl도 항목별로 반환한다. 요청 { "requestId": "req-152", "action": "recexports.get", "params": { "recexportId": "00000001" } } 응답 { "requestId": "req-152", "ok": true, "result": { "recexportId": "00000001", "requestedBy": "operator01", "state": "RUNNING", "progressPercent": 42, "items": [ { "cameraId": "00000038", "trackType": 0, "state": "RUNNING", "exportedSegments": 0, "skippedSegments": 0, "exportedDurationMs": 0, "partialSuccess": false, "sizeBytes": 0, "downloadUrl": null }, { "cameraId": "00000039", "trackType": 0, "state": "RUNNING", "exportedSegments": 0, "skippedSegments": 0, "exportedDurationMs": 0, "partialSuccess": false, "sizeBytes": 0, "downloadUrl": null } ], "expiresAtWallMs": null } } progressPercent는 작업 전체의 진행률이다. 요청 구간 전체 길이 대비 지금까지 기록한 길이의 비율이며, CCTV가 여러 대면 모든 CCTV의 합산 기준이다. 0~100 정수다. state는 QUEUED / RUNNING / DONE / FAILED / CANCELED이며 작업 전체와 항목 각각에 붙는다. 항목별 결과(state, downloadUrl)는 내부 배치 처리가 전부 끝나야 한꺼번에 채워지며, 일부 CCTV만 먼저 완료돼 따로 받을 수는 없다. 작업 전체는 모든 항목이 끝나야 DONE이 되고 이때 expiresAtWallMs가 완료 시점 + 5분으로 채워진다. 실패한 CCTV만 골라 재시도하면 된다. skippedSegments는 손상되어 건너뛴 세그먼트 수이며, 0이 아니면 partialSuccess가 true가 된다. 녹화 공백은 여기 포함되지 않는다. 요청 구간에 녹화 공백이 있으면 그 길이만큼 출력에서 제거하고 앞뒤를 이어붙인다. 결과 파일은 끊김 없이 녹화재생되지만 녹화재생 위치와 실제 시각이 일치하지 않는다. downloadUrl의 경로에는 recexportId 대신 추측이 어려운 downloadToken을 쓴다. recexportId는 순번이라 그대로 노출하면 다른 사용자의 결과 파일 주소를 추측할 수 있기 때문이며, 엔트로피 요구는 recplayId와 같다. 완료 후 다운로드 유예 시간은 5분이다. expiresAtWallMs(완료 시점 + 5분)가 지나면 결과 파일을 삭제하고 토큰도 폐기해 이후 요청에는 404를 반환한다. [h4] recexports.list 진행 중이거나 아직 만료되지 않은 내보내기 목록이다. 요청 { "requestId": "req-153", "action": "recexports.list", "params": { } } 응답 { "requestId": "req-153", "ok": true, "result": { "items": [ { "recexportId": "00000001", "requestedBy": "operator01", "state": "DONE", "cameraCount": 2, "sizeBytes": 306000000, "expiresAtWallMs": 1780369200000 } ] } } [h4] recexports.cancel 진행 중인 작업을 중단하고 생성 중이던 파일을 삭제한다. 이미 DONE인 항목의 파일도 함께 삭제하므로, 받을 것이 있으면 먼저 내려받는다. 요청 { "requestId": "req-154", "action": "recexports.cancel", "params": { "recexportId": "00000001" } } 응답 { "requestId": "req-154", "ok": true, "result": { "recexportId": "00000001", "state": "CANCELED" } } [h2] 영상 접속13 제어 채널은 Opaque Token을 쓰지만 영상 엔드포인트는 소비자별로 인증이 다르다. Client가 Media Server에 직접 접근하는 유일한 경로다. 이 장은 CCTV 상태 조회가 아니다. CCTV의 online·recording·error 상태는 6장의 cameras.runtimeStatus.list로 조회한다. 아래 HTTP 경로의 base는 둘로 나뉘며 둘 다 평문이다. 제어 채널의 HTTPS 인증서는 이 포트들에 적용되지 않는다. recplayHttpPort 쪽 URL의 host는 controlIp, httpPort(ZLM) 쪽은 streamingBindIp다(7.1 참고). WebRTC/WHEP signaling: http://{host}:{httpPort}(기본 8888) — ZLM 스트리밍 엔진이 처리한다. HLS 녹화재생·내보내기 다운로드: http://{host}:{recplayHttpPort}(기본 27001) — 별도 Kestrel HTTP 서버가 처리하며, WSS 제어 채널과 기동·종료가 함께 묶인다(3.1 참고). | 소비자 | 경로 | 인증 | View Client | WebRTC / WHEP | 별도 로그인 없음. Web Server에서 받은 URL의 streamingToken으로 접속한다. | View Client | HLS, MP4 | 별도 로그인 없음. recplayId / downloadToken으로 세션 단위 통제한다. | AI Server | RTSP | 별도 로그인 없음. streams.info에서 받은 URL의 streamingToken으로 접속한다. 계정 자격증명은 검증하지 않는다. 제공하는 endpoint는 아래가 전부다. | Endpoint | 포트 | Method | 용도 | 인증 | /whep/ivpm/{streamId}?token={streamingToken} | httpPort | POST | Client가 실시간 영상을 시청할 때 쓰는 기본 signaling 경로다. 여기서 WebRTC 세션을 협상한다 | streamingToken | /index/api/webrtc?app=ivpm&stream={streamId}&type=play&token={streamingToken} | httpPort | POST | WHEP를 지원하지 않는 환경을 위한 대체 경로다. 같은 실시간 영상을 JSON 기반 signaling으로 협상한다 | streamingToken | /recplay/{recplayId}/index.m3u8 | recplayHttpPort | GET | Client가 녹화 영상을 녹화재생할 때 요청하는 HLS manifest다. 녹화재생이 진행되는 동안 progressive하게 계속 갱신된다 | 없음 | /recplay/{recplayId}/segment-{sequence}.ts | recplayHttpPort | GET | manifest가 가리키는 실제 영상 조각이다. 4초 단위 MPEG-TS 파일이며 녹화재생 위치에 맞춰 순서대로 요청된다 | 없음 | /recexports/{downloadToken}/{cameraId}_{trackType}.mp4 | recplayHttpPort | GET | 완료된 내보내기 작업의 결과 MP4 파일을 다운로드하는 경로다. 이어받기를 위한 Range 요청을 지원한다 | downloadToken 유효성·만료 확인 | rtsp://{host}:{rtspPort}/ivpm/{streamId}?token={streamingToken} | rtspPort | RTSP | AI Server가 실시간 분석을 위해 수신하는 RTSP 스트림이다. 브라우저 Client에는 제공하지 않는다 | streamingToken HLS 요청은 recplayId, 내보내기 MP4 요청은 downloadToken이 존재하고 만료되지 않았는지 확인한다. 두 값 모두 추측이 어렵도록 충분한 엔트로피를 갖는 값으로 생성한다. HLS manifest와 segment 요청은 모두 recplayId 유효성을 확인한다. manifest만 확인하고 segment를 열어두면 안 된다. Segment URL은 실제 storage path를 노출하지 않는다. HLS와 MP4 응답은 Cache-Control: no-store 또는 짧은 max-age를 사용한다. 브라우저 Client에는 RTSP를 제공하지 않는다. RTSP는 AI Server 전용이다. [h4] 스트리밍 토큰 스트리밍 엔진은 기동할 때 추측이 어려운 streamingToken을 하나 생성하고 프로세스가 살아 있는 동안 그 값을 유지한다. 실시간 스트림 요청은 쿼리 파라미터 token으로 이 값을 전달한다. rtsp://{host}:{rtspPort}/ivpm/{streamId}?token={streamingToken} http://{host}:{httpPort}/whep/ivpm/{streamId}?token={streamingToken} http://{host}:{httpPort}/index/api/webrtc?app=ivpm&stream={streamId}&type=play&token={streamingToken} 토큰이 없거나 값이 다르면 401로 거부한다. 검증은 최초 요청(RTSP는 DESCRIBE/SETUP, HTTP는 signaling POST) 시점에만 하고 RTP 패킷마다 하지 않는다. 이미 수립된 세션은 토큰이 바뀌어도 끊지 않는다. 다음 접속부터 새 토큰이 필요하다. 토큰은 스트리밍 엔진 기동 시마다 새로 발급한다. streaming.server.restart나 Media Server 프로세스 재시작이 일어나면 이전 토큰은 즉시 무효가 된다. 소비자는 streaming.streams.info가 반환하는 완성 URL을 그대로 쓰고, 401을 받으면 재조회한다. Web Server는 streaming.server.status의 streamingToken으로 URL을 직접 조립해도 된다. 계정 자격증명은 검증하지 않는다. rtsp://id:pw@host/path 형태로 id를 실어 보내면 audit에 기록만 한다. streamingToken은 이 스트리밍 엔진의 모든 실시간 스트림에 공통으로 적용되며 스트림·계정 단위로 나뉘지 않는다. 값이 노출되면 재시작 전까지 해당 스트리밍 엔진의 실시간 영상 전체가 열린다. 브라우저 Client도 이 값을 그대로 받으므로 사실상 공유 비밀이며, 막는 대상은 streamId 규칙(camera_{숫자 cameraId}_{trackType})을 이용한 무단 열거다. 계정 단위 통제는 적용하지 않는다. 내부망 배포를 전제로 한 설계다. HLS 녹화재생과 내보내기 다운로드에는 적용하지 않는다. recplayId와 downloadToken이 이미 세션 단위로 더 좁게 통제한다. [h4] WebRTC signaling offer/answer는 trickle ICE 없이 단일 요청/응답으로 끝난다. 양쪽 모두 ICE candidate를 모두 모은 뒤 주고받는다(vanilla ICE). [h2] 토큰 관리14 [h4] tokens.revoke Web Server가 토큰을 폐기했음을 알린다. Media Server는 해당 accountId의 캐시를 지우고 그 토큰으로 열린 연결을 닫는다. standalone에서는 캐시가 비어 있으므로 evicted: 0으로 성공 응답하고 아무것도 하지 않는다. 이후 재접속하면 캐시가 없어 Web Server에 재질의하고, 무효면 거부된다. 토큰 원문을 주고받지 않으려고 accountId 단위로 지정한다. 같은 accountId의 다른 토큰도 캐시에서 빠지지만 유효하면 재질의를 통과하므로 지장이 없다. Web Server는 AI Server에도 같은 목적의 통지를 보낸다. AI 역시 WSS 제어 채널 서버이며 그 계약은 별도 명세를 따른다. 요청 { "requestId": "req-501", "action": "tokens.revoke", "params": { "accountIds": ["ai-gpu-01"] } } 응답 { "requestId": "req-501", "ok": true, "result": { "evicted": 2, "closedConnections": 1 } } [h4] tokens.flushCache 캐시를 전부 비운다. 이후 모든 요청이 Web Server 재질의를 거치므로, 폐기 대상을 특정하기 어렵거나 Web Server DB를 일괄 변경한 뒤 전체를 다시 맞출 때 사용한다. standalone에서는 캐시가 비어 있으므로 evicted: 0으로 성공 응답한다. 열려 있는 연결은 닫지 않는다. 다만 3.5의 요청 단위 캐시 조회에 따라, 각 연결은 다음 action 요청에서 한 번 재질의를 거친다. 유효하면 다시 캐시되어 그대로 쓰이고, 무효면 그 요청이 TOKEN_REVOKED로 거부되면서 연결이 닫힌다. 즉 재접속을 기다리지 않고도 폐기된 토큰이 걸러진다. 연결을 즉시 끊어야 하면 tokens.revoke를 accountId별로 호출한다. 둘의 차이는 아래와 같다. | | tokens.revoke | tokens.flushCache | 대상 | 지정한 accountId | 캐시 전체 | 연결 | 대상 accountId의 연결을 즉시 닫는다 | 닫지 않는다 | 유효한 토큰 | 캐시에서 빠지지만 재접속하면 통과한다 | 다음 요청에서 재질의 후 계속 사용된다 | 쓰는 때 | 특정 계정을 즉시 차단할 때 | 계정 정보를 일괄 변경한 뒤 전체를 다시 맞출 때 요청 { "requestId": "req-502", "action": "tokens.flushCache", "params": { } } 응답 { "requestId": "req-502", "ok": true, "result": { "evicted": 17 } } 캐시를 비운 직후에는 모든 연결과 요청이 Web Server 유효성 검사 API로 몰린다. Web Server가 응답하지 못하면 INTROSPECTION_UNAVAILABLE로 전부 거부되므로, 운영 시간대를 피해 호출한다. [h2] 서버 상태 · 저장소15 health.get 프로세스 생존만 확인한다. CCTV나 저장소 상태를 보지 않으므로 항상 즉시 응답한다. 요청 { "requestId": "req-200", "action": "health.get", "params": { } } 응답 { "requestId": "req-200", "ok": true, "result": { "status": "OK" } } status.get readiness다. 저장 공간 부족이나 session 초과는 재시작 사유가 아니다. 대신 WSS 인증 뒤 부트스트랩·CCTV 동기화가 끝났는지를 status로 알려준다. status는 셋 중 하나다. | 값 | 의미 | BOOTSTRAPPING | WSS 인증은 끝났지만 부트스트랩·CCTV 동기화가 진행 중이다. 카메라 구성이 아직 확정되지 않았다. | READY | 부트스트랩 반영이 끝났다. 모든 action을 받는다. | NOT_READY | 부트스트랩이 실패해 대기 중이다. lastError에 사유가 담기며, 재시도가 성공하면 BOOTSTRAPPING을 거쳐 READY가 된다. 요청 { "requestId": "req-201", "action": "status.get", "params": { } } 응답 { "requestId": "req-201", "ok": true, "result": { "serverId": "MEDIA-01", "status": "READY", "assignedCameraCount": 100, "runtime": { "onlineCameraCount": 97, "recordingCameraCount": 96 }, "streamCounts": { "publishing": 126, "total": 128 }, "storage": { "writable": true, "usagePercent": 72.4 }, "clock": { "ntpSynced": true, "clockOffsetMs": 3 } } } usagePercent 85% 이상은 경고, 95% 이상은 신규 CCTV 배정 차단과 신규 세션 거부를 권장한다. clockOffsetMs가 허용치를 넘으면 녹화재생과 AI 이벤트 시각 동기화가 깨지므로 CLOCK_SKEW_TOO_LARGE로 경고한다. BOOTSTRAPPING·NOT_READY에서는 카메라 구성에 의존하는 assignedCameraCount, runtime, streamCounts를 생략한다 — 반영 중인 값이라 최종 결과로 오해할 수 있기 때문이다. serverId, status, storage, clock은 항상 반환하고, NOT_READY일 때만 lastError에 사유를 담는다. 응답 { "requestId": "req-201", "ok": true, "result": { "serverId": "MEDIA-01", "status": "BOOTSTRAPPING", "storage": { "writable": true, "usagePercent": 72.4 }, "clock": { "ntpSynced": true, "clockOffsetMs": 3 } } } [h4] 준비 상태에 따른 허용 action WSS 인증이 끝나면 아래 action은 준비 상태와 무관하게 언제든 받는다. 카메라 구성에 의존하지 않기 때문이다. | 구분 | action | 허용 시점 | 항상 허용 | health.get, status.get, version.get, system.time.get, control.server.status, storage.status.get, settings.recording.get, tokens.revoke, tokens.flushCache, cameras.discover, cameras.probe | WSS 인증 직후 | READY에서만 | cameras.capabilities.*, cameras.onvif.status.get, ptz.*, imaging.* (그 외 카메라 구성 의존 action과 동일) | 부트스트랩 반영 완료 후 | READY에서만 | camera-groups.*, camera-assignments.*, cameras.runtimeStatus.list, recordings.*, recording.schedules.*, streaming.*, ptz.*, recplay-sessions.*, recexports.*, camera-tests.* | 부트스트랩 반영 완료 후 READY가 아닐 때 READY에서만 받는 action을 호출하면 SERVER_NOT_READY(retryable: true)를 반환하며, details.status에 현재 status가 담긴다. 호출한 쪽은 이를 연결 실패로 취급하지 말고 WSS를 유지한 채 status.get을 폴링해 READY가 된 뒤 다시 호출한다. Media Server가 상태를 push하지 않는 원칙은 그대로다. tokens.flushCache는 로그아웃이 아니다. 로그인 토큰을 지우거나 연결을 끊지 않고 검증 캐시만 비우며, 다음 요청에서 Web Server의 /tokens/introspect로 다시 검증한다(14장). WSS 연결 성공, Media Server의 READY, Web Server의 초기 동기화 완료는 서로 다른 상태다. WSS가 열렸어도 초기화 중일 수 있으며, 어느 쪽도 상대의 최종 동기화 완료를 기다려야 자기 준비를 끝낼 수 있게 만들지 않는다. version.get 빌드와 API 버전이다. Web Server는 이 값으로 Media Server별 기능 차이를 판단한다. 요청 { "requestId": "req-202", "action": "version.get", "params": { } } 응답 { "requestId": "req-202", "ok": true, "result": { "apiVersion": "2.0", "build": "2026.06.19.1", "serverId": "MEDIA-01" } } system.time.get Media Server의 현재 시각과 시간대다. 응답 봉투의 serverTimeMs가 이미 현재 UTC epoch milliseconds이므로 시각 자체는 어느 action으로 조회해도 알 수 있지만, 녹화 스케줄(10장) 계산에는 현지 시간대 정보도 필요해서 별도로 둔다. 요청 { "requestId": "req-204", "action": "system.time.get", "params": { } } 응답 { "requestId": "req-204", "ok": true, "result": { "timeZoneId": "Asia/Seoul", "windowsTimeZoneId": "Korea Standard Time", "utcOffsetMinutes": 540, "daylightSavingTime": false }, "serverTimeMs": 1788289200000 } timeZoneId는 스케줄 계산과 Web Server 화면 표시에 쓴다. utcOffsetMinutes는 현재 오프셋이라 DST 지역에서는 계절에 따라 바뀌므로, 스케줄 계산은 이 고정값이 아니라 timeZoneId 기준으로 한다. NTP 동기화 여부는 여기 포함하지 않는다 — status.get의 clock.ntpSynced를 쓴다. storage.status.get 녹화 저장소 상태다. status.get의 storage 요약보다 자세하다. 요청 { "requestId": "req-203", "action": "storage.status.get", "params": { } } 응답 { "requestId": "req-203", "ok": true, "result": { "path": "D:\\record", "writable": true, "totalBytes": 8000000000000, "freeBytes": 2200000000000, "usagePercent": 72.5, "minFreeSpacePercent": 10 } } control.server.status 제어 채널(WSS) 서버 자체의 상태다. 스트리밍(ZLM) 서버와도, 녹화재생·내보내기용 recplayHttpPort 서버와도 별개의 포트에서 동작한다. recplayHttpPort 서버는 이 제어 채널과 기동·종료가 함께 묶여 있으므로, running이 false면 녹화재생·내보내기 요청도 전부 실패한다(3.1 참고). 요청 { "requestId": "req-210", "action": "control.server.status", "params": { } } 응답 { "requestId": "req-210", "ok": true, "result": { "enabled": true, "running": true, "controlBindIp": "192.168.30.42", "boundPort": 27000, "startedAtWallMs": 1783664000000, "clientCount": 2, "heartbeat": { "intervalSeconds": 60, "maxMissedPongs": 2 }, "certificate": { "subject": "CN=media-01", "notAfterWallMs": 1812000000000 }, "lastError": null } } controlBindIp는 WSS 서버가 실제 바인딩한 주소이며, 정상 실행 중에는 로컬에 설정된 controlIp와 같다. 녹화재생·내보내기 HTTP 서버도 같은 IP의 별도 포트에서 동작한다. 스트리밍의 streamingBindIp와는 다른 서버의 값이다. certificate.notAfterWallMs가 지나면 신규 연결이 전부 실패하므로 Web Server는 만료 전에 경고한다. [h2] 에러 코드16 | error.code | 설명 | TOKEN_INVALID | Web Server 검증 결과 active=false. 폐기되었거나 존재하지 않는 토큰. managed 전용. | TOKEN_REVOKED | Web Server가 토큰을 폐기했거나 계정이 비활성화됨. managed 전용. | INTROSPECTION_UNAVAILABLE | Web Server 토큰 유효성 검사 API에 질의할 수 없어 인증을 완료하지 못함. managed 전용. | INVALID_PARAMS | 필수 파라미터 누락 또는 형식 오류. | AUTH_FAILED | 카메라 장치 자격증명 오류. camera-tests.create에서 씀 — cameras.discover/cameras.probe는 이 오류를 항목별 authStatus/error에 담아 ok: true로 반환한다(8.1). | SERVER_BUSY | 다른 요청을 처리 중이라 지금은 받을 수 없음. retryable: true. | SERVER_NOT_READY | 부트스트랩·CCTV 동기화 중이거나 부트스트랩에 실패해 이 action을 아직 받을 수 없음. retryable: true. details.status에 현재 status가 담긴다(15장). | CAMERA_NOT_FOUND | cameraId가 이 Media Server에 없음. | CAMERA_CONFLICT | 요청한 cameraId가 이미 이 Media Server에 존재함. | CAMERA_NAME_CONFLICT | CCTV 이름 UNIQUE 제약 충돌. | GROUP_HAS_CAMERAS | 소속 카메라가 남아 있어 그룹 삭제 불가. 카메라 이동·배정 해제 반영 후 재시도. | GROUP_NOT_FOUND | groupId가 이 Media Server에 없음. | GROUP_CONFLICT | 요청한 groupId가 이미 이 Media Server에 존재함. | GROUP_NAME_CONFLICT | 그룹 이름 UNIQUE 제약 충돌. | INVALID_TIME_ZONE | recording.schedules.put의 timeZoneId가 지원하지 않는 값. | INVALID_SCHEDULE | 요일·시간 범위 형식 오류. | SCHEDULE_OVERLAP | 같은 CCTV·같은 요일에 겹치는 periods 구간. | SCHEDULE_APPLY_FAILED | DB 저장 후 실제 녹화 시작/중지 적용 실패. | ONVIF_UNAVAILABLE | 이 카메라의 ONVIF 지원 여부를 아직 확인하지 못함(연결·인증 실패 포함). retryable: true. 원인은 cameras.onvif.status.get의 lastError 참고. | ONVIF_CHANNEL_UNRESOLVED | 다채널 장치에서 대상 채널을 특정할 수 없음(onvifVideoSourceToken이 없거나 해당 VideoSource를 찾지 못함). 임의로 첫 번째 채널을 제어하지 않는다. retryable: false. | PTZ_NOT_SUPPORTED | 카메라가 PTZ를 지원하지 않는 것으로 확인됨. details.reason: PTZ_UNSUPPORTED_BY_DEVICE. | IMAGING_NOT_SUPPORTED | 카메라가 이미징을 지원하지 않는 것으로 확인됨. details.reason: IMAGING_UNSUPPORTED_BY_DEVICE. | IMAGING_FOCUS_MODE_INVALID | 수동 포커스 모드가 아닌데 imaging.focus.move를 호출함. | PRESET_NOT_FOUND | 요청한 presetToken이 CCTV에 없음(8.4). | PRESET_LIMIT_REACHED | CCTV의 프리셋 생성 한도 초과(8.4). | ONVIF_BUSY | 같은 카메라의 동시 ONVIF 요청 한도 초과 또는 설정 변경 정리 중. retryable: true. | ONVIF_CONFIGURATION_CHANGED | 요청을 처리하는 도중 카메라 설정이 변경됨. 다시 요청한다. | ONVIF_OPERATION_CANCELLED | 정지·이동 제한시간 만료 또는 후속 제어로 이전 이동 요청이 취소됨. retryable: false. 이전 이동을 자동 재전송하지 않는다. | ONVIF_STOP_FAILED | PTZ·포커스 자동 정지 재시도가 실패함. 카메라가 계속 움직일 수 있으니 전부 0인 ptz.move 또는 imaging.focus.stop으로 다시 정지시킨다. | STREAM_UNAVAILABLE | RTSP 소스 연결 불가. | STREAM_NOT_PUBLISHED | 요청한 CCTV/품질 스트림이 송출 상태가 아님. | STREAM_TIMELINE_NOT_READY | 첫 키프레임 또는 시간 기준이 아직 준비되지 않음. | RECPLAY_SESSION_NOT_FOUND | sessionId 없음. | RECPLAY_SESSION_STATE_INVALID | 현재 상태에서 허용되지 않는 작업. details.state에 현재 상태(PREPARING/EXPIRED/CLOSED/FAILED)가 담긴다. | NO_RECORDING_IN_RANGE | 요청 구간에 녹화 없음. | INVALID_TIME_RANGE | fromMs >= toMs(recplay-sessions.create는 timelineToMs) 또는 형식 오류. | RECEXPORT_NOT_FOUND | recexportId 없음. | RECEXPORT_NOT_READY | 내보내기가 아직 완료되지 않음. | CLOCK_SKEW_TOO_LARGE | Media Server 시계 오차가 허용치를 초과. | INSUFFICIENT_STORAGE | 디스크 임계 초과 또는 쓰기 불가. | STREAMING_DISABLED | 스트리밍 엔진 사용이 꺼져 있음. | STREAMING_SERVER_UNAVAILABLE | 스트리밍 host가 실행 중이 아니거나 포트 바인딩 실패. | UNKNOWN_ACTION | 존재하지 않는 action 호출. | CAMERA_TEST_NOT_FOUND | testId 없음. | CAMERA_TEST_LIMIT_EXCEEDED | 동시 테스트 개수 제한 초과(MEDIA 하나당 1개). | CAMERA_CONNECT_TIMEOUT | 카메라 접속 시간 초과. | CAMERA_VIDEO_TIMEOUT | 접속은 됐으나 제한 시간 내 영상 미수신. | CAMERA_PREVIEW_UNSUPPORTED | 브라우저용 송출을 만들 수 없음. videoReceived: true면 코덱 미지원 등으로 수신한 영상을 송출 못 하는 경우, videoReceived: false면 스트리밍 엔진이 꺼져 있거나 송출 주소가 미확정이라 카메라에 접속하지 않은 경우. | INTERNAL_ERROR | Media Server 내부 오류. [h2] 로그 전송17 Media Server는 주요 이벤트를 Web Server의 로그 수집 API로 HTTPS push한다. 수집 경로와 인증, 배치·재시도 규칙은 Web Server API 명세를 따르며, 이 장은 Media Server가 무엇을 어떤 코드로 보내는지만 정의한다. 현재 상태값은 로그로 보내지 않는다. 상태는 Web Server가 status.get과 cameras.runtimeStatus.list로 조회하고, 로그는 상태가 바뀌는 시점만 전송한다. 이 경계를 지켜야 조회와 로그가 서로를 대체하려 들지 않는다. [h3] 전송 규칙17.1 전송 대상은 FATAL, ERROR, WARN, 그리고 감사·상태 복구 성격의 INFO다. DEBUG와 그 외 INFO는 Media Server 로컬 로그 파일에만 남긴다. FATAL은 프로세스가 정상 기동하지 못하거나 직전 세션이 비정상 종료된 경우에만 쓴다. 운영 중 개별 기능 실패는 ERROR다. 패킷 단위 진단은 전송하지 않는다. 타임스탬프 보정, GOP 드롭, 클럭 재앵커, muxer 드리프트 같은 값은 CCTV 수에 비례해 초당 수백 건까지 발생한다. 로컬 파일에만 기록하고 Web Server로 올리지 않는다. 같은 code와 같은 대상(cameraId, trackType)이 짧은 간격으로 반복되면 한 건으로 묶어 count, firstOccurredAtWallMs, lastOccurredAtWallMs를 채운다. Web Server에 전송하지 못한 로그는 로컬 버퍼에 보관했다가 재전송한다. 같은 로그가 두 번 이상 도착할 수 있으므로 Web Server는 요청의 토큰과 seq로 중복을 제거한다. 토큰은 로그인마다 새로 발급되므로 재부팅·재로그인 범위가 자동으로 갈린다. 버퍼가 가득 차면 오래된 것부터 버리되 레벨이 높은 것을 나중에 버린다. 토큰 원문, 계정 비밀번호, streamingToken 값은 어떤 필드에도 싣지 않는다. [h3] 레코드 형식17.2 { "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, "firstOccurredAtWallMs": 1780282795120, "lastOccurredAtWallMs": 1780282795120, "detail": { "freeBytes": 1288490188, "minFreeSpacePercent": 10 } } ] } | 필드 | 설명 | serverId | Web Server가 Media Server 계정 등록 시 발급한 영구 식별자다. 로그인 ID인 accountId와 별개다. | seq | 로그인 후 로그 일련번호. 새로 로그인하면 1부터 다시 센다. Web Server는 요청 헤더의 토큰과 묶어 중복 제거 키로 쓴다. | occurredAtWallMs | 이벤트 발생 시각. UTC epoch milliseconds. | level | INFO / WARN / ERROR / FATAL. | category | 운영 분류. 17.3의 그룹명이다. 내부 클래스명을 그대로 쓰지 않는다. | code | 17.3의 고정 코드. Web Server는 이 값으로 분기한다. | cameraId / trackType | CCTV 단위 이벤트일 때만. 서버 단위면 생략한다. | message | 사람이 읽을 요약. 화면 표시용이며 파싱 대상이 아니다. | count | 묶인 건수. 단건이면 1이다. | detail | 코드별 가변 필드. 코드마다 키가 다르므로 Web Server는 JSON 그대로 저장한다. [h3] 로그 코드17.3 [h4] Server | code | level | 뜻 | detail | MEDIA_STARTED | INFO | 프로세스 기동 | version, build | MEDIA_STOPPED | INFO | 정상 종료 | — | MEDIA_CRASH_RECOVERED | FATAL | 직전 세션이 정상 종료 절차를 밟지 못하고 종료됨 | discardedSegments | LICENSE_INVALID | FATAL | 라이선스 설정 오류. 제품이 정상 기동하지 못한다 | path | LICENSE_ACTIVATION_FAILED | FATAL | 라이선스 활성화 실패. 제품이 정상 기동하지 못한다 | resultCode | CERT_EXPIRING | WARN | 제어 채널 인증서 만료 임박 | notAfterWallMs, daysLeft [h4] Auth | code | level | 뜻 | detail | TOKEN_REJECTED | WARN | 토큰 무효·폐기로 요청 거부. managed 전용 | accountId, reason | INTROSPECTION_FAILED | ERROR | Web Server 토큰 유효성 검사 API 질의 실패. managed 전용 | attempts | MEDIA_AUTH_REJECTED | WARN | streamingToken 불일치로 스트리밍 요청 거부 | streamId, clientIp, protocol [h4] Ingest | code | level | 뜻 | detail | CAMERA_ONLINE | INFO | ingest online 전이 | — | CAMERA_OFFLINE | WARN | ingest offline 전이 | reason | CAMERA_RECONNECT_LOOP | WARN | 재연결이 반복됨 | retryCount, windowMs | CAMERA_CODEC_TIMEOUT | WARN | 연결은 되었으나 코덱 정보를 받지 못함 | waitedMs | CAMERA_PIPELINE_ERROR | ERROR | ingest 파이프라인 오류 | stage, gstMessage | CAMERA_CRYPTO_PROBE_FAILED | ERROR | 암호화 CCTV 패킷 헤더 인식 실패. 녹화·재생 불가 | attempts [h4] Recording | code | level | 뜻 | detail | RECORDING_STARTED | INFO | 녹화 시작 | requestedBy | RECORDING_STOPPED | INFO | 녹화 중지 | requestedBy | RECORDING_FAILED | ERROR | recordingStatus가 failed로 전이 | reason | RECORDING_DISK_FULL | ERROR | 디스크 부족으로 해당 CCTV 녹화 중단 | freeBytes, minFreeSpacePercent | RECORDING_DISK_FULL_RECOVERED | INFO | 공간 확보 후 녹화 재개 | freeBytes | RECORDING_WATCHDOG_RETRY | WARN | 녹화기 자동 재시도 | retryCount, reason, elapsedMs | RECORDING_WRITER_RECOVERY | WARN | writer 복구 시도 | reason | RECORDING_WRITER_FATAL | ERROR | writer 복구 실패. 재기동 전까지 해당 트랙 녹화 불가 | elapsedMs | RECORDING_NO_KEYFRAME | WARN | 패킷은 도착하지만 키프레임이 없어 녹화기를 재시작함 | packets, waitedMs | RECORDING_ENCRYPT_FAILED | ERROR | 녹화 암호화 실패 | resultCode | SEGMENT_DISCARDED | WARN | 손상·빈 세그먼트를 폐기함. 그만큼 녹화가 남지 않았다 | fileId, reason [h4] Storage | code | level | 뜻 | detail | STORAGE_WARNING | WARN | usagePercent 85% 이상 | usagePercent, freeBytes | STORAGE_CRITICAL | ERROR | usagePercent 95% 이상 | usagePercent, freeBytes | STORAGE_RECOVERED | INFO | 임계 미만으로 복귀 | usagePercent | STORAGE_NOT_WRITABLE | ERROR | 저장 경로에 쓸 수 없음 | path, error | CLEANUP_BELOW_MINIMUM | ERROR | 순환삭제가 최소 여유공간을 확보하지 못함. 곧 녹화가 멈춘다 | availableBytes, minimumBytes | CLEANUP_NO_PROGRESS | ERROR | 순환삭제가 한 건도 진행되지 못함 | deferred, failed | CLEANUP_DELETE_FAILED | WARN | 파일 삭제 실패 또는 보류 | path, reason, blocker | CLEANUP_PASS_SUMMARY | INFO | 순환삭제 1회 결과. 추세 분석용 | deleted, reclaimedBytes, availableBytes [h4] IO / DB | code | level | 뜻 | detail | IO_WRITER_PRESSURE | ERROR | 녹화 쓰기 큐 적체. 디스크가 입력을 못 따라간다 | depth, limit, oldestAgeMs | IO_QUEUE_HARD | ERROR | 쓰기 큐 하드 리밋 도달. 이후 패킷이 버려진다 | depth, hard, channels | IO_PRESSURE_RECOVERED | INFO | 큐 적체 해소 | depth | DB_WRITE_SLOW | WARN | 인덱스 DB 쓰기 지연 | totalMs, sqliteMs | DB_WRITE_DROPPED | WARN | 인덱스 쓰기 드롭. 해당 구간이 검색되지 않는다 | dropped, queueDepth | INDEX_DB_ERROR | ERROR | 녹화 인덱스 DB 오류 | operation, sqliteError | CAMERA_DB_ERROR | ERROR | CCTV 정보 DB 오류 | operation, sqliteError [h4] Streaming | code | level | 뜻 | detail | STREAMING_SERVER_RESTARTED | WARN | 스트리밍 엔진 재시작 | requestedBy, reason | STREAMING_RESTART_FAILED | ERROR | 재시작 실패 | requestedBy, reason, error | STREAMING_SERVER_START_FAILED | ERROR | 스트리밍 엔진을 시작하지 못했다. 바인딩 실패 또는 streamingBindIp 미확정이며 녹화·재생은 계속 동작한다 | streamingBindIp, reason | STREAMING_PROTOCOL_DOWN | ERROR | 서버는 떴으나 일부 프로토콜 바인딩만 실패. 나머지는 계속 서비스한다 | protocol, port | STREAMING_PROTOCOL_RECOVERED | INFO | 실패했던 프로토콜의 바인딩이 복구됨. 전체 서버 재시작은 아님 | protocol, port | STREAM_PUBLISH_FAILED | ERROR | 송출 스트림 생성 실패 | streamId, codec | MEDIA_TOKEN_ROTATED | INFO | streamingToken 재발급. 이전 URL이 모두 무효가 되었다 | issuedAtWallMs [h4] Time | code | level | 뜻 | detail | TIMELINE_ANCHOR_REBASED | WARN | startWallTimeMs가 재설정됨. 소비자는 streaming.streams.info를 재조회해야 한다 | streamId, previousStartWallTimeMs, startWallTimeMs, wrapIndex | CLOCK_SKEW | WARN | 시계 오차가 허용치를 초과 | clockOffsetMs | NTP_UNSYNCED | WARN | NTP 동기화되지 않음 | — TIMELINE_ANCHOR_REBASED는 Media Server가 제어 채널로 통보하지 않는 변화다(7장 참조). 이 로그가 유일한 감지 수단이며, Web Server가 놓치면 AI 이벤트 시각과 Client 동기화가 조용히 어긋난다. [h4] Export | code | level | 뜻 | detail | RECEXPORT_FAILED | ERROR | 내보내기 실패 | recexportId, requestedBy, stage | RECEXPORT_PARTIAL | WARN | 손상 세그먼트를 건너뛰고 완료 | recexportId, skippedSegments [h4] Audit | code | level | 뜻 | detail | CONFIG_CHANGED | INFO | 설정 변경 | accountId, action, changed | CAMERA_LEDGER_CHANGED | INFO | CCTV·그룹 생성·변경·삭제 | accountId, action, cameraId [h3] 상태 쌍 코드17.4 아래 코드는 여는 이벤트와 닫는 이벤트가 쌍을 이룬다. Web Server는 이 쌍으로 미해소 상태를 관리하고, 로그 이력 전체를 훑지 않고도 현재 장애 목록을 만들 수 있다. | 여는 코드 | 닫는 코드 | 범위 | CAMERA_OFFLINE | CAMERA_ONLINE | CCTV | RECORDING_DISK_FULL | RECORDING_DISK_FULL_RECOVERED | CCTV | STORAGE_WARNING / STORAGE_CRITICAL | STORAGE_RECOVERED | 서버 | IO_WRITER_PRESSURE / IO_QUEUE_HARD | IO_PRESSURE_RECOVERED | 서버 | RECORDING_WRITER_FATAL | MEDIA_STARTED | 트랙 | STREAMING_SERVER_START_FAILED | STREAMING_SERVER_RESTARTED / MEDIA_STARTED | 서버 | STREAMING_PROTOCOL_DOWN | STREAMING_PROTOCOL_RECOVERED / STREAMING_SERVER_RESTARTED / MEDIA_STARTED | 서버·프로토콜 STREAMING_PROTOCOL_RECOVERED는 동일 서버의 detail.protocol이 같은 STREAMING_PROTOCOL_DOWN만 해제한다. 다른 프로토콜 장애나 서버 시작 실패는 해제하지 않는다. 전체 재시작·프로세스 기동 이벤트는 기존처럼 서버 범위로 해제한다. 닫는 코드의 범위가 여는 코드보다 넓으면, 그 서버에서 해당 코드로 열려 있던 항목을 전부 닫는다. 예를 들어 MEDIA_STARTED는 그 Media Server의 모든 트랙에 열려 있던 RECORDING_WRITER_FATAL을 일괄 종료한다. 나머지 코드는 단발성이며 닫는 이벤트가 없다. [h2] 전체 목록18 연결된 client는 모든 action을 호출할 수 있다. managed는 유효한 토큰이 있어야 연결되고, standalone은 인증 없이 연결된다. 영상 경로의 인증은 13장을 따르고, 로그 코드 카탈로그는 17.3에 있다. [h4] Control Channel action | # | Group | Action | 요약 | 상세 | 1 | Health | health.get | Liveness. | 15 | 2 | Health | status.get | Readiness 요약. | 15 | 3 | Health | version.get | 빌드/API 버전. | 15 | 4 | Health | system.time.get | 현재 시각·시간대. | 15 | 5 | Control | control.server.status | 제어 채널 서버 상태·포트·인증서 만료. | 15 | 6 | Group | camera-groups.list | 그룹 목록. | 5 | 7 | Group | camera-groups.create | 그룹 생성. Web Server가 발급한 groupId를 받는다. | 5 | 8 | Group | camera-groups.update | 그룹 이름·정렬 변경. | 5 | 9 | Group | camera-groups.delete | 이 MEDIA에 남은 소속 카메라가 없어진 그룹을 로컬에서 정리. 배정·녹화에는 영향 없음. | 5 | 10 | Camera | camera-assignments.list | CCTV 목록·cameraId 조회. | 5 | 11 | Camera | camera-assignments.get | CCTV 단건 조회. | 5 | 12 | Camera | camera-assignments.create | CCTV 신규 등록. Web Server가 발급한 cameraId를 받는다. | 5 | 13 | Camera | camera-assignments.update | CCTV 설정 변경. | 5 | 14 | Camera | camera-assignments.delete | CCTV 배정 해제. | 5 | 15 | Camera | camera-assignments.sync | Web Server DB 기준 bulk 동기화. | 5 | 16 | Runtime | cameras.runtimeStatus.list | CCTV runtime bulk 조회. | 6 | 17 | Streaming | streaming.channels.list | 채널별 송출 허용 여부 조회. | 7 | 18 | Streaming | streaming.channels.setEnabled | 채널별 송출 on/off. | 7 | 19 | Recording | recordings.status.get | 녹화 상태 조회. | 9 | 20 | Recording | recordings.start | 녹화 시작. | 9 | 21 | Recording | recordings.stop | 녹화 중지. | 9 | 22 | Recording | recordings.ranges.get | 녹화 구간·gap 조회. | 9 | 23 | Recording | recordings.days.get | 날짜별 녹화 여부. | 9 | 24 | Settings | settings.recording.get | 전역 녹화 설정 조회. 읽기 전용. | 9 | 25 | Schedule | recording.schedules.list | CCTV별 녹화 스케줄 조회. | 10 | 26 | Schedule | recording.schedules.put | CCTV별 녹화 스케줄 일괄 변경. | 10 | 27 | Recplay | recplay-sessions.create | 녹화재생 세션 생성. HLS. | 11 | 28 | Recexports | recexports.estimate | 내보내기 용량·시간 추정. | 12 | 29 | Recexports | recexports.create | 내보내기 작업 생성. MP4. | 12 | 30 | Recexports | recexports.get | 진행률·다운로드 URL 조회. | 12 | 31 | Recexports | recexports.list | 내보내기 목록. | 12 | 32 | Recexports | recexports.cancel | 작업 취소·결과 삭제. | 12 | 33 | Recplay | recplay-sessions.get | 녹화재생 세션 조회. | 11 | 34 | Recplay | recplay-sessions.refresh | 녹화재생 세션 TTL 갱신, 선택적으로 녹화재생 가능 상한 연장. | 11 | 35 | Recplay | recplay-sessions.close | 녹화재생 세션 종료. | 11 | 36 | Streaming | streaming.streams.list | 송출 stream 목록. | 7 | 37 | Streaming | streaming.streams.info | 접속 URL·시간 기준. | 7 | 38 | Streaming | streaming.server.status | 스트리밍 엔진 상태·바인딩 포트. | 7 | 39 | Streaming | streaming.server.settings.get | 스트리밍 엔진 저장 설정 조회. | 7 | 40 | Streaming | streaming.server.settings.put | 스트리밍 엔진 설정 변경·적용. | 7 | 41 | Streaming | streaming.clients.list | RTSP/WebRTC 접속 세션 조회. | 7 | 42 | Streaming | streaming.server.restart | 스트리밍 엔진 재시작. | 7 | 43 | Storage | storage.status.get | 저장소 상태. | 15 | 44 | Token | tokens.revoke | 토큰 폐기 통지. 캐시 제거 및 연결 종료. managed 전용. | 14 | 45 | Token | tokens.flushCache | 토큰 캐시 전체 삭제. managed 전용. | 14 | 46 | ONVIF | cameras.discover | 네트워크 ONVIF 카메라 검색. | 8.1 | 47 | ONVIF | cameras.probe | 자격증명으로 RTSP 경로 확인. | 8.1 | 48 | PTZ | ptz.move | 팬·틸트·줌 연속 이동. 전부 0이면 정지. | 8.2 | 49 | ONVIF | cameras.capabilities.get | 기능별 지원 여부·허용 범위 조회. | 8.1 | 50 | ONVIF | cameras.capabilities.refresh | 장치에서 지원 기능 재조회. | 8.1 | 51 | ONVIF | cameras.onvif.status.get | ONVIF 연결·인증 상태 조회. | 8.1 | 52 | 이미징 | imaging.dayNight.get | 주간·야간·자동 설정 조회. | 8.3 | 53 | 이미징 | imaging.dayNight.set | 주간·야간·자동 설정 변경. | 8.3 | 54 | 이미징 | imaging.focus.get | 포커스 모드·현재 값 조회. | 8.3 | 55 | 이미징 | imaging.focus.set | 포커스 모드·초점 위치 변경. | 8.3 | 56 | 이미징 | imaging.focus.move | 연속 포커스 이동. | 8.3 | 57 | 이미징 | imaging.focus.stop | 포커스 이동 정지. | 8.3 | 58 | Camera | camera-tests.create | 등록 전 카메라 연결 테스트 시작. | 5 | 59 | Camera | camera-tests.get | 연결 테스트 상태·영상 정보 조회. | 5 | 60 | Camera | camera-tests.close | 연결 테스트 종료. | 5 | 61 | PTZ | ptz.home.set | 현재 위치를 홈 포지션으로 저장. | 8.4 | 62 | PTZ | ptz.home.goto | 홈 포지션으로 이동. | 8.4 | 63 | PTZ | ptz.presets.list | CCTV에 저장된 프리셋 목록 조회. | 8.4 | 64 | PTZ | ptz.presets.set | 프리셋 생성·덮어쓰기. | 8.4 | 65 | PTZ | ptz.presets.delete | 프리셋 삭제. | 8.4 | 66 | PTZ | ptz.presets.goto | 프리셋 위치로 이동. | 8.4 [h4] 영상 접속 HTTP 경로의 base는 둘로 나뉜다 — WHEP/WebRTC는 http://{host}:{httpPort}(기본 8888, ZLM), HLS·내보내기는 http://{host}:{recplayHttpPort}(기본 27001, WSS 제어 채널과 기동·종료가 함께 묶이는 별도 Kestrel 서버). 둘 다 평문이다. | # | Kind | Path | 포트 | 요약 | 1 | HTTP | POST /whep/ivpm/{streamId}?token={streamingToken} | httpPort | WHEP signaling. | 2 | HTTP | POST /index/api/webrtc?app=ivpm&stream={streamId}&type=play&token={streamingToken} | httpPort | WebRTC JSON signaling. | 3 | HTTP | GET /recplay/{recplayId}/index.m3u8 | recplayHttpPort | HLS manifest. | 4 | HTTP | GET /recplay/{recplayId}/segment-{sequence}.ts | recplayHttpPort | HLS segment. | 5 | HTTP | GET /recexports/{downloadToken}/{cameraId}_{trackType}.mp4 | recplayHttpPort | 내보내기 결과 다운로드. | 6 | RTSP | rtsp://{host}:{rtspPort}/ivpm/{streamId}?token={streamingToken} | rtspPort | AI 분석용 스트림.