commit f0da6f724a10d91ce7983d0c0e7eca01e118435e Author: ysj7705 Date: Sun Oct 4 12:23:09 2026 +0900 live-rec: 치지직·유튜브 방송 예약 녹화 Co-Authored-By: Claude Opus 5.5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4c867cf --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# cp .env.example .env 후 수정 + +# 녹화 저장 위치 (호스트 경로). 예: /volume1/video/live +REC_DIR=./recordings +# 녹화 파일 소유자 — 호스트에서 `id` 로 확인 (Synology 첫 사용자는 보통 1026 / 100) +PUID=1000 +PGID=1000 +# 예약·표시 시간대 +TZ=Asia/Seoul +# 웹 화면 포트 +REC_WEB_PORT=8790 +# 여유 공간이 이보다 적으면(GB) 녹화를 멈춘다 +REC_MIN_FREE_GB=20 + +# ---- 웹 화면 로그인 (선택, 외부에 공개할 때) ---- +# OIDC 제공자 주소 (Gitea: https://git.example.com, Authentik·Keycloak 등도 가능) +REC_OIDC_ISSUER= +# 제공자에 만든 OAuth2 앱의 클라이언트 ID (리디렉션 URI: /auth/callback) +REC_OIDC_CLIENT_ID= +# 브라우저로 접속하는 공개 주소. 예: https://rec.example.com +REC_PUBLIC_URL= +# 로그인을 허용할 계정 (OIDC preferred_username, 쉼표로 여러 명) +REC_ALLOWED_USERS= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..075f4c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# 실행 중 데이터 — 쿠키·로그인 키 등 비밀값 포함 +data/ +recordings/ +.env +.env.bak* +__pycache__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a21fdcc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates tzdata \ + && rm -rf /var/lib/apt/lists/* + +# 유튜브는 JS 챌린지 풀이에 외부 JS 런타임(deno)이 필요하다 +COPY --from=denoland/deno:bin /deno /usr/local/bin/deno + +# 녹화 파일 소유자. 호스트 사용자와 맞춘다 (.env PUID/PGID, 예: Synology 첫 사용자 1026:100) +ARG PUID=1000 +ARG PGID=1000 + +# yt-dlp는 venv에 두고 컨테이너 사용자 소유로 → 재빌드 없이 rec update / 데몬이 매일 업데이트 +RUN python -m venv /opt/venv \ + && /opt/venv/bin/pip install --no-cache-dir -U 'yt-dlp[default]' \ + && chown -R ${PUID}:${PGID} /opt/venv + +# rec.py(데몬·CLI) + web.py·index.html(웹 화면) — 같은 폴더에 있어야 데몬이 web을 불러온다 +COPY --chmod=755 rec.py /opt/rec/ +COPY web.py index.html /opt/rec/ +RUN ln -s /opt/rec/rec.py /usr/local/bin/rec + +ENV PATH=/opt/venv/bin:$PATH TZ=Asia/Seoul PYTHONUNBUFFERED=1 +USER ${PUID}:${PGID} +WORKDIR /data +CMD ["rec", "daemon"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4670373 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# live-rec + +치지직·유튜브 방송을 **미리 대기 걸어 두면 켜지는 순간 녹화**하는 Docker 컨테이너. +yt-dlp + ffmpeg 기반이고, 웹 화면과 CLI를 함께 제공한다. + +- 치지직 채널: 30초마다 방송 확인 → 켜지면 녹화 +- 유튜브 채널: 2분마다 확인, 예약 방송은 시작까지 대기 +- 다시보기·클립·일반 영상 주소는 바로 받기 +- **예약**: 정한 시각(기본 한국시간)부터 N시간 동안 방송을 기다린다 +- 녹화가 끊겨도 방송이 계속되면 다시 연결해 이어 녹화 (파일이 나뉨) +- 웹 화면: 등록·예약·화질 선택, 녹화 용량·속도·경과, 정체 경고, 로그, 중지, 쿠키 입력 +- 여유 공간이 부족하면 지금까지 분량을 저장하고 멈춤 +- yt-dlp는 컨테이너 시작 시와 매일 05시(녹화가 없을 때) 자동 업데이트 + +> 개인 시청 목적으로만 사용하세요. 저작권과 각 플랫폼 이용약관을 지키는 것은 사용자 책임입니다. + +## 설치 + +Docker Compose가 필요하다 (Synology는 Container Manager). + +```bash +git clone <이 저장소 주소> live-rec +cd live-rec +cp .env.example .env # 저장 위치(REC_DIR), PUID/PGID 등 수정 +mkdir -p data recordings # REC_DIR을 바꿨다면 그 폴더를 만든다 +docker compose up -d --build +``` + +웹 화면: `http://<서버 IP>:8790` + +- **PUID/PGID**: 녹화 파일 소유자. 호스트에서 `id`로 확인해 `.env`에 넣는다. + 이 값은 이미지 빌드에 쓰이므로 바꾸면 `docker compose up -d --build`. +- **폴더 권한**: `data/`와 저장 폴더는 PUID 사용자가 쓸 수 있어야 한다. + Docker가 대신 만들면 root 소유가 되니 미리 만들어 둔다. +- **Synology**: 공유 폴더의 ACL이 상속되면 컨테이너 안에서 읽기 전용으로 보인다. + 그럴 때는 해당 폴더만 ACL을 지우고 일반 권한으로 바꾼다. + ```bash + sudo synoacltool -del /volume1/<공유폴더>/live && sudo chmod 755 /volume1/<공유폴더>/live + ``` + +## 사용법 + +### 웹 화면 + +주소를 넣고 **확인**을 누르면 방송 중인지 미리 볼 수 있다. 시작을 **지금부터** 또는 **예약**으로 고르고, 기다릴 시간과 화질을 정해 등록한다. + +### CLI + +```bash +R="docker exec live-rec rec" +$R add https://chzzk.naver.com/<채널ID> -H 6 -m "메모" # 지금부터 6시간 대기 +$R add https://www.youtube.com/@핸들 -a "21:00" -H 3 # 21시부터 3시간 대기 (지난 시각이면 내일) +$R add -a "10-05 20:30" -q 1080 # 날짜 지정, 1080p 이하 +$R ls # 목록 +$R st # 상세 + 로그 +$R stop # 대기 취소 / 녹화 중지 (분량 저장) +$R rm # 끝난 작업 기록 삭제 (녹화 파일은 그대로) +$R check # 등록 없이 방송 여부 확인 +$R update # yt-dlp 수동 업데이트 +``` + +ID는 앞부분만 써도 된다. 저장 파일: `/<채널명>/<시작시각> <제목> [].mp4` + +### 쿠키 (성인 방송·연령 제한 영상) + +치지직 성인 방송은 네이버 로그인 쿠키(`NID_AUT`, `NID_SES`)가 있어야 받을 수 있다. +웹 화면 아래 **쿠키**에 붙여넣거나, 터미널에서 입력한다 (입력 내용은 화면에 보이지 않음). + +```bash +docker exec -it live-rec rec cookies set chzzk # NID_AUT=...; NID_SES=... 한 줄 +docker exec -it live-rec rec cookies set youtube # 유튜브는 Netscape cookies.txt 내용 +docker exec live-rec rec cookies # 상태 확인 (값은 출력하지 않음) +``` + +쿠키는 계정 로그인 정보다. `data/cookies/`는 다른 사람과 공유하지 말 것. +브라우저에서 로그아웃하면 쿠키도 무효가 되니 다시 넣어야 한다. + +## 외부 공개 (선택) + +웹 화면을 인터넷에 열 때는 리버스 프록시(Caddy, Nginx 등)로 HTTPS를 붙이고 **OIDC 로그인**을 켠다. +로그인 설정 전에는 프록시를 거친 요청(`X-Forwarded-For` 있음)을 모두 거부하고, 같은 네트워크에서 포트로 직접 들어온 접속만 허용한다. + +1. OIDC 제공자(Gitea, Authentik, Keycloak 등)에 OAuth2 앱을 만든다. + - 리디렉션 URI: `https://<공개 주소>/auth/callback` + - 기밀(confidential) 클라이언트 +2. `.env`에 `REC_OIDC_ISSUER`, `REC_OIDC_CLIENT_ID`, `REC_PUBLIC_URL`, `REC_ALLOWED_USERS`를 채우고 `docker compose up -d` +3. 클라이언트 시크릿을 넣는다: `docker exec -it live-rec rec auth secret` +4. 상태 확인: `docker exec live-rec rec auth` + +허용 계정은 OIDC `preferred_username`으로 확인한다. 세션은 30일 유지된다. + +Caddy 예시: + +``` +rec.example.com { + reverse_proxy 127.0.0.1:8790 +} +``` + +## 구조 + +| 파일 | 내용 | +|---|---| +| `rec.py` | 데몬(작업마다 스레드, yt-dlp 실행·감시)과 CLI | +| `web.py` | 웹 서버 (표준 라이브러리), OIDC 로그인 | +| `index.html` | 웹 화면 | + +상태는 `data/` 아래 파일로만 주고받는다. + +| 경로 | 내용 | +|---|---| +| `jobs//job.json` | 요청 | +| `jobs//state.json` | 상태 (데몬만 씀) | +| `jobs//log.txt` | 데몬 이벤트와 yt-dlp 출력 | +| `cookies/` | 쿠키 | +| `auth/` | 세션 키, OIDC 시크릿 | + +녹화 중 파일은 `/.rec-tmp//`에 있다가 끝나면 이동한다. + +참고: +- 유튜브용 JS 런타임 deno를 포함한다. +- 치지직 라이브의 fMP4 조각(`.m4s`) 때문에 ffmpeg에 `-extension_picky 0`을 넘긴다. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..b0ffa76 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,36 @@ +services: + live-rec: + build: + context: . + args: + PUID: ${PUID:-1000} + PGID: ${PGID:-1000} + image: live-rec:local + container_name: live-rec + restart: unless-stopped + init: true + environment: + - TZ=${TZ:-Asia/Seoul} + # 여유 공간이 이보다 적으면 녹화를 저장하고 멈춘다 + - REC_MIN_FREE_GB=${REC_MIN_FREE_GB:-20} + # 웹 화면에 표시할 저장 위치 (호스트 경로) + - REC_DIR_HOST=${REC_DIR:-./recordings} + # 웹 화면 OIDC 로그인 (선택). 비우면 같은 네트워크 직접 접속만 허용 + # 시크릿은 data/auth/oidc_client_secret — docker exec -it live-rec rec auth secret + - REC_OIDC_ISSUER=${REC_OIDC_ISSUER:-} + - REC_OIDC_CLIENT_ID=${REC_OIDC_CLIENT_ID:-} + - REC_PUBLIC_URL=${REC_PUBLIC_URL:-} + - REC_ALLOWED_USERS=${REC_ALLOWED_USERS:-} + volumes: + # 대기열·로그·쿠키·로그인 키 (cookies/, auth/ 는 비밀값) + - ./data:/data + # 녹화 저장 위치 + - ${REC_DIR:-./recordings}:/recordings + ports: + - "${REC_WEB_PORT:-8790}:8790" + # 녹화 중 종료 시 파일을 마무리할 시간 + stop_grace_period: 3m + mem_limit: 1g + labels: + # 로컬 빌드 이미지라 Watchtower 등 자동 업데이트 대상에서 뺀다 (yt-dlp는 컨테이너가 스스로 업데이트) + - com.centurylinklabs.watchtower.enable=false diff --git a/index.html b/index.html new file mode 100644 index 0000000..f6dd965 --- /dev/null +++ b/index.html @@ -0,0 +1,458 @@ + + + + + +녹화 대기열 + + + + +
+
+

녹화 대기열

+ +
+
+ +
+

방송 대기 걸기

+
+ + +
+
+ + + + + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ +
+

작업

+ +
+
불러오는 중…
+ +
+
+ 쿠키 (성인 방송·연령 제한 영상용) +
+ + + + +
+
저장된 값은 화면에 다시 보이지 않습니다.
+
+
+
+
+ + + + diff --git a/rec.py b/rec.py new file mode 100644 index 0000000..77ff23f --- /dev/null +++ b/rec.py @@ -0,0 +1,983 @@ +#!/usr/bin/env python3 +"""live-rec — 치지직·유튜브 녹화 대기열 (데몬 + CLI 한 파일) + + rec add [-a "21:00"] [-H 시간] [-m 메모] [-q 1080] [-f 포맷] [-x "yt-dlp 추가 인자"] + 채널 주소면 방송이 켜질 때까지 기다렸다 녹화, 영상 주소면 바로 받는다(유튜브 예약 방송은 시작까지 대기) + -a: 예약 시작 시각(컨테이너 TZ, 기본 한국시간). 그 시각부터 -H 시간 동안 방송을 기다린다 + rec ls [-a] 목록 (기본: 진행 중 + 최근 끝난 10개) + rec st 상세 상태 + 로그 끝부분 + rec stop 대기 취소 / 녹화 중지 (지금까지 분량은 저장) + rec rm 끝난 작업 기록 삭제 (녹화 파일은 그대로) + rec check 등록하지 않고 감지 결과·방송 여부만 확인 + rec cookies 쿠키 파일 상태 (값은 출력하지 않음) + rec cookies set [chzzk|youtube] + 쿠키 붙여넣기 입력 (docker exec -it 로 실행, 입력이 화면에 안 보임) + rec auth [secret] 웹 화면 OIDC 로그인 상태 / 클라이언트 시크릿 입력 (docker exec -it) + rec update yt-dlp 업데이트 + rec daemon 컨테이너 메인 프로세스 + +웹 화면(web.py, 기본 :8790)은 데몬 프로세스 안에서 돌고, 상태는 /data 아래 파일로만 주고받는다. + jobs//job.json 요청 (add가 만들고 이후 바꾸지 않음) + jobs//state.json 상태 (데몬만 씀) + jobs//stop 중지 요청 표시 (있으면 중지) + jobs//files.txt 완성된 파일 경로 + jobs//log.txt 데몬 이벤트 + yt-dlp 출력 + cookies/chzzk.txt, cookies/youtube.txt + Netscape 형식(브라우저 확장으로 내보낸 cookies.txt) 또는 'NID_AUT=...; NID_SES=...' 한 줄 +""" +import argparse +import json +import os +import re +import shlex +import shutil +import signal +import subprocess +import sys +import threading +import time +import urllib.request +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from pathlib import Path + +DATA = Path(os.environ.get('REC_DATA', '/data')) +OUT = Path(os.environ.get('REC_OUT', '/recordings')) +JOBS = DATA / 'jobs' +COOKIES = DATA / 'cookies' +TMP = OUT / '.rec-tmp' # 녹화 중 파일. 같은 마운트 안이라 완료 후 이동이 즉시 끝난다 +MIN_FREE_GB = float(os.environ.get('REC_MIN_FREE_GB', '20')) +UA = ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36') +COOKIE_DOMAIN = {'chzzk': '.naver.com', 'youtube': '.youtube.com'} +FINAL = {'done', 'failed', 'stopped', 'expired'} +MAX_RECONNECT = 30 +STALL_WARN = 120 +SHUTDOWN = threading.Event() +# 예약·표시 시각의 시간대 (컨테이너 TZ, 기본 한국시간) +TZ_NAME = os.environ.get("TZ") or "Asia/Seoul" +LOCAL_TZ = ZoneInfo(TZ_NAME) +TZ_LABEL = "한국시간" if TZ_NAME == "Asia/Seoul" else TZ_NAME +WEB_PORT = int(os.environ.get("REC_WEB_PORT", "8790")) + +STATUS_KO = {'waiting': '대기', 'recording': '녹화중', 'done': '완료', 'failed': '실패', + 'stopped': '중지', 'expired': '만료', 'scheduled': '예약'} +PLATFORM_KO = {'chzzk': '치지직', 'youtube': '유튜브', 'other': '기타'} + + +# ---------- 공통 ---------- + +def now(): + return time.time() + + +def ts(t=None): + return datetime.fromtimestamp(t or now(), LOCAL_TZ).strftime("%Y-%m-%d %H:%M:%S") + + +def read_json(p, default=None): + try: + return json.loads(Path(p).read_text()) + except (FileNotFoundError, json.JSONDecodeError): + return default + + +def write_json(p, obj): + p = Path(p) + tmp = p.with_name(p.name + '.tmp') + tmp.write_text(json.dumps(obj, ensure_ascii=False, indent=1)) + os.replace(tmp, p) + + +def human(n): + n = float(n or 0) + for unit in ('B', 'KB', 'MB', 'GB', 'TB'): + if n < 1024 or unit == 'TB': + return f'{n:.0f}{unit}' if unit in ('B', 'KB') else f'{n:.1f}{unit}' + n /= 1024 + + +def dur(sec): + sec = int(max(sec or 0, 0)) + return f'{sec // 3600}:{sec % 3600 // 60:02d}:{sec % 60:02d}' + + +def dir_size(p): + total = 0 + for f in Path(p).rglob('*'): + try: + if f.is_file(): + total += f.stat().st_size + except FileNotFoundError: + pass + return total + + +def free_gb(): + return shutil.disk_usage(OUT).free / 1024 ** 3 + + +def tail(path, n=20): + try: + with open(path, 'rb') as f: + f.seek(0, 2) + f.seek(max(f.tell() - 16384, 0)) + text = f.read().decode(errors='replace') + except FileNotFoundError: + return [] + lines = [l for l in re.split(r'[\r\n]+', text) if l.strip()] + return lines[-n:] + + +# ---------- 주소 판별 ---------- + +CHZZK_CH = re.compile(r'chzzk\.naver\.com/(?:live/)?([0-9a-f]{32})(?:[/?#]|$)') +YT_VIDEO = re.compile(r'(?:youtube\.com/(?:watch\?(?:.*&)?v=|live/|shorts/)|youtu\.be/)([\w-]{11})') +YT_CHANNEL = re.compile(r'(https?://(?:www\.|m\.)?youtube\.com/' + r'(?:@[^/?#]+|channel/UC[\w-]{22}|c/[^/?#]+|user/[^/?#]+))') + + +def classify(url): + if m := CHZZK_CH.search(url): + return {'platform': 'chzzk', 'mode': 'channel', 'channel_id': m[1]} + if 'chzzk.naver.com' in url: + return {'platform': 'chzzk', 'mode': 'video'} + if m := YT_VIDEO.search(url): + return {'platform': 'youtube', 'mode': 'video', 'video_id': m[1]} + if m := YT_CHANNEL.search(url): + return {'platform': 'youtube', 'mode': 'channel', 'channel_url': m[1]} + if 'youtube.com' in url or 'youtu.be' in url: + return {'platform': 'youtube', 'mode': 'video'} + return {'platform': 'other', 'mode': 'video'} + + +# ---------- 쿠키 ---------- + +def load_cookies(platform): + """-> (쿠키 목록, 형식). 파일이 없으면 ([], None)""" + p = COOKIES / f'{platform}.txt' + if platform not in COOKIE_DOMAIN or not p.exists(): + return [], None + text = p.read_text(errors='replace') + out = [] + if re.search(r'^\S+\t(TRUE|FALSE)\t', text, re.M): + for line in text.splitlines(): + if line.startswith('#HttpOnly_'): + line = line[len('#HttpOnly_'):] + if not line.strip() or line.startswith('#'): + continue + f = line.split('\t') + if len(f) >= 7: + out.append({'domain': f[0], 'path': f[2], 'secure': f[3], 'expires': f[4], + 'name': f[5], 'value': f[6]}) + return out, 'netscape' + for kv in re.split(r';\s*|\n', text.strip()): + if '=' in kv: + k, v = kv.split('=', 1) + out.append({'domain': COOKIE_DOMAIN[platform], 'path': '/', 'secure': 'TRUE', + 'expires': '0', 'name': k.strip(), 'value': v.strip()}) + return out, 'header' + + +def write_ytdlp_cookies(platform, dest): + """yt-dlp용 Netscape 사본을 만든다(yt-dlp가 쿠키 파일을 덮어쓰므로 원본은 건드리지 않음).""" + cookies, _ = load_cookies(platform) + if not cookies: + return None + lines = ['# Netscape HTTP Cookie File'] + for c in cookies: + sub = 'TRUE' if c['domain'].startswith('.') else 'FALSE' + lines.append('\t'.join([c['domain'], sub, c['path'], c['secure'], c['expires'] or '0', + c['name'], c['value']])) + dest = Path(dest) + dest.write_text('\n'.join(lines) + '\n') + os.chmod(dest, 0o600) + return dest + + +def cookie_header(platform, host): + cookies, _ = load_cookies(platform) + return '; '.join(f"{c['name']}={c['value']}" for c in cookies + if host.endswith(c['domain'].lstrip('.'))) + + +# ---------- 플랫폼 조회 ---------- + +def http_json(url, platform=None): + req = urllib.request.Request(url, headers={'User-Agent': UA, 'Accept': 'application/json'}) + if platform: + ck = cookie_header(platform, urllib.request.urlparse(url).hostname) + if ck: + req.add_header('Cookie', ck) + with urllib.request.urlopen(req, timeout=15) as r: + return json.load(r) + + +def chzzk_channel_name(cid): + c = http_json(f'https://api.chzzk.naver.com/service/v1/channels/{cid}', 'chzzk').get('content') + return (c or {}).get('channelName') + + +def chzzk_live(cid): + """-> (방송 중?, 방송 제목, 성인 방송?)""" + c = http_json(f'https://api.chzzk.naver.com/service/v3/channels/{cid}/live-detail', + 'chzzk').get('content') or {} + return c.get('status') == 'OPEN', c.get('liveTitle'), bool(c.get('adult')) + + +def yt_probe(url, cookie=None): + """yt-dlp로 방송 상태 조회 -> ({'live_status','url','title'} 또는 None, 오류 메시지)""" + cmd = ['yt-dlp', '--ignore-config', '--skip-download', '--no-warnings', '--no-playlist', + '--print', '%(live_status)s\t%(webpage_url)s\t%(title)s'] + if cookie: + cmd += ['--cookies', str(cookie)] + try: + r = subprocess.run(cmd + [url], capture_output=True, text=True, timeout=180) + except subprocess.TimeoutExpired: + return None, '조회 시간 초과' + if r.returncode != 0: + err = (r.stderr.strip().splitlines() or ['알 수 없는 오류'])[-1] + return None, err + f = (r.stdout.strip().splitlines() or [''])[-1].split('\t') + ['', '', ''] + return {'live_status': f[0], 'url': f[1], 'title': f[2]}, None + + +# ---------- 작업 실행 (데몬) ---------- + +class Worker(threading.Thread): + def __init__(self, jid): + super().__init__(daemon=True, name=jid) + self.jid = jid + self.dir = JOBS / jid + self.job = read_json(self.dir / 'job.json') + self.state = read_json(self.dir / 'state.json', {}) + self.tmp = TMP / jid + self.proc = None + + # 상태·로그 + + def log(self, msg): + with open(self.dir / 'log.txt', 'a') as f: + f.write(f'[{ts()}] {msg}\n') + + def set(self, **kw): + self.state.update(kw) + self.state['updated_at'] = now() + write_json(self.dir / 'state.json', self.state) + + def stop_requested(self): + return (self.dir / 'stop').exists() + + def deadline(self): + return start_of(self.job) + self.job['wait_hours'] * 3600 + + def sleep(self, sec): + end = now() + sec + while now() < end and not self.stop_requested() and not SHUTDOWN.is_set(): + time.sleep(1) + + def cookie(self): + if self.job['platform'] in COOKIE_DOMAIN: + return write_ytdlp_cookies(self.job['platform'], self.dir / 'cookies.txt') + return None + + def files(self): + try: + return [l for l in (self.dir / 'files.txt').read_text().splitlines() if l.strip()] + except FileNotFoundError: + return [] + + # 흐름 + + def run(self): + try: + self.main() + except Exception as e: + self.log(f'오류: {e!r}') + self.finish('failed', error=str(e)) + + def main(self): + self.collect() # 데몬이 녹화 중 재시작됐다면 남은 임시 파일부터 정리 + # 예약: 시작 시각까지 기다린다 + start = self.job.get('start_at') + if start and now() < start: + self.set(status='scheduled', note=f'{ts(start)} 시작 예약') + self.log(f'예약 대기: {ts(start)} 시작') + while now() < start: + if SHUTDOWN.is_set(): + return + if self.stop_requested(): + return self.finish('stopped') + time.sleep(min(5, max(start - now(), 0.1))) + if self.state.get('status') != 'waiting': + self.set(status='waiting', note='방송 확인 시작') + self.log(f"감시 시작 ({'채널 대기' if self.job['mode'] == 'channel' else '바로 받기'})") + while True: + if SHUTDOWN.is_set(): + return + if self.stop_requested(): + return self.finish('stopped') + target = self.find_target() + if target is None: + if now() > self.deadline(): + return self.finish('done' if self.files() else 'expired') + self.sleep(self.job['interval']) + continue + before = len(self.files()) + result = self.record(target) + self.collect() + if result == 'shutdown': + self.set(status='waiting', note='데몬 재시작 — 다시 이어서 확인') + return + if self.stop_requested(): + return self.finish('stopped') + if result == 'expired': + return self.finish('expired') + if result == 'nospace': + return self.finish('failed', error=f'저장 공간 부족 (여유 {MIN_FREE_GB:.0f}GB 미만)') + # 한 조각도 못 받고 실패하는 일이 반복되면 재연결해도 소용없다 + empty = 0 if len(self.files()) > before else self.state.get('empty_fails', 0) + 1 + self.set(empty_fails=empty) + if result != 0 and empty >= 3: + return self.finish('failed', error=self.last_error()) + if self.job['mode'] == 'channel' and self.still_live(): + n = self.state.get('reconnects', 0) + 1 + if n <= MAX_RECONNECT: + self.set(reconnects=n) + self.log(f'녹화가 끊겼지만 방송은 계속 중 — 다시 연결 ({n}회째)') + time.sleep(10 * empty + 5) + continue + if result == 0 or self.files(): + return self.finish('done') + return self.finish('failed', error=self.last_error()) + + def finish(self, status, **kw): + self.collect() + (self.dir / 'cookies.txt').unlink(missing_ok=True) + self.set(status=status, finished_at=now(), bytes=0, rate=0, pid=None, **kw) + self.log(f'종료: {STATUS_KO[status]}' + (f" — {kw['error']}" if kw.get('error') else '')) + + def find_target(self): + j = self.job + if j['mode'] == 'video': + return j['url'] + try: + if j['platform'] == 'chzzk': + live, title, adult = chzzk_live(j['channel_id']) + if live: + self.set(live_title=title, adult=adult) + self.log(f'방송 감지: {title}' + (' (성인 방송)' if adult else '')) + return f"https://chzzk.naver.com/live/{j['channel_id']}" + else: + info, err = yt_probe(j['channel_url'] + '/live', self.cookie()) + if info and info['live_status'] in ('is_live', 'is_upcoming'): + self.set(live_title=info['title']) + self.log(f"방송 감지({info['live_status']}): {info['title']}") + return info['url'] + self.set(status='waiting', checked_at=now(), note='방송 꺼짐 — 대기 중') + except Exception as e: + self.set(status='waiting', checked_at=now(), note=f'확인 실패: {e}') + return None + + def still_live(self): + for _ in range(3): + try: + if self.job['platform'] == 'chzzk': + return chzzk_live(self.job['channel_id'])[0] + info, _ = yt_probe(self.job['channel_url'] + '/live', self.cookie()) + return bool(info) and info['live_status'] == 'is_live' + except Exception: + time.sleep(10) + return False + + def record(self, url): + j = self.job + start = datetime.now(LOCAL_TZ).strftime('%Y%m%d_%H%M%S') + self.tmp.mkdir(parents=True, exist_ok=True) + cmd = ['yt-dlp', '--ignore-config', '--newline', '--no-colors', '--progress-delta', '10', + '--windows-filenames', '--no-playlist', + '-P', f'home:{OUT}', '-P', f'temp:{self.tmp}', + '-o', f'%(uploader,channel|기타)s/{start} %(fulltitle).80B [%(id)s].%(ext)s', + '--print-to-file', 'after_move:filepath', str(self.dir / 'files.txt'), + '--merge-output-format', 'mp4', + # 치지직 fMP4 조각(.m4s)을 ffmpeg 7.1+가 확장자 검사로 거부하는 문제 + '--downloader-args', 'ffmpeg_i:-extension_picky 0'] + cookie = self.cookie() + if cookie: + cmd += ['--cookies', str(cookie)] + if j['platform'] == 'youtube': + cmd += ['--wait-for-video', '30-60'] + if j.get('quality'): + cmd += ['-S', f"res:{j['quality']}"] # 이 해상도 이하 중 최고 + if j.get('format'): + cmd += ['-f', j['format']] + cmd += j.get('extra_args', []) + cmd.append(url) + + self.log(f'yt-dlp 시작: {url}') + with open(self.dir / 'log.txt', 'a') as logf: + self.proc = subprocess.Popen(cmd, stdout=logf, stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, start_new_session=True) + self.set(pid=self.proc.pid, note='시작 중', part_started_at=now(), stalled_since=None) + last_size, last_t, stopping, result = 0, now(), None, None + while (rc := self.proc.poll()) is None: + time.sleep(5) + size, t = dir_size(self.tmp), now() + rate = max(size - last_size, 0) / (t - last_t) + st = {'bytes': size, 'rate': rate} + if size > 0: + st['status'] = 'recording' + if not self.state.get('recording_since') or self.state.get('status') != 'recording': + st['recording_since'] = t + if size == last_size: + stalled = self.state.get('stalled_since') or t + st['stalled_since'] = stalled + st['note'] = f'⚠ {dur(t - stalled)}째 용량 증가 없음' if t - stalled >= STALL_WARN else '녹화 중' + else: + st['stalled_since'], st['note'] = None, '녹화 중' + else: + st['note'] = '방송 시작 대기 중' if j['platform'] == 'youtube' else '연결 중' + self.set(**st) + last_size, last_t = size, t + + if stopping is None: + why = None + if SHUTDOWN.is_set(): + why, result = '데몬 종료', 'shutdown' + elif self.stop_requested(): + why = '중지 요청' + elif size == 0 and t > self.deadline(): + why, result = '대기 시간 초과', 'expired' + elif free_gb() < MIN_FREE_GB: + why, result = '저장 공간 부족', 'nospace' + if why: + self.log(f'{why} — 지금까지 분량을 저장하고 종료') + stopping = t + self.proc.send_signal(signal.SIGINT) # yt-dlp가 ffmpeg에 q를 보내 파일을 정상 마무리 + elif t - stopping > 1800: # 긴 녹화는 마무리(MP4 변환)에 몇 분 걸린다 + self.log('정상 종료가 늦어 강제 종료') + self.proc.kill() + self.proc = None + self.set(pid=None, bytes=0, rate=0, stalled_since=None) + self.log(f'yt-dlp 종료 (코드 {rc})') + return result or rc + + def collect(self): + """임시 폴더에 남은 파일(강제 종료·오류로 이동 못 한 분량)을 저장 위치로 옮긴다.""" + if self.tmp.exists(): + for f in sorted(self.tmp.rglob('*')): + if not f.is_file(): + continue + name = f.name + if (f.stat().st_size == 0 or re.search(r'\.(ytdl|json|temp)$|\.part-Frag', name)): + f.unlink() + continue + if name.endswith('.part'): + name = name[:-5] + dest = OUT / f.relative_to(self.tmp).parent / name + dest.parent.mkdir(parents=True, exist_ok=True) + stem, n = dest, 1 + while dest.exists(): + dest = stem.with_name(f'{stem.stem} ({n}){stem.suffix}') + n += 1 + shutil.move(f, dest) + with open(self.dir / 'files.txt', 'a') as fl: + fl.write(f'{dest}\n') + self.log(f'남은 임시 파일 보관: {dest}') + shutil.rmtree(self.tmp, ignore_errors=True) + files = self.files() + total = sum(Path(p).stat().st_size for p in files if Path(p).exists()) + if files != self.state.get('files') or total != self.state.get('total_bytes'): + self.set(files=files, total_bytes=total) + + def last_error(self): + errs = [l for l in tail(self.dir / 'log.txt', 60) if 'ERROR' in l] + return errs[-1] if errs else '알 수 없는 오류 (rec st로 로그 확인)' + + +def ytdlp_version(): + try: + return subprocess.run(['yt-dlp', '--version'], capture_output=True, text=True, + timeout=30).stdout.strip() + except Exception: + return '?' + + +def update_ytdlp(): + before = ytdlp_version() + r = subprocess.run([sys.executable, '-m', 'pip', 'install', '-q', '-U', '--no-cache-dir', + 'yt-dlp[default]'], capture_output=True, text=True, timeout=600) + after = ytdlp_version() + if r.returncode != 0: + return f'yt-dlp 업데이트 실패: {(r.stderr.strip().splitlines() or ["?"])[-1]}' + return f'yt-dlp {before} → {after}' if before != after else f'yt-dlp {after} (최신)' + + +def daemon(): + for d in (JOBS, COOKIES, TMP): + d.mkdir(parents=True, exist_ok=True) + os.chmod(COOKIES, 0o700) + signal.signal(signal.SIGTERM, lambda *_: SHUTDOWN.set()) + signal.signal(signal.SIGINT, lambda *_: SHUTDOWN.set()) + print(f'[{ts()}] {update_ytdlp()}', flush=True) + ver = ytdlp_version() + workers = {} + started, updated_day = now(), datetime.now(LOCAL_TZ).date() + import web # 같은 폴더의 web.py — 데몬과 같은 프로세스에서 웹 화면 제공 + threading.Thread(target=web.serve, args=(WEB_PORT, sys.modules[__name__]), daemon=True, + name='web').start() + print(f'[{ts()}] 웹 화면 :{WEB_PORT}', flush=True) + while not SHUTDOWN.is_set(): + present = set() + for d in sorted(JOBS.iterdir()): + if not d.is_dir() or not (d / 'job.json').exists(): + continue + present.add(d.name) + if d.name in workers: + continue + if read_json(d / 'state.json', {}).get('status') in FINAL: + workers[d.name] = None + continue + w = workers[d.name] = Worker(d.name) + w.start() + print(f'[{ts()}] 작업 시작 {d.name}', flush=True) + for k in set(workers) - present: + del workers[k] + busy = [w for w in workers.values() if w and w.is_alive() and w.proc] + # 매일 05시, 녹화가 없을 때 yt-dlp 업데이트 (사이트 변경 대응) + if datetime.now(LOCAL_TZ).hour == 5 and datetime.now(LOCAL_TZ).date() != updated_day and not busy: + updated_day = datetime.now(LOCAL_TZ).date() + print(f'[{ts()}] {update_ytdlp()}', flush=True) + ver = ytdlp_version() + write_json(DATA / 'daemon.json', {'pid': os.getpid(), 'started_at': started, + 'updated_at': now(), 'ytdlp': ver, + 'active': len([w for w in workers.values() if w and w.is_alive()]), + 'recording': len(busy), 'free_gb': round(free_gb(), 1)}) + SHUTDOWN.wait(3) + print(f'[{ts()}] 종료 신호 — 녹화 중인 작업을 저장하는 중', flush=True) + for w in workers.values(): + if w and w.is_alive(): + w.join(timeout=170) + + +# ---------- 작업 관리 (CLI·웹 공용) ---------- + +class RecError(Exception): + """사용자에게 그대로 보여줄 오류""" + + +def parse_at(s): + """'2026-10-04 21:00' / '2026-10-04T21:00' / '10-04 21:00' / '21:00' → epoch (TZ 기준, 기본 한국시간). + 시각만 주면 오늘 그 시각, 이미 지났으면 내일.""" + s = s.strip().replace('T', ' ') + s = re.sub(r'(\+09:?00|Z)$', '', s).strip() + n = datetime.now(LOCAL_TZ) + for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M', '%m-%d %H:%M', '%H:%M'): + try: + d = datetime.strptime(s, fmt) + except ValueError: + continue + if fmt == '%H:%M': + d = n.replace(hour=d.hour, minute=d.minute, second=0, microsecond=0) + if d.timestamp() < n.timestamp(): + d = d + timedelta(days=1) + return d.timestamp() + if fmt == '%m-%d %H:%M': + d = d.replace(year=n.year) + return d.replace(tzinfo=LOCAL_TZ).timestamp() + raise RecError(f'시각 형식을 모름: {s} (예: 2026-10-04 21:00, 21:00)') + + +def find_job(prefix): + ids = sorted(d.name for d in JOBS.iterdir() if d.is_dir() and d.name.startswith(prefix)) + if not ids: + raise RecError(f'작업 없음: {prefix}') + if len(ids) > 1 and prefix not in ids: + raise RecError(f'여러 작업과 일치: {" ".join(ids)}') + return prefix if prefix in ids else ids[0] + + +def load(jid): + d = JOBS / jid + return read_json(d / 'job.json', {}), read_json(d / 'state.json', {}) + + +def label(job, state): + return job.get('memo') or job.get('title') or state.get('live_title') or job['url'] + + +def start_of(job): + return job.get('start_at') or job['created_at'] + + +def create_job(url, hours=12, memo=None, fmt=None, interval=None, extra=None, start_at=None, + quality=None): + """대기 등록 -> (작업, 안내 메시지 목록)""" + url = (url or '').strip() + if not re.match(r'https?://', url): + raise RecError('주소는 http(s)://로 시작해야 함') + if not 0 < float(hours) <= 72: + raise RecError('대기 시간은 0~72시간') + info = classify(url) + jid = datetime.now(LOCAL_TZ).strftime('%m%d-%H%M%S') + while (JOBS / jid).exists(): + jid += 'x' + t = now() + if start_at and start_at <= t + 30: + start_at = None # 지금이나 과거면 바로 시작 + job = {'id': jid, 'url': url, 'created_at': t, 'start_at': start_at, 'wait_hours': float(hours), + 'interval': int(interval or (30 if info['platform'] == 'chzzk' else 120)), + 'memo': (memo or '').strip() or None, 'format': fmt or None, + 'quality': int(quality) if quality else None, + 'extra_args': shlex.split(extra or ''), **info} + msgs = [] + if info['platform'] == 'chzzk' and info['mode'] == 'channel': + try: + job['title'] = chzzk_channel_name(info['channel_id']) + live, title, adult = chzzk_live(info['channel_id']) + msgs.append(f'지금 방송 중: {title}' if live else '지금은 방송 꺼짐') + if adult and not load_cookies('chzzk')[0]: + msgs.append('⚠ 성인 방송인데 치지직 쿠키가 없음 — 녹화가 실패할 수 있음') + except Exception as e: + msgs.append(f'채널 조회 실패({e}) — 대기는 등록함') + d = JOBS / jid + d.mkdir(parents=True) + write_json(d / 'job.json', job) + status = 'scheduled' if start_at else 'waiting' + write_json(d / 'state.json', {'status': status, 'note': '등록됨', 'updated_at': now()}) + return job, msgs + + +def job_view(jid, log_lines=0): + """웹·CLI 표시용으로 작업과 상태를 합친다""" + job, st = load(jid) + s = st.get('status', '?') + v = {**st, 'id': jid, 'job': job, 'label': label(job, st), 'status': s, + 'status_ko': STATUS_KO.get(s, s), 'platform_ko': PLATFORM_KO.get(job.get('platform'), '?'), + 'start_at': start_of(job), 'deadline': start_of(job) + job.get('wait_hours', 0) * 3600, + 'final': s in FINAL} + v['files'] = [{'path': str(Path(f).relative_to(OUT)) if Path(f).is_relative_to(OUT) else f, + 'size': Path(f).stat().st_size if Path(f).exists() else None} + for f in st.get('files', [])] + if log_lines: + v['log'] = [l[:400] for l in tail(JOBS / jid / 'log.txt', log_lines)] + return v + + +def list_jobs(all_=False): + ids = [d.name for d in sorted(JOBS.iterdir(), reverse=True) + if d.is_dir() and (d / 'job.json').exists()] + views = [job_view(i) for i in ids] + active = [v for v in views if not v['final']] + active.sort(key=lambda v: (v['status'] != 'recording', v['status'] == 'scheduled', v['start_at'])) + done = [v for v in views if v['final']] + return active + (done if all_ else done[:20]) + + +def stop_job(jid): + _, st = load(jid) + if st.get('status') in FINAL: + raise RecError(f"이미 끝난 작업 ({STATUS_KO[st['status']]})") + (JOBS / jid / 'stop').touch() + return '녹화분 저장 후 종료' if st.get('status') == 'recording' else '대기 취소' + + +def remove_job(jid): + _, st = load(jid) + if st.get('status') not in FINAL: + raise RecError('진행 중인 작업 — 먼저 중지') + shutil.rmtree(JOBS / jid) + + +def check_url(url): + info = classify(url) + r = {'platform': info['platform'], 'platform_ko': PLATFORM_KO[info['platform']], + 'mode': info['mode'], 'mode_ko': '채널 대기' if info['mode'] == 'channel' else '영상'} + if info['platform'] == 'chzzk' and info['mode'] == 'channel': + live, title, adult = chzzk_live(info['channel_id']) + r.update(name=chzzk_channel_name(info['channel_id']), live=live, title=title, adult=adult) + return r + cookie = None + if info['platform'] in COOKIE_DOMAIN: + cookie = write_ytdlp_cookies(info['platform'], DATA / f'.check-{os.getpid()}-{threading.get_ident()}.txt') + try: + res, err = yt_probe(info['channel_url'] + '/live' if info['mode'] == 'channel' else url, cookie) + finally: + if cookie: + cookie.unlink(missing_ok=True) + if res: + r.update(live=res['live_status'] in ('is_live', 'is_upcoming'), live_status=res['live_status'], + title=res['title']) + else: + r.update(live=False, error=err) + return r + + +COOKIE_KEYS = {'chzzk': ['NID_AUT', 'NID_SES'], 'youtube': ['SAPISID', '__Secure-3PSID', 'LOGIN_INFO']} + + +def save_cookies(platform, text): + """쿠키 한 줄(또는 Netscape 파일 내용) 저장. 필수 쿠키가 없으면 바꾸지 않는다.""" + if platform not in COOKIE_KEYS: + raise RecError(f'모르는 플랫폼: {platform}') + text = (text or '').strip().strip('"\'') + if not text: + raise RecError('입력 없음 — 바꾸지 않음') + names = (set(re.findall(r'(?:^|[;\s])([A-Za-z0-9_.-]+)=', text)) + | set(re.findall(r'\t([^\t]+)\t[^\t]*$', text, re.M))) + missing = [k for k in COOKIE_KEYS[platform][:2] if k not in names] + if missing: + raise RecError(f"필요한 쿠키가 없음: {', '.join(missing)} — 바꾸지 않음") + COOKIES.mkdir(parents=True, exist_ok=True) + path = COOKIES / f'{platform}.txt' + tmp = path.with_name(path.name + '.tmp') + tmp.write_text(text + '\n') + os.chmod(tmp, 0o600) + os.replace(tmp, path) + + +def cookie_status(): + """쿠키 상태 (값은 절대 포함하지 않음)""" + out = [] + for platform, keys in COOKIE_KEYS.items(): + cookies, fmt = load_cookies(platform) + path = COOKIES / f'{platform}.txt' + r = {'platform': platform, 'platform_ko': PLATFORM_KO[platform], 'present': bool(cookies)} + if cookies: + names = {c['name'] for c in cookies} + exp = [int(c['expires']) for c in cookies if c['name'] in keys and c['expires'].isdigit() + and int(c['expires']) > 0] + r.update(format=fmt, count=len(cookies), modified=path.stat().st_mtime, + keys={k: k in names for k in keys}, expires=min(exp) if exp else None) + if platform == 'chzzk': + try: + c = http_json('https://comm-api.game.naver.com/nng_main/v1/user/getUserStatus', + 'chzzk').get('content') or {} + r['logged_in'] = bool(c.get('loggedIn')) + except Exception as e: + r['login_error'] = str(e) + out.append(r) + return out + + +def daemon_info(): + d = read_json(DATA / 'daemon.json') or {} + d['alive'] = bool(d) and now() - d.get('updated_at', 0) <= 30 + d['out_dir'] = os.environ.get('REC_DIR_HOST', str(OUT)) + d['tz'], d['tz_label'] = TZ_NAME, TZ_LABEL + return d + + +# ---------- CLI ---------- + +def daemon_warning(): + return None if daemon_info()['alive'] else '⚠ 데몬이 응답하지 않음 — 컨테이너 상태 확인 필요' + + +def cmd_add(a): + start_at = parse_at(a.at) if a.at else None + job, msgs = create_job(a.url, a.hours, a.memo, a.format, a.interval, a.extra, start_at, a.quality) + mode = '채널 방송 대기' if job['mode'] == 'channel' else '바로 받기' + print(f"등록: {job['id']} [{PLATFORM_KO[job['platform']]} · {mode}] {label(job, {})}") + if job['start_at']: + print(f"예약: {ts(job['start_at'])} ({TZ_LABEL})부터 시작") + if job['mode'] == 'channel' or job['platform'] == 'youtube': + print(f"대기 한도: {ts(start_of(job) + job['wait_hours'] * 3600)} 까지 ({job['wait_hours']:g}시간)") + for m in msgs: + print(m) + + +def cmd_ls(a): + if w := daemon_warning(): + print(w) + rows = list_jobs(a.all) + if not rows: + print('작업 없음') + for v in rows: + s = v['status'] + if s == 'recording': + extra = (f"{human(v.get('total_bytes', 0) + v.get('bytes', 0))} " + f"{human(v.get('rate'))}/s {dur(now() - v.get('recording_since', now()))}") + elif s == 'scheduled': + extra = f"{ts(v['start_at'])} 시작" + elif s == 'waiting': + extra = v.get('note', '') + if v.get('checked_at'): + extra += f" (확인 {int(now() - v['checked_at'])}초 전)" + else: + extra = f"{human(v.get('total_bytes'))} 파일 {len(v['files'])}개" + if v.get('error'): + extra += f" {v['error'][:60]}" + print(f"{v['id']} {v['status_ko']:<3} {v['platform_ko']:<3} {v['label'][:30]} | {extra}") + + +def cmd_st(a): + v = job_view(find_job(a.id), a.lines) + job, s = v['job'], v['status'] + print(f"ID {v['id']}") + print(f"상태 {v['status_ko']} {v.get('note', '') if not v['final'] else ''}") + print(f"대상 {v['platform_ko']} · {'채널 대기' if job['mode'] == 'channel' else '영상'} {v['label']}") + print(f"주소 {job['url']}") + print(f"등록 {ts(job['created_at'])}" + (f" 예약 {ts(job['start_at'])}" if job.get('start_at') else '') + + f" 대기 한도 {ts(v['deadline'])}") + if v.get('live_title'): + print(f"방송 {v['live_title']}" + (' (성인)' if v.get('adult') else '')) + if v.get('checked_at'): + print(f"마지막 확인 {ts(v['checked_at'])}") + if s == 'recording': + print(f"녹화 {dur(now() - v.get('recording_since', now()))} 경과 " + f"현재 파일 {human(v.get('bytes'))} 속도 {human(v.get('rate'))}/s") + if v.get('reconnects'): + print(f"재연결 {v['reconnects']}회") + if v.get('finished_at'): + print(f"종료 {ts(v['finished_at'])}") + if v.get('error'): + print(f"오류 {v['error']}") + for f in v['files']: + print(f"파일 {f['path']} {human(f['size']) if f['size'] is not None else '(없음)'}") + print(f'여유공간 {free_gb():.0f}GB') + if w := daemon_warning(): + print(w) + print('--- 로그 ---') + for line in v.get('log', []): + print(line[:300]) + + +def cmd_stop(a): + jid = find_job(a.id) + print(f'{jid} 중지 요청 — {stop_job(jid)}') + + +def cmd_rm(a): + jid = find_job(a.id) + remove_job(jid) + print(f'{jid} 기록 삭제 (녹화 파일은 그대로)') + + +def cmd_check(a): + r = check_url(a.url) + print(f"판별: {r['platform_ko']} · {r['mode_ko']}") + if r.get('name'): + print(f"채널: {r['name']}") + if r.get('error'): + print(f"결과: {r['error']}") + else: + print(f"방송: {'켜짐 — ' + str(r.get('title')) if r['live'] else '꺼짐'}" + + (f" ({r['live_status']})" if r.get('live_status') else '') + + (' (성인)' if r.get('adult') else '')) + + +def cmd_cookies(a): + if a.action == 'set': + if sys.stdin.isatty(): + import getpass + print(f"{PLATFORM_KO[a.platform]} 쿠키를 붙여넣고 Enter (입력 내용은 화면에 표시되지 않음)") + print(" 형식: NID_AUT=...; NID_SES=... (크롬 확장 '치지직 도우미' → 한 줄로 복사)" + if a.platform == 'chzzk' else " 형식: 이름=값; 이름=값 ...") + text = getpass.getpass('> ') + else: + text = sys.stdin.read() # 파이프: Netscape 파일도 그대로 넣을 수 있다 + save_cookies(a.platform, text) + print(f'저장함: {COOKIES / (a.platform + ".txt")}') + for r in cookie_status(): + if not r['present']: + print(f"{r['platform_ko']}: 없음 ({COOKIES / (r['platform'] + '.txt')})") + continue + line = (f"{r['platform_ko']}: {r['format']} 형식, {r['count']}개, 수정 {ts(r['modified'])}" + f" 핵심 쿠키 " + ' '.join(f"{k}{'✓' if ok else '✗'}" for k, ok in r['keys'].items())) + if r.get('expires'): + line += f" 가장 빠른 만료 {ts(r['expires'])}" + if 'logged_in' in r: + line += f" 로그인 {'확인됨' if r['logged_in'] else '안 됨(만료?)'}" + elif r.get('login_error'): + line += f" 로그인 확인 실패({r['login_error']})" + print(line) + + +def cmd_auth(a): + """웹 화면 OIDC 로그인 설정 상태 / 클라이언트 시크릿 입력 (값은 출력하지 않음)""" + d = DATA / 'auth' + d.mkdir(parents=True, exist_ok=True) + os.chmod(d, 0o700) + f = d / 'oidc_client_secret' + if a.action == 'secret': + if sys.stdin.isatty(): + import getpass + print("OIDC(OAuth2) 앱의 클라이언트 시크릿을 붙여넣고 Enter (화면에 표시되지 않음)") + text = getpass.getpass('> ') + else: + text = sys.stdin.read() + text = text.strip() + if len(text) < 16: + raise RecError('시크릿이 너무 짧음 — 바꾸지 않음') + tmp = f.with_name(f.name + '.tmp') + tmp.write_text(text + '\n') + os.chmod(tmp, 0o600) + os.replace(tmp, f) + print('저장함 — 바로 적용됨') + cid = os.environ.get('REC_OIDC_CLIENT_ID', '').strip() + secret = f.read_text().strip() if f.exists() else '' + issuer = os.environ.get('REC_OIDC_ISSUER', '').strip() + public = os.environ.get('REC_PUBLIC_URL', '').strip().rstrip('/') + allowed = os.environ.get('REC_ALLOWED_USERS', '').strip() + print(f"발급자(issuer) {issuer or '없음 (.env REC_OIDC_ISSUER)'}") + print(f"클라이언트 ID {cid or '없음 (.env REC_OIDC_CLIENT_ID)'}") + print(f"시크릿 {f'있음 ({len(secret)}자)' if secret else '없음 (rec auth secret)'}") + print(f"공개 주소 {public or '없음 (.env REC_PUBLIC_URL)'}" + + (f" → 리디렉션 URI {public}/auth/callback" if public else '')) + print(f"허용 계정 {allowed or '없음 (.env REC_ALLOWED_USERS)'}") + on = all([cid, secret, issuer, public, allowed]) + print('상태 ' + ('로그인 필요 (켜짐)' if on else '꺼짐 — 리버스 프록시를 거치지 않은 사설망 직접 접속만 허용')) + + +def cmd_update(a): + if daemon_info().get('recording'): + print('⚠ 녹화 중인 작업이 있음 — 진행 중인 yt-dlp에는 영향 없음') + print(update_ytdlp()) + + +def main(): + p = argparse.ArgumentParser(prog='rec', description='치지직·유튜브 녹화 대기열') + sub = p.add_subparsers(dest='cmd', required=True) + s = sub.add_parser('add', help='대기 등록') + s.add_argument('url') + s.add_argument('-H', '--hours', type=float, default=12, help='방송 대기 최대 시간 (기본 12)') + s.add_argument('-a', '--at', help='예약 시작 (TZ 기준, 기본 한국시간): "2026-10-04 21:00", "10-04 21:00", "21:00"') + s.add_argument('-m', '--memo', help='목록에 보일 이름') + s.add_argument('-q', '--quality', type=int, help='최대 해상도 (예: 1080, 720)') + s.add_argument('-f', '--format', help='yt-dlp 포맷 (예: "bv*[height<=720]+ba/b")') + s.add_argument('-i', '--interval', type=int, help='방송 확인 간격 초 (치지직 30, 유튜브 120)') + s.add_argument('-x', '--extra', help='yt-dlp 추가 인자 (따옴표로 묶어서)') + s.set_defaults(fn=cmd_add) + s = sub.add_parser('ls', help='목록') + s.add_argument('-a', '--all', action='store_true') + s.set_defaults(fn=cmd_ls) + s = sub.add_parser('st', help='상세 상태') + s.add_argument('id') + s.add_argument('-n', '--lines', type=int, default=15) + s.set_defaults(fn=cmd_st) + for name, fn, h in (('stop', cmd_stop, '중지/취소'), ('rm', cmd_rm, '끝난 작업 기록 삭제')): + s = sub.add_parser(name, help=h) + s.add_argument('id') + s.set_defaults(fn=fn) + s = sub.add_parser('check', help='등록 없이 확인') + s.add_argument('url') + s.set_defaults(fn=cmd_check) + s = sub.add_parser('cookies', help='쿠키 상태 / rec cookies set chzzk 로 입력') + s.add_argument('action', nargs='?', choices=['set']) + s.add_argument('platform', nargs='?', choices=['chzzk', 'youtube'], default='chzzk') + s.set_defaults(fn=cmd_cookies) + s = sub.add_parser('auth', help='웹 OIDC 로그인 상태 / rec auth secret 으로 시크릿 입력') + s.add_argument('action', nargs='?', choices=['secret']) + s.set_defaults(fn=cmd_auth) + sub.add_parser('update', help='yt-dlp 업데이트').set_defaults(fn=cmd_update) + sub.add_parser('daemon', help='데몬 실행').set_defaults(fn=lambda a: daemon()) + a = p.parse_args() + try: + a.fn(a) + except RecError as e: + sys.exit(str(e)) + + +if __name__ == '__main__': + main() diff --git a/web.py b/web.py new file mode 100644 index 0000000..e38fee6 --- /dev/null +++ b/web.py @@ -0,0 +1,288 @@ +"""live-rec 웹 화면 — 데몬 프로세스 안에서 표준 라이브러리 HTTP 서버로 동작한다. + +작업 관리는 rec 모듈(데몬 본체)의 함수를 그대로 쓴다. 상태는 /data 아래 파일이 원본. + + GET / 화면 (index.html) + GET /api/jobs[?all=1] 목록 + 데몬 상태 + GET /api/jobs/ 상세 + 로그 + POST /api/jobs 등록 {url, memo, hours, start_at:"YYYY-MM-DDTHH:MM"(컨테이너 TZ), quality} + POST /api/jobs//stop 중지 + DELETE /api/jobs/ 기록 삭제 (녹화 파일은 그대로) + POST /api/check {url} 방송 여부 확인 + GET /api/cookies 쿠키 상태 (값은 절대 내보내지 않음) + POST /api/cookies/ {text} 쿠키 저장 + +변경 요청(POST/DELETE)은 X-Rec 헤더가 있어야 한다 — 다른 사이트에서 몰래 보내는 요청(CSRF) 차단. + +로그인: OIDC(PKCE, Gitea·Authentik·Keycloak 등). REC_OIDC_ISSUER·REC_OIDC_CLIENT_ID·REC_PUBLIC_URL· +REC_ALLOWED_USERS와 시크릿(data/auth/oidc_client_secret)이 모두 있으면 +모든 화면·API에 로그인이 필요하고(세션 30일, REC_ALLOWED_USERS만 허용), 없으면 리버스 프록시를 거치지 않은 +사설망 직접 접속(X-Forwarded-For 없음)만 허용한다. +""" +import base64 +import hashlib +import hmac +import html +import ipaddress +import json +import os +import re +import secrets +import time +import traceback +import urllib.parse +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +HERE = Path(__file__).resolve().parent +MAX_BODY = 64 * 1024 + + + + +# ---------- 로그인 (OIDC) ---------- + +class Auth: + """OIDC 로그인 (preferred_username으로 허용 계정 확인). 설정(.env)과 시크릿 파일(rec auth secret)이 모두 있어야 켜진다. + 꺼져 있을 때는 사설망에서 포트로 직접 들어온 요청만 허용한다(리버스 프록시를 거친 외부 요청은 거부).""" + + def __init__(self, rec): + self.dir = rec.DATA / 'auth' + self.dir.mkdir(parents=True, exist_ok=True) + os.chmod(self.dir, 0o700) + self.client_id = os.environ.get('REC_OIDC_CLIENT_ID', '').strip() + self.issuer = os.environ.get('REC_OIDC_ISSUER', '').strip().rstrip('/') + self.public = os.environ.get('REC_PUBLIC_URL', '').strip().rstrip('/') + self.allowed = {u.strip().lower() for u in os.environ.get('REC_ALLOWED_USERS', '').split(',') + if u.strip()} + kf = self.dir / 'session_key' + if not kf.exists(): + kf.write_bytes(secrets.token_bytes(32)) + os.chmod(kf, 0o600) + self.key = kf.read_bytes() + self._meta = None + + def secret(self): + try: + return (self.dir / 'oidc_client_secret').read_text().strip() + except FileNotFoundError: + return '' + + def enabled(self): + return bool(self.client_id and self.secret() and self.issuer and self.public and self.allowed) + + def meta(self): + if not self._meta: + with urllib.request.urlopen(f'{self.issuer}/.well-known/openid-configuration', timeout=10) as r: + self._meta = json.load(r) + return self._meta + + def sign(self, obj): + data = base64.urlsafe_b64encode(json.dumps(obj).encode()).decode().rstrip('=') + mac = hmac.new(self.key, data.encode(), hashlib.sha256).hexdigest() + return f'{data}.{mac}' + + def verify(self, token): + try: + data, mac = (token or '').rsplit('.', 1) + if not hmac.compare_digest(mac, hmac.new(self.key, data.encode(), hashlib.sha256).hexdigest()): + return None + obj = json.loads(base64.urlsafe_b64decode(data + '=' * (-len(data) % 4))) + return obj if obj.get('exp', 0) > time.time() else None + except Exception: + return None + + +def cookie_header(name, value, max_age, secure): + c = f'{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}' + return c + ('; Secure' if secure else '') + + +def page(title, body): + return (f'' + f'{html.escape(title)}' + '' + f'

{html.escape(title)}

{body}').encode() + + +def serve(port, rec): + auth = Auth(rec) + + class Handler(BaseHTTPRequestHandler): + server_version = 'live-rec' + + def log_message(self, fmt, *args): # 접근 로그는 남기지 않음 (docker logs가 지저분해짐) + pass + + def send(self, code, body, ctype='application/json; charset=utf-8', headers=()): + data = body if isinstance(body, bytes) else json.dumps(body, ensure_ascii=False).encode() + self.send_response(code) + self.send_header('Content-Type', ctype) + self.send_header('Content-Length', str(len(data))) + self.send_header('Cache-Control', 'no-store') + self.send_header('X-Content-Type-Options', 'nosniff') + self.send_header('Referrer-Policy', 'same-origin') + for k, v in headers: + self.send_header(k, v) + self.end_headers() + self.wfile.write(data) + + def redirect(self, url, cookies=()): + self.send(302, b'', 'text/plain', [('Location', url)] + [('Set-Cookie', c) for c in cookies]) + + def cookies(self): + out = {} + for part in (self.headers.get('Cookie') or '').split(';'): + if '=' in part: + k, v = part.strip().split('=', 1) + out[k] = v + return out + + def https(self): + return self.headers.get('X-Forwarded-Proto') == 'https' + + def user(self): + s = auth.verify(self.cookies().get('rec_session')) + return s and s.get('u') + + def direct_lan(self): + """리버스 프록시를 거치지 않고 사설망에서 포트로 직접 들어온 요청인가""" + try: + ip = ipaddress.ip_address(self.client_address[0]) + except ValueError: + return False + return ip.is_private and 'X-Forwarded-For' not in self.headers + + def body(self): + n = int(self.headers.get('Content-Length') or 0) + if n > MAX_BODY: + raise rec.RecError('요청이 너무 큼') + try: + return json.loads(self.rfile.read(n) or b'{}') + except json.JSONDecodeError: + raise rec.RecError('JSON 형식이 아님') + + # 로그인 흐름: /auth/login → OIDC 제공자 → /auth/callback → 세션 쿠키(30일) + + def auth_route(self, path, query): + q = urllib.parse.parse_qs(query) + if path == '/auth/login': + if not auth.enabled(): + return self.send(503, page('로그인 설정 전', '

로그인(OIDC)이 아직 설정되지 않았습니다.

'), + 'text/html; charset=utf-8') + nxt = (q.get('next') or ['/'])[0] + if not nxt.startswith('/') or nxt.startswith('//'): + nxt = '/' + state, verifier = secrets.token_urlsafe(24), secrets.token_urlsafe(48) + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip('=') + params = urllib.parse.urlencode({ + 'response_type': 'code', 'client_id': auth.client_id, 'scope': 'openid profile', + 'redirect_uri': f'{auth.public}/auth/callback', 'state': state, + 'code_challenge': challenge, 'code_challenge_method': 'S256'}) + st = auth.sign({'s': state, 'v': verifier, 'n': nxt, 'exp': time.time() + 600}) + return self.redirect(f"{auth.meta()['authorization_endpoint']}?{params}", + [cookie_header('rec_state', st, 600, self.https())]) + if path == '/auth/callback': + st = auth.verify(self.cookies().get('rec_state')) + if not st or not hmac.compare_digest(st['s'], (q.get('state') or [''])[0]) or not q.get('code'): + return self.send(400, page('로그인 실패', '

로그인 요청이 만료됐거나 올바르지 않습니다. ' + '다시 로그인

'), + 'text/html; charset=utf-8') + form = urllib.parse.urlencode({ + 'grant_type': 'authorization_code', 'code': q['code'][0], + 'redirect_uri': f'{auth.public}/auth/callback', 'client_id': auth.client_id, + 'client_secret': auth.secret(), 'code_verifier': st['v']}).encode() + req = urllib.request.Request(auth.meta()['token_endpoint'], data=form, + headers={'Accept': 'application/json'}) + with urllib.request.urlopen(req, timeout=15) as r: + token = json.load(r)['access_token'] + req = urllib.request.Request(auth.meta()['userinfo_endpoint'], + headers={'Authorization': f'Bearer {token}'}) + with urllib.request.urlopen(req, timeout=15) as r: + info = json.load(r) + name = (info.get('preferred_username') or '').lower() + if name not in auth.allowed: + print(f'[{rec.ts()}] 로그인 거부: {name or "?"}', flush=True) + return self.send(403, page('허용되지 않은 계정', f'

{html.escape(name)} 계정은 사용할 수 없습니다.

'), + 'text/html; charset=utf-8', + [('Set-Cookie', cookie_header('rec_state', '', 0, self.https()))]) + print(f'[{rec.ts()}] 로그인: {name}', flush=True) + session = auth.sign({'u': name, 'exp': time.time() + 30 * 86400}) + return self.redirect(st.get('n') or '/', [ + cookie_header('rec_session', session, 30 * 86400, self.https()), + cookie_header('rec_state', '', 0, self.https())]) + if path == '/auth/logout': + return self.send(200, page('로그아웃됨', '

다시 로그인

'), + 'text/html; charset=utf-8', + [('Set-Cookie', cookie_header('rec_session', '', 0, self.https()))]) + if path == '/auth/me': + return self.send(200, {'user': self.user(), 'auth': auth.enabled()}) + return self.send(404, {'error': '없는 주소'}) + + def route(self, method): + path = self.path.split('?', 1)[0].rstrip('/') or '/' + query = self.path.split('?', 1)[1] if '?' in self.path else '' + try: + if path.startswith('/auth/') and method == 'GET': + return self.auth_route(path, query) + if auth.enabled(): + if not self.user(): + if path.startswith('/api/'): + return self.send(401, {'error': '로그인 필요'}) + # 로그인 쿠키는 공개 주소에만 붙으므로 IP 등 다른 주소로 들어오면 공개 주소로 보낸다 + if self.headers.get('Host') != urllib.parse.urlparse(auth.public).netloc: + return self.redirect(auth.public + self.path) + return self.redirect('/auth/login?' + urllib.parse.urlencode({'next': self.path})) + elif not self.direct_lan(): + return self.send(403, page('접속 불가', '

로그인(OIDC) 설정 전에는 같은 네트워크에서 직접 접속할 때만 열 수 있습니다.

'), + 'text/html; charset=utf-8') + if method != 'GET' and self.headers.get('X-Rec') != '1': + return self.send(403, {'error': '허용되지 않은 요청'}) + if method == 'GET' and path == '/': + return self.send(200, (HERE / 'index.html').read_bytes(), 'text/html; charset=utf-8') + if method == 'GET' and path == '/api/jobs': + return self.send(200, {'daemon': rec.daemon_info(), 'now': rec.now(), + 'jobs': rec.list_jobs('all=1' in query)}) + if method == 'POST' and path == '/api/jobs': + b = self.body() + start_at = rec.parse_at(b['start_at']) if b.get('start_at') else None + job, msgs = rec.create_job(b.get('url'), b.get('hours') or 12, b.get('memo'), + start_at=start_at, quality=b.get('quality') or None) + return self.send(200, {'id': job['id'], 'messages': msgs}) + if m := re.fullmatch(r'/api/jobs/([\w-]+)', path): + jid = rec.find_job(m[1]) + if method == 'GET': + return self.send(200, rec.job_view(jid, 80)) + if method == 'DELETE': + rec.remove_job(jid) + return self.send(200, {'ok': True}) + if (m := re.fullmatch(r'/api/jobs/([\w-]+)/stop', path)) and method == 'POST': + return self.send(200, {'message': rec.stop_job(rec.find_job(m[1]))}) + if method == 'POST' and path == '/api/check': + return self.send(200, rec.check_url((self.body().get('url') or '').strip())) + if method == 'GET' and path == '/api/cookies': + return self.send(200, rec.cookie_status()) + if (m := re.fullmatch(r'/api/cookies/(chzzk|youtube)', path)) and method == 'POST': + rec.save_cookies(m[1], self.body().get('text')) + return self.send(200, rec.cookie_status()) + return self.send(404, {'error': '없는 주소'}) + except rec.RecError as e: + return self.send(400, {'error': str(e)}) + except Exception as e: + traceback.print_exc() + return self.send(500, {'error': f'서버 오류: {e}'}) + + def do_GET(self): + self.route('GET') + + def do_POST(self): + self.route('POST') + + def do_DELETE(self): + self.route('DELETE') + + ThreadingHTTPServer.daemon_threads = True + ThreadingHTTPServer(('0.0.0.0', port), Handler).serve_forever()