live-rec: 치지직·유튜브 방송 예약 녹화

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ysj7705andClaude Opus 5.5 committed 2026-10-04 12:23:09 +09:00
commit f0da6f724a
8 files changed
+1942

No files matched your search

+23
View File
@@ -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: <REC_PUBLIC_URL>/auth/callback)
REC_OIDC_CLIENT_ID=
# 브라우저로 접속하는 공개 주소. 예: https://rec.example.com
REC_PUBLIC_URL=
# 로그인을 허용할 계정 (OIDC preferred_username, 쉼표로 여러 명)
REC_ALLOWED_USERS=
+6
View File
@@ -0,0 +1,6 @@
# 실행 중 데이터 — 쿠키·로그인 키 등 비밀값 포함
data/
recordings/
.env
.env.bak*
__pycache__/
+26
View File
@@ -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"]
+122
View File
@@ -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 <URL> -a "10-05 20:30" -q 1080 # 날짜 지정, 1080p 이하
$R ls # 목록
$R st <ID> # 상세 + 로그
$R stop <ID> # 대기 취소 / 녹화 중지 (분량 저장)
$R rm <ID> # 끝난 작업 기록 삭제 (녹화 파일은 그대로)
$R check <URL> # 등록 없이 방송 여부 확인
$R update # yt-dlp 수동 업데이트
```
ID는 앞부분만 써도 된다. 저장 파일: `<REC_DIR>/<채널명>/<시작시각> <제목> [<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/<ID>/job.json` | 요청 |
| `jobs/<ID>/state.json` | 상태 (데몬만 씀) |
| `jobs/<ID>/log.txt` | 데몬 이벤트와 yt-dlp 출력 |
| `cookies/` | 쿠키 |
| `auth/` | 세션 키, OIDC 시크릿 |
녹화 중 파일은 `<REC_DIR>/.rec-tmp/<ID>/`에 있다가 끝나면 이동한다.
참고:
- 유튜브용 JS 런타임 deno를 포함한다.
- 치지직 라이브의 fMP4 조각(`.m4s`) 때문에 ffmpeg에 `-extension_picky 0`을 넘긴다.
+36
View File
@@ -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
+458
View File
@@ -0,0 +1,458 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>녹화 대기열</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='%23e5484d'/%3E%3C/svg%3E">
<style>
:root {
--bg: #f6f7f9; --card: #fff; --fg: #17181a; --muted: #6b7076; --line: #e3e5e8;
--accent: #00a85a; --accent-fg: #fff; --rec: #e5484d; --warn: #c27c0e; --info: #3b6fd8;
--chip: #eef0f2; --input: #fff; --shadow: 0 1px 2px rgba(0,0,0,.05);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111214; --card: #1b1c1f; --fg: #e9eaec; --muted: #9a9fa6; --line: #2c2e33;
--accent: #1fc777; --accent-fg: #06150d; --rec: #ff6369; --warn: #f0b445; --info: #7aa2f7;
--chip: #26282c; --input: #141518; --shadow: none;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg);
font: 14px/1.5 system-ui, -apple-system, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; }
main { max-width: 760px; margin: 0 auto; padding: 16px; }
header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
h1 { font-size: 20px; margin: 0; }
h2 { font-size: 15px; margin: 0 0 10px; }
.clock { color: var(--muted); font-variant-numeric: tabular-nums; }
.pills { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; }
.pill { background: var(--chip); border-radius: 999px; padding: 2px 10px; font-size: 12px; color: var(--muted); }
.pill b { color: var(--fg); font-weight: 600; }
.bad { color: var(--rec) !important; } .ok { color: var(--accent); } .warn { color: var(--warn); }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: var(--shadow); }
label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; }
input, select, textarea { width: 100%; font: inherit; color: var(--fg); background: var(--input);
border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.row { display: flex; gap: 8px; }
.row > * { flex: 1; min-width: 0; }
.row > .fit { flex: 0 0 auto; }
button { font: inherit; border: 1px solid var(--line); background: var(--card); color: var(--fg);
border-radius: 8px; padding: 7px 12px; cursor: pointer; white-space: nowrap; }
button:hover { border-color: var(--muted); }
button.primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; font-weight: 600; }
button.danger { color: var(--rec); }
button:disabled { opacity: .5; cursor: default; }
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.seg button { border: 0; border-radius: 0; }
.seg button.on { background: var(--chip); font-weight: 600; }
.chips { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
.chips button { padding: 3px 10px; font-size: 12px; border-radius: 999px; }
.hint { font-size: 12px; color: var(--muted); margin-top: 6px; min-height: 1.5em; }
.job { padding: 12px 14px; }
.job-top { display: flex; align-items: center; gap: 8px; }
.status { font-size: 12px; font-weight: 600; padding: 1px 8px; border-radius: 999px; background: var(--chip); flex: 0 0 auto; }
.s-recording { color: #fff; background: var(--rec); }
.s-waiting { color: var(--info); }
.s-scheduled { color: var(--warn); }
.s-done { color: var(--accent); }
.s-failed { color: var(--rec); }
.plat { font-size: 12px; color: var(--muted); flex: 0 0 auto; }
.title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; }
.title a { color: inherit; text-decoration: none; }
.title a:hover { text-decoration: underline; }
.sub { color: var(--muted); font-size: 13px; margin-top: 4px; overflow-wrap: anywhere; }
.metrics { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 6px; font-variant-numeric: tabular-nums; }
.metrics span b { font-weight: 600; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--rec); margin-right: 4px; animation: blink 1.2s infinite; }
@keyframes blink { 50% { opacity: .25; } }
.actions { display: flex; gap: 6px; justify-content: flex-end; margin-top: 8px; flex-wrap: wrap; }
.actions button { padding: 4px 10px; font-size: 13px; }
.detail { margin-top: 10px; border-top: 1px solid var(--line); padding-top: 10px; }
.files div { font-size: 13px; overflow-wrap: anywhere; }
pre { background: var(--input); border: 1px solid var(--line); border-radius: 8px; padding: 8px; margin: 8px 0 0;
font-size: 11.5px; line-height: 1.45; max-height: 280px; overflow: auto; white-space: pre-wrap; word-break: break-all; }
.empty { color: var(--muted); text-align: center; padding: 24px 0; }
.section-head { display: flex; justify-content: space-between; align-items: center; margin: 18px 0 8px; }
.section-head h2 { margin: 0; }
.toggle { font-size: 12px; color: var(--muted); display: flex; gap: 6px; align-items: center; }
.toggle input { width: auto; }
details summary { cursor: pointer; font-weight: 600; }
.secret { -webkit-text-security: disc; font-family: monospace; }
.ck { display: flex; justify-content: space-between; gap: 8px; padding: 4px 0; font-size: 13px; flex-wrap: wrap; }
.toast { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); background: var(--fg); color: var(--bg);
padding: 8px 14px; border-radius: 8px; font-size: 13px; opacity: 0; transition: opacity .2s; pointer-events: none; max-width: calc(100% - 32px); }
.toast.show { opacity: 1; }
</style>
</head>
<body>
<main>
<header>
<h1>녹화 대기열</h1>
<span class="clock"><span id="clock"></span> <span id="me"></span></span>
</header>
<div class="pills" id="pills"></div>
<section class="card" aria-labelledby="add-h">
<h2 id="add-h">방송 대기 걸기</h2>
<div class="row">
<input id="url" placeholder="치지직 채널·유튜브 채널/영상 주소" autocomplete="off" inputmode="url">
<button class="fit" id="check">확인</button>
</div>
<div class="hint" id="check-out"></div>
<label for="memo">메모 (목록에 보일 이름, 선택)</label>
<input id="memo" autocomplete="off">
<label>시작</label>
<div class="seg" role="group">
<button id="mode-now" class="on" type="button">지금부터</button>
<button id="mode-at" type="button">예약</button>
</div>
<div id="at-box" hidden>
<label for="at">예약 시각 (<span class="tz-label">한국시간</span>)</label>
<input id="at" type="datetime-local">
<div class="chips" id="at-chips"></div>
</div>
<div class="row">
<div>
<label for="hours">방송 기다릴 시간</label>
<select id="hours">
<option value="1">1시간</option><option value="2">2시간</option><option value="3">3시간</option>
<option value="6">6시간</option><option value="12" selected>12시간</option><option value="24">24시간</option>
<option value="48">48시간</option>
</select>
</div>
<div>
<label for="quality">화질</label>
<select id="quality">
<option value="">최고</option><option value="1080">1080p 이하</option>
<option value="720">720p 이하</option><option value="480">480p 이하</option>
</select>
</div>
</div>
<div class="hint" id="window"></div>
<div class="actions"><button class="primary" id="add">대기 등록</button></div>
</section>
<div class="section-head">
<h2>작업</h2>
<label class="toggle"><input type="checkbox" id="show-all"> 끝난 작업 모두</label>
</div>
<div id="jobs"><div class="empty">불러오는 중…</div></div>
<section class="card" style="margin-top:18px">
<details id="ck-box">
<summary>쿠키 (성인 방송·연령 제한 영상용)</summary>
<div id="ck-status" style="margin-top:10px"></div>
<label for="ck-platform">플랫폼</label>
<select id="ck-platform"><option value="chzzk">치지직</option><option value="youtube">유튜브</option></select>
<label for="ck-text">쿠키 붙여넣기 — Netscape cookies.txt 내용, 또는 치지직은 NID_AUT=...; NID_SES=... 한 줄</label>
<textarea id="ck-text" class="secret" rows="3" autocomplete="off" spellcheck="false"
placeholder="NID_AUT=...; NID_SES=..."></textarea>
<div class="actions"><button class="primary" id="ck-save">저장</button></div>
<div class="hint">저장된 값은 화면에 다시 보이지 않습니다.</div>
</details>
</section>
</main>
<div class="toast" id="toast" role="status"></div>
<script>
// 시간대는 서버(컨테이너 TZ)를 따른다 — /api/jobs 응답으로 갱신
let TZ = 'Asia/Seoul';
let TZ_LABEL = '한국시간';
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const open = new Set();
let serverSkew = 0; // 서버 시계 - 브라우저 시계 (초)
async function api(path, opts = {}) {
const r = await fetch(path, {
...opts,
headers: { 'X-Rec': '1', ...(opts.body ? { 'Content-Type': 'application/json' } : {}) },
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (r.status === 401) { location.href = '/auth/login?next=/'; throw new Error('로그인 필요'); }
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
return data;
}
function toast(msg) {
const t = $('toast');
t.textContent = msg;
t.classList.add('show');
clearTimeout(toast.t);
toast.t = setTimeout(() => t.classList.remove('show'), 3500);
}
// ---- 시각 (모두 서버 시간대 기준) ----
const nowSec = () => Date.now() / 1000 + serverSkew;
const parts = (sec) => Object.fromEntries(new Intl.DateTimeFormat('en-CA', {
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
second: '2-digit', hourCycle: 'h23' }).formatToParts(new Date(sec * 1000)).map((p) => [p.type, p.value]));
const localInput = (sec) => { const p = parts(sec); return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}`; };
// 그 시간대의 벽시계 시각 → UTC와의 차(초)
const tzOffset = (sec) => { const p = parts(sec); return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) / 1000 - Math.floor(sec); };
// "YYYY-MM-DDTHH:MM"(서버 시간대) → epoch 초 (서머타임 경계는 한 번 더 보정)
const fromLocal = (v) => {
const guess = Date.parse(`${v}:00Z`) / 1000;
const first = guess - tzOffset(guess);
return guess - tzOffset(first);
};
function when(sec) {
if (!sec) return '-';
const p = parts(sec), t = parts(nowSec());
const day = p.year === t.year && p.month === t.month && p.day === t.day ? '오늘'
: (fromLocal(`${p.year}-${p.month}-${p.day}T00:00`) - fromLocal(`${t.year}-${t.month}-${t.day}T00:00`)) === 86400 ? '내일'
: `${+p.month}/${+p.day}`;
return `${day} ${p.hour}:${p.minute}`;
}
const dur = (s) => { s = Math.max(0, Math.floor(s)); return `${Math.floor(s / 3600)}:${String(Math.floor(s % 3600 / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; };
const until = (s) => { s = Math.max(0, Math.round(s / 60)); return s < 60 ? `${s}분 후` : `${Math.floor(s / 60)}시간 ${s % 60}분 후`; };
function human(n) {
n = Number(n || 0);
for (const u of ['B', 'KB', 'MB', 'GB', 'TB']) {
if (n < 1024 || u === 'TB') return u === 'B' || u === 'KB' ? `${n.toFixed(0)}${u}` : `${n.toFixed(1)}${u}`;
n /= 1024;
}
}
function tickClock() {
const p = parts(nowSec());
$('clock').textContent = `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second} ${TZ_LABEL}`;
}
// ---- 등록 폼 ----
let mode = 'now';
function setMode(m) {
mode = m;
$('mode-now').classList.toggle('on', m === 'now');
$('mode-at').classList.toggle('on', m === 'at');
$('at-box').hidden = m !== 'at';
if (m === 'at' && !$('at').value) $('at').value = localInput(Math.ceil(nowSec() / 600) * 600 + 1800);
renderWindow();
}
$('mode-now').onclick = () => setMode('now');
$('mode-at').onclick = () => setMode('at');
function renderChips() {
const box = $('at-chips');
box.innerHTML = '';
const add = (text, fn) => { const b = document.createElement('button'); b.type = 'button'; b.textContent = text; b.onclick = () => { $('at').value = localInput(fn()); renderWindow(); }; box.appendChild(b); };
add('+30분', () => Math.ceil(nowSec() / 60) * 60 + 1800);
add('+1시간', () => Math.ceil(nowSec() / 60) * 60 + 3600);
for (const h of [18, 19, 20, 21, 22]) {
add(`${h}:00`, () => {
const t = parts(nowSec());
let s = fromLocal(`${t.year}-${t.month}-${t.day}T${String(h).padStart(2, '0')}:00`);
if (s <= nowSec()) s += 86400; // 지났으면 내일
return s;
});
}
}
function renderWindow() {
const start = mode === 'at' && $('at').value ? fromLocal($('at').value) : nowSec();
const end = start + Number($('hours').value) * 3600;
let txt = `${when(start)}부터 ${when(end)}까지 방송을 기다립니다 (${TZ_LABEL})`;
if (mode === 'at' && start < nowSec()) txt = '⚠ 예약 시각이 이미 지났습니다 — 등록하면 바로 시작합니다';
$('window').textContent = txt;
}
$('at').oninput = renderWindow;
$('hours').onchange = renderWindow;
$('check').onclick = async () => {
const url = $('url').value.trim();
if (!url) return;
const out = $('check-out');
out.textContent = '확인 중…';
out.className = 'hint';
$('check').disabled = true;
try {
const r = await api('/api/check', { method: 'POST', body: { url } });
let t = `${r.platform_ko} · ${r.mode_ko}`;
if (r.name) t += ` · ${r.name}`;
if (r.error) t += ` · ${r.error}`;
else t += r.live ? ` · 방송 중: ${r.title || ''}` : ' · 지금은 방송 꺼짐';
if (r.adult) t += ' (성인)';
out.textContent = t;
out.className = 'hint ' + (r.live ? 'ok' : '');
if (r.name && !$('memo').value) $('memo').placeholder = r.name;
} catch (e) {
out.textContent = e.message;
out.className = 'hint bad';
} finally {
$('check').disabled = false;
}
};
$('add').onclick = async () => {
const url = $('url').value.trim();
if (!url) { $('url').focus(); return; }
$('add').disabled = true;
try {
const r = await api('/api/jobs', { method: 'POST', body: {
url, memo: $('memo').value, hours: Number($('hours').value),
quality: $('quality').value ? Number($('quality').value) : null,
start_at: mode === 'at' && $('at').value ? $('at').value : null,
} });
toast(`등록됨 (${r.id})${r.messages.length ? ' — ' + r.messages.join(' / ') : ''}`);
$('url').value = ''; $('memo').value = ''; $('memo').placeholder = ''; $('check-out').textContent = '';
setMode('now');
refresh();
} catch (e) {
toast(e.message);
} finally {
$('add').disabled = false;
}
};
// ---- 목록 ----
function jobCard(j) {
const job = j.job, s = j.status;
const el = document.createElement('article');
el.className = 'card job';
const live = j.live_title && j.live_title !== j.label ? `<div class="sub">📺 ${esc(j.live_title)}${j.adult ? ' (성인)' : ''}</div>` : '';
let metrics = '';
if (s === 'recording') {
metrics = `<div class="metrics">
<span><span class="dot"></span><b>${dur(nowSec() - (j.recording_since || nowSec()))}</b></span>
<span>용량 <b>${human((j.total_bytes || 0) + (j.bytes || 0))}</b></span>
<span>속도 <b>${human(j.rate)}/s</b></span>
${j.reconnects ? `<span class="warn">재연결 ${j.reconnects}회</span>` : ''}</div>`;
}
let info = '';
if (s === 'scheduled') info = `${when(j.start_at)} 시작 (${until(j.start_at - nowSec())}) · ${when(j.deadline)}까지 대기`;
else if (s === 'waiting') info = `${esc(j.note || '')}${j.checked_at ? ` · ${Math.round(nowSec() - j.checked_at)}초 전 확인` : ''} · ${when(j.deadline)}까지 대기`;
else if (s === 'recording') info = j.note && j.note.startsWith('⚠') ? `<span class="warn">${esc(j.note)}</span>` : `${when(j.recording_since)} 녹화 시작`;
else {
info = `${when(j.finished_at)} 종료 · 파일 ${j.files.length}개 · ${human(j.total_bytes)}`;
if (j.error) info += ` · <span class="bad">${esc(j.error)}</span>`;
}
el.innerHTML = `
<div class="job-top">
<span class="status s-${esc(s)}">${esc(j.status_ko)}</span>
<span class="plat">${esc(j.platform_ko)}</span>
<span class="title"><a href="${esc(job.url)}" target="_blank" rel="noreferrer">${esc(j.label)}</a></span>
</div>
${live}${metrics}
<div class="sub">${info}</div>
<div class="actions">
<button data-act="detail">${open.has(j.id) ? '접기' : '상세'}</button>
${j.final ? '<button data-act="rm" class="danger">기록 삭제</button>'
: `<button data-act="stop" class="danger">${s === 'recording' ? '녹화 중지' : '대기 취소'}</button>`}
</div>
<div class="detail" ${open.has(j.id) ? '' : 'hidden'}></div>`;
el.querySelector('[data-act=detail]').onclick = () => { open.has(j.id) ? open.delete(j.id) : open.add(j.id); refresh(); };
const stop = el.querySelector('[data-act=stop]');
if (stop) stop.onclick = async () => {
if (!confirm(s === 'recording' ? '녹화를 멈출까요? 지금까지 분량은 저장됩니다.' : '대기를 취소할까요?')) return;
try { toast((await api(`/api/jobs/${j.id}/stop`, { method: 'POST' })).message); refresh(); } catch (e) { toast(e.message); }
};
const rm = el.querySelector('[data-act=rm]');
if (rm) rm.onclick = async () => {
if (!confirm('작업 기록을 지울까요? 녹화 파일은 그대로 남습니다.')) return;
try { await api(`/api/jobs/${j.id}`, { method: 'DELETE' }); open.delete(j.id); refresh(); } catch (e) { toast(e.message); }
};
if (open.has(j.id)) loadDetail(j.id, el.querySelector('.detail'));
return el;
}
async function loadDetail(id, box) {
try {
const d = await api(`/api/jobs/${id}`);
const job = d.job;
const rows = [
['ID', d.id], ['주소', job.url],
['등록', when(job.created_at)], job.start_at ? ['예약', when(job.start_at)] : null,
['대기 한도', when(d.deadline)], job.quality ? ['화질', `${job.quality}p 이하`] : null,
].filter(Boolean);
box.innerHTML = rows.map(([k, v]) => `<div class="sub"><b>${esc(k)}</b> ${esc(v)}</div>`).join('')
+ (d.files.length ? `<div class="files" style="margin-top:6px">${d.files.map((f) => `<div>📁 ${esc(f.path)} <span class="sub">${f.size == null ? '(없음)' : human(f.size)}</span></div>`).join('')}</div>` : '')
+ `<pre>${esc((d.log || []).join('\n'))}</pre>`;
const pre = box.querySelector('pre');
pre.scrollTop = pre.scrollHeight;
} catch (e) {
box.textContent = e.message;
}
}
function renderPills(dm) {
const p = [];
p.push(dm.alive ? '<span class="pill">데몬 <b class="ok">정상</b></span>' : '<span class="pill bad">⚠ 데몬 응답 없음</span>');
if (dm.recording) p.push(`<span class="pill">녹화 중 <b>${dm.recording}</b></span>`);
if (dm.free_gb != null) p.push(`<span class="pill">여유 공간 <b class="${dm.free_gb < 50 ? 'warn' : ''}">${dm.free_gb}GB</b></span>`);
if (dm.ytdlp) p.push(`<span class="pill">yt-dlp <b>${esc(dm.ytdlp)}</b></span>`);
$('pills').innerHTML = p.join('');
}
let busy = false;
async function refresh() {
if (busy) return;
busy = true;
try {
const d = await api(`/api/jobs${$('show-all').checked ? '?all=1' : ''}`);
serverSkew = d.now - Date.now() / 1000;
if (d.daemon.tz && d.daemon.tz !== TZ) {
TZ = d.daemon.tz;
TZ_LABEL = d.daemon.tz_label || TZ;
document.querySelectorAll(".tz-label").forEach((e) => { e.textContent = TZ_LABEL; });
renderWindow();
}
renderPills(d.daemon);
const box = $('jobs');
box.innerHTML = '';
if (!d.jobs.length) box.innerHTML = '<div class="empty">작업 없음</div>';
for (const j of d.jobs) box.appendChild(jobCard(j));
} catch (e) {
$('pills').innerHTML = `<span class="pill bad">⚠ 서버 연결 실패: ${esc(e.message)}</span>`;
} finally {
busy = false;
}
}
$('show-all').onchange = refresh;
// ---- 쿠키 ----
function renderCookies(list) {
$('ck-status').innerHTML = list.map((c) => {
if (!c.present) return `<div class="ck"><span>${esc(c.platform_ko)}</span><span class="sub">없음</span></div>`;
const keys = Object.entries(c.keys).map(([k, ok]) => `${esc(k)}${ok ? '✓' : '✗'}`).join(' ');
const login = 'logged_in' in c ? (c.logged_in ? '<b class="ok">로그인 확인됨</b>' : '<b class="bad">로그인 안 됨(만료?)</b>') : '';
return `<div class="ck"><span>${esc(c.platform_ko)} ${login}</span><span class="sub">${keys} · 수정 ${when(c.modified)}${c.expires ? ` · 만료 ${when(c.expires)}` : ''}</span></div>`;
}).join('');
}
$('ck-box').ontoggle = async () => {
if (!$('ck-box').open) return;
$('ck-status').innerHTML = '<div class="sub">확인 중…</div>';
try { renderCookies(await api('/api/cookies')); } catch (e) { $('ck-status').textContent = e.message; }
};
$('ck-save').onclick = async () => {
const text = $('ck-text').value.trim();
if (!text) return;
$('ck-save').disabled = true;
try {
renderCookies(await api(`/api/cookies/${$('ck-platform').value}`, { method: 'POST', body: { text } }));
$('ck-text').value = '';
toast('쿠키 저장됨');
} catch (e) {
toast(e.message);
} finally {
$('ck-save').disabled = false;
}
};
api('/auth/me').then((m) => { if (m.user) $('me').innerHTML = `· ${esc(m.user)} <a href="/auth/logout" style="color:inherit">로그아웃</a>`; }).catch(() => {});
renderChips();
renderWindow();
tickClock();
refresh();
setInterval(tickClock, 1000);
setInterval(() => { if (!document.hidden) refresh(); }, 5000);
setInterval(renderWindow, 30000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); });
</script>
</body>
</html>
+983
View File
@@ -0,0 +1,983 @@
#!/usr/bin/env python3
"""live-rec — 치지직·유튜브 녹화 대기열 (데몬 + CLI 한 파일)
rec add <URL> [-a "21:00"] [-H 시간] [-m 메모] [-q 1080] [-f 포맷] [-x "yt-dlp 추가 인자"]
채널 주소면 방송이 켜질 때까지 기다렸다 녹화, 영상 주소면 바로 받는다(유튜브 예약 방송은 시작까지 대기)
-a: 예약 시작 시각(컨테이너 TZ, 기본 한국시간). 그 시각부터 -H 시간 동안 방송을 기다린다
rec ls [-a] 목록 (기본: 진행 중 + 최근 끝난 10개)
rec st <ID> 상세 상태 + 로그 끝부분
rec stop <ID> 대기 취소 / 녹화 중지 (지금까지 분량은 저장)
rec rm <ID> 끝난 작업 기록 삭제 (녹화 파일은 그대로)
rec check <URL> 등록하지 않고 감지 결과·방송 여부만 확인
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/<id>/job.json 요청 (add가 만들고 이후 바꾸지 않음)
jobs/<id>/state.json 상태 (데몬만 씀)
jobs/<id>/stop 중지 요청 표시 (있으면 중지)
jobs/<id>/files.txt 완성된 파일 경로
jobs/<id>/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()
+288
View File
@@ -0,0 +1,288 @@
"""live-rec 웹 화면 — 데몬 프로세스 안에서 표준 라이브러리 HTTP 서버로 동작한다.
작업 관리는 rec 모듈(데몬 본체)의 함수를 그대로 쓴다. 상태는 /data 아래 파일이 원본.
GET / 화면 (index.html)
GET /api/jobs[?all=1] 목록 + 데몬 상태
GET /api/jobs/<id> 상세 + 로그
POST /api/jobs 등록 {url, memo, hours, start_at:"YYYY-MM-DDTHH:MM"(컨테이너 TZ), quality}
POST /api/jobs/<id>/stop 중지
DELETE /api/jobs/<id> 기록 삭제 (녹화 파일은 그대로)
POST /api/check {url} 방송 여부 확인
GET /api/cookies 쿠키 상태 (값은 절대 내보내지 않음)
POST /api/cookies/<platform> {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'<!doctype html><html lang="ko"><head><meta charset="utf-8">'
f'<meta name="viewport" content="width=device-width, initial-scale=1"><title>{html.escape(title)}</title>'
'<style>body{font:15px/1.6 system-ui,sans-serif;max-width:420px;margin:15vh auto;padding:0 16px;'
'color:#17181a;background:#f6f7f9}@media(prefers-color-scheme:dark){body{color:#e9eaec;background:#111214}'
'a{color:#1fc777}}</style></head>'
f'<body><h2>{html.escape(title)}</h2>{body}</body></html>').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('로그인 설정 전', '<p>로그인(OIDC)이 아직 설정되지 않았습니다.</p>'),
'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('로그인 실패', '<p>로그인 요청이 만료됐거나 올바르지 않습니다. '
'<a href="/auth/login">다시 로그인</a></p>'),
'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'<p>{html.escape(name)} 계정은 사용할 수 없습니다.</p>'),
'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('로그아웃됨', '<p><a href="/auth/login">다시 로그인</a></p>'),
'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('접속 불가', '<p>로그인(OIDC) 설정 전에는 같은 네트워크에서 직접 접속할 때만 열 수 있습니다.</p>'),
'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()