live-rec: 치지직·유튜브 방송 예약 녹화
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f0da6f724a
8 files changed
+1942
No files matched your search
@@ -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=
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 실행 중 데이터 — 쿠키·로그인 키 등 비밀값 포함
|
||||||
|
data/
|
||||||
|
recordings/
|
||||||
|
.env
|
||||||
|
.env.bak*
|
||||||
|
__pycache__/
|
||||||
+26
@@ -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"]
|
||||||
@@ -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`을 넘긴다.
|
||||||
@@ -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
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user