Files
2026-10-04 12:23:09 +09:00

984 lines
41 KiB
Python

#!/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()