Skip to content

Repository files navigation

CCTV 아카이브

RTSP 카메라를 무인으로 녹화하고, 브라우저에서 시각 기반으로 되돌려 보는 셀프호스트 스택입니다. 녹화는 재인코딩 없이(-c:v copy) 하고, 조회는 TS 패킷의 PCR을 직접 읽어 HLS를 동적으로 만듭니다.

  • 녹화 — go2rtc(RTSP 허브) + ffmpeg. 10분 조각, 벽시계 정렬로 저장합니다.
  • 조회 — Rust 서버가 #EXT-X-BYTERANGE HLS를 즉석에서 생성합니다. 600초 조각을 6초 프래그먼트로 쪼개 보여줍니다.
  • 뷰어 — 단일 HTML. 타임라인(녹화 공백 표시)·시각 점프·배속, 두 카메라 동시 보기, 디지털 확대/이동, 키보드·더블탭 탐색을 제공합니다. 자세한 조작은 아래 뷰어 조작.
  • 이관 — 완결된 날짜를 아카이브 디렉터리로 옮기고 오래된 것은 지웁니다. 파일시스템이 유일한 상태입니다.
  • 인증 — 리버스 프록시(Caddy) + argon2 로그인. 모든 요청이 인증을 거칩니다.

카메라 두 대 기준 하루 약 40–60GB이며, 전부 컨테이너로 docker compose 하나로 뜹니다.

검증한 카메라 — Tapo C520WS

이 프로젝트의 모든 실측과 튜닝은 TP-Link Tapo C520WS를 기준으로 했습니다. 다른 기종이 없어 일반화하지 못했습니다. 아래 값들은 C520WS의 특성에서 나온 것이므로, 다른 카메라를 붙이실 때는 직접 재보고 조정하셔야 합니다.

항목 C520WS 실측 어디에 영향
RTSP 경로 메인 /stream1(2560×1440·25fps), 서브 /stream2(640×360) go2rtc.yaml 의 stream URL
코덱 영상 H.264, 오디오 PCM A-law recorder가 오디오만 AAC로 재인코딩(-c:a aac)
GOP(키프레임 간격) 2초 (50프레임) 프래그먼트 경계·공백 판정 (CCTV_GOP_SECONDS)
레이트컨트롤 메인 CBR ~2.7Mbps (야간에도 안 떨어짐) 하루 용량 산정

RTSP 경로는 기종마다 다릅니다. /stream1·/stream2 는 Tapo 규약입니다. 다른 카메라는 제조사 문서나 ffprobe rtsp://… 로 실제 경로를 확인해 go2rtc.yaml 에 넣으시면 됩니다. GOP가 2초가 아니면 CCTV_GOP_SECONDS 를 그 값으로 바꾸셔야 합니다(안 그러면 공백을 잘못 판정합니다).

구성

브라우저 ─► caddy :8081 ─┬─ /login ──► auth (argon2)
                         └─ 그 외 전부 ─► forward_auth → auth /verify
                                          통과하면 ─► archive-server
                                                       ├─ record/{cam}/*.ts  (오늘)
                                                       └─ archive/{cam}/{날짜}/  (이관됨)

go2rtc ◄─ RTSP ─ 카메라        recorder(ffmpeg) ─► record/{cam}/*.ts
archive-indexer  5분마다 키프레임 인덱스(.idx)
archive-flusher  매시간 이관 + 보존기간 순환삭제

설치

git clone <this-repo> cctv-archive && cd cctv-archive

# 1. 템플릿 복사 후 값 채우기
cp docker-compose.yml.example docker-compose.yml   # ★ 표시된 곳(UID, TZ, 보존일 등)
cp .env.example .env                               # 카메라 계정·IP.  chmod 600 .env
cp go2rtc/go2rtc.yaml.example go2rtc/go2rtc.yaml   # 카메라 RTSP 경로 (기종별로 다름 — "검증한 카메라" 절)

# 2. 뷰어 비밀번호 (argon2 해시를 secrets/auth_hash 에 만든다. 화면에 안 찍힘)
docker compose build archive-server                # 먼저 이미지 빌드(바이너리 필요)
./scripts/set-password.sh

# 3. 기동
docker compose up -d
docker compose logs -f archive-server              # 기동 시 설정·카메라 목록이 찍힌다

설치가 끝나면 http://<호스트>:8081/ 로 접속해 로그인 후 재생하시면 됩니다.

카메라 추가

record/cam3/ 는 자동으로 인식됩니다. 두 곳만 손대시면 됩니다.

  • go2rtc.yaml 에 스트림 cam3 를 추가합니다.
  • docker-compose.yml 에 recorder-cam3 서비스를 추가합니다(4줄, 기존 것을 복사한 뒤 CAM: cam3).

뷰어 조작

브라우저 하나로 되돌려 봅니다. 마우스·터치·키보드를 모두 지원합니다.

  • 타임라인 — 클릭/드래그로 그 시각으로 이동, 휠·핀치로 확대. 드래그 중 커서 위에 시:분:초가 뜹니다.
  • 탐색 ±5초 — 플레이어나 타임라인에 포커스를 두고 방향키 ←/→. 모바일은 화면 좌/우 끝(각 20%)을 더블탭.
  • 재생/정지 — 화면 탭(또는 스페이스). 타임라인 프리셋 줄 오른쪽의 ▶ 재생 / ⏸ 일시정지 버튼으로도 됩니다(터치·마우스가 없는 TV 리모컨에서 방향키로 포커스 이동 후 OK). 가운데를 더블탭/더블클릭하면 전체화면.
  • 배속 — 1·2·4·16x 버튼, 또는 화면을 0.5초 이상 꾹 누르는 동안만 임시 배속(배속 값 선택 가능).
  • 확대/이동 — 마우스 휠 또는 핀치로 확대(최대 4x), 확대 상태에서 끌어 화면 이동. 확대 중엔 재생/정지는 탭, 탐색은 타임라인으로.
  • 영상 높이 — 영상과 타임라인 사이 구분선을 세로로 끌어 조절(넓은 화면). 못 커지는 만큼은 위아래 여백으로 남고 가운데 정렬됩니다.
  • 사이드바 접기 — 넓은 화면에서 상단 ⇥ 버튼으로 사이드바를 접어 영상을 넓게 봅니다. 접힘 상태는 기억됩니다.
  • 멀티뷰 — 오른쪽에 둘째 카메라를 골라 나란히. 한쪽을 더블클릭하면 그 화면만 크게(집중), ⊞ 2분할로 복귀. 두 화면의 벽시각은 자동 동기화됩니다(넓은 화면 전용). 멀티뷰를 켜고 끌 때 보던 시각과 타임라인 확대는 그대로 유지됩니다(같은 카메라·날짜면 다시 로딩하지 않음).
  • 테마·로그아웃 — 상단바에서.

일부 조작(구분선·사이드바·달력·멀티뷰)은 넓은 화면(≥1024px) 전용입니다. 좁은 화면은 세로로 쌓여 스크럽·배속·꾹눌러배속·더블탭 탐색에 집중합니다.

설정 (환경변수)

.example 파일들에 전부 주석과 함께 있습니다. 핵심만 옮기면 다음과 같습니다.

변수 뜻
TZ 필수. 파일명(-strftime)과 PDT가 이 시간대를 따릅니다. 틀리면 모든 시각이 밀립니다. 녹화·조회가 같아야 합니다.
CCTV_ARCHIVE_DIR 이관된 과거 날짜가 쌓이는 곳입니다. 기본은 ./archive 입니다. 네트워크 스토리지를 쓰시려면 그 마운트를 여기에 두시면 됩니다(코드는 그냥 디렉터리로 봅니다).
CCTV_RETENTION_DAYS 아카이브 보존일입니다. 0이면 순환삭제를 하지 않습니다.
CCTV_SESSION_HOURS 로그인 유지 시간입니다(기본 720=30일). 절대 만료이며, restart auth 가 전체 로그아웃입니다.
CCTV_COOKIE_SECURE 앞단이 HTTPS면 1, 평문 HTTP로만 접근하면 0 입니다.

GOP·스트림 경로처럼 카메라 특성에 의존하는 값은 위 "검증한 카메라" 절을 참조하세요. Tapo C520WS 기준입니다.

TLS / 외부 공개

이 스택은 평문 HTTP로 :8081 을 냅니다. TLS는 각자 앞단에서 처리하시면 되고, 방식은 무엇이든 좋습니다.

  • 리버스 프록시(Caddy/nginx/Traefik)로 인증서를 종료한 뒤 :8081 로 프록시하기
  • Cloudflare Tunnel / Tailscale 등으로 감싸기
  • 신뢰된 LAN 안에서만 쓰기

앞단이 HTTPS가 되면 CCTV_COOKIE_SECURE=1 로 바꾸셔야 합니다(안 그러면 쿠키에 Secure가 안 붙습니다). 반대로 평문 HTTP로 접근하면서 1로 두면 브라우저가 쿠키를 안 실어 로그인이 되지 않으니 주의하세요.

왜 이렇게 만들었나 (설계 요점)

전부 실측에서 나온 결정입니다.

  • 저장 단위 ≠ 재생 단위. 디스크는 600초 조각이지만 플레이어에는 #EXT-X-BYTERANGE로 6초 프래그먼트를 줍니다. 600초를 통째로 주면 브라우저가 조각 하나(수백 MB)를 다 받아야 첫 프레임이 나옵니다.
  • 길이는 PCR로 잽니다. EXTINF = 다음 키프레임 PCR − 현재 키프레임 PCR 입니다. 조각 안의 last−first를 쓰면 마지막 PCR 뒤 패킷을 놓쳐 하루 수 초가 누적됩니다. ffprobe는 조각당 수백 ms라 느리고 스트림별로 요동칩니다.
  • 프래그먼트는 키프레임이 아니라 그 앞의 PAT에서 자릅니다. 안 그러면 트랜스먹서가 앞부분을 버립니다.
  • 불연속은 PCR 리셋으로 판정합니다. ffmpeg가 재시작하면 PCR이 0으로 돌아가므로 #EXT-X-DISCONTINUITY를 넣습니다.
  • 이관에 DB를 쓰지 않습니다. "아카이브에 같은 바이트로 있는가"는 보면 압니다. 복사 → 다시 읽어 검증 → 그 다음 삭제 순서입니다.
  • SIGTERM을 보존합니다. recorder를 sh -c 로 띄울 때 exec 가 없으면 PID 1이 sh가 되어 SIGTERM이 ffmpeg에 닿지 않고 조각이 찢어집니다.

개발

# 호스트에서 직접 (빠른 반복; HTML은 include_str! 로 바이너리에 박힌다)
cargo build --release --manifest-path server/Cargo.toml
CCTV_BIND=0.0.0.0:8082 ./server/target/release/archive-server

cargo test --manifest-path server/Cargo.toml     # 실제 조각에서 뜯은 픽스처로 검증

Dockerfile은 의존성 캐시 층에서 더미 fn main(){} 을 만듭니다. COPY 가 mtime을 보존하는 탓에 소스가 다시 빌드되지 않을 수 있어, 빌드 끝에 --version 출력을 확인해 진짜 바이너리인지 검사합니다.

라이선스

MIT (LICENSE). 단, 함께 배포되는 hls.js(server/assets/hls.min.js)는 Apache-2.0 입니다.

About

Self-hosted CCTV recording and time-based playback. RTSP → PCR-based HLS viewer with timeline, multi-view, archival, and auth. Rust + Docker.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages