Files
gitlab-instance-0a899031_d2…/web2_relay_log.py
2026-07-10 05:15:29 -05:00

1070 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""YouTube relay session history (simplified from sh2 youtube_relay_log.py)."""
from __future__ import annotations
import json
import re
import sqlite3
import subprocess
import threading
import time
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any
_TABLE = "youtube_relay_sessions"
_RECORDING_LINE_RE = re.compile(
r"正在录制\s*\d+\s*(?:个|路)\s*[:]\s*([^[\n]+?)\[原画\s*([0-9:]+)\]",
re.IGNORECASE,
)
_PUSH_START_RE = re.compile(r"开始推流到 YouTube:\s*(.+?)\s*$")
_INCOMING_URL_RE = re.compile(r"传入地址:\s*(https?://\S+)", re.IGNORECASE)
_ROOM_RE = re.compile(r"live\.douyin\.com/([a-zA-Z0-9_-]+)", re.IGNORECASE)
from web2_youtube_live_store import (
extract_first_url,
extract_source_room_id,
extract_youtube_key_suffix,
)
_STREAM_TITLE_TS_RE = re.compile(r"_(?P<ts>\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})$")
_LOG_TS_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}):")
_FAIL_ADDR_RE = re.compile(
r"获取失败的地址是:\('原画',\s*'(https?://[^']+)',\s*'主播:\s*([^']+)'\)"
)
_ANCHOR_LABEL_RE = re.compile(r"主播[:]\s*([^\s'\"()]+)")
_JUNK_END_REASONS = frozenset({"not_found", "idle", "pm2_online", "stopped", "errored", "error"})
_ACTIVE_BY_PROCESS: dict[str, int] = {}
from web2_douyin_source_display import (
_entry_for_title,
_url_entries_from_lines,
extract_douyin_source_meta,
)
def _normalize_process(process: str) -> str:
return (process or "").strip() or "youtube"
def _push_date_from_ts(ts: float) -> str:
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
def _parse_stream_title(stream_title: str) -> tuple[str, str]:
title = (stream_title or "").strip()
match = _STREAM_TITLE_TS_RE.search(title)
if not match:
return title, ""
anchor = title[: match.start()].rstrip("_")
stamp = match.group("ts")
push_date = stamp.split("_", 1)[0] if "_" in stamp else ""
return anchor, push_date
def _timestamp_from_stream_title(stream_title: str) -> float | None:
match = _STREAM_TITLE_TS_RE.search(stream_title or "")
if not match:
return None
try:
return datetime.strptime(match.group("ts"), "%Y-%m-%d_%H-%M-%S").timestamp()
except ValueError:
return None
def _parse_log_line_ts(line: str) -> float | None:
match = _LOG_TS_RE.match(line.strip())
if not match:
return None
try:
return datetime.strptime(match.group(1), "%Y-%m-%dT%H:%M:%S").timestamp()
except ValueError:
return None
def _parse_local_wall_time(line: str, log_ts: float | None) -> float | None:
if log_ts is None:
return None
local_hms = ""
for part in reversed(line.split("|")):
token = part.strip()
if re.fullmatch(r"\d{2}:\d{2}:\d{2}", token):
local_hms = token
break
if not local_hms:
return log_ts
try:
base = datetime.fromtimestamp(log_ts)
hour, minute, second = (int(x) for x in local_hms.split(":"))
return base.replace(hour=hour, minute=minute, second=second, microsecond=0).timestamp()
except (ValueError, OSError):
return log_ts
def _clean_douyin_url(url: str) -> str:
return extract_first_url(url) or ""
def _build_anchor_url_index(url_lines: str) -> dict[str, tuple[str, str]]:
index: dict[str, tuple[str, str]] = {}
for entry in _url_entries_from_lines(url_lines):
title = (entry.get("source_title") or "").strip()
url = _clean_douyin_url(entry.get("source_url") or "")
room_id = entry.get("room_id") or ""
if title and url:
index[title] = (url, room_id)
return index
def _lookup_room_id(anchor_name: str, url_lines: str) -> tuple[str, str]:
anchor = (anchor_name or "").strip()
for line in (url_lines or "").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if anchor and anchor not in stripped:
continue
url = extract_first_url(stripped)
room_id = extract_source_room_id(stripped, url)
if url:
return url, room_id
meta = extract_douyin_source_meta(url_lines, anchor)
return str(meta.get("source_url") or ""), str(meta.get("source_room_id") or "")
def _resolve_anchor_url(
anchor: str,
url_index: dict[str, tuple[str, str]],
url_lines: str,
log_url_index: dict[str, tuple[str, str]] | None = None,
) -> tuple[str, str]:
name = (anchor or "").strip()
if not name:
return "", ""
if log_url_index and name in log_url_index:
return log_url_index[name]
entry = _entry_for_title(_url_entries_from_lines(url_lines), name)
if entry:
return _clean_douyin_url(entry["source_url"]), entry["room_id"]
if name in url_index:
url, room_id = url_index[name]
return _clean_douyin_url(url), room_id
meta = extract_douyin_source_meta(url_lines, name)
url = _clean_douyin_url(str(meta.get("source_url") or ""))
room_id = str(meta.get("source_room_id") or "")
if url:
return url, room_id
return _lookup_room_id(name, url_lines)
def _session_has_signal(row: sqlite3.Row | dict[str, Any]) -> bool:
data = dict(row) if not isinstance(row, dict) else row
anchor = str(data.get("anchor_name") or "").strip()
source_url = str(data.get("source_url") or "").strip()
stream_title = str(data.get("stream_title") or "").strip()
room_id = str(data.get("source_room_id") or "").strip()
return bool(anchor or source_url or stream_title or room_id)
def _is_junk_session_row(row: sqlite3.Row | dict[str, Any]) -> bool:
if _session_has_signal(row):
return False
data = dict(row) if not isinstance(row, dict) else row
end_reason = str(data.get("end_reason") or "").strip().lower()
if end_reason in _JUNK_END_REASONS:
return True
meta_raw = data.get("metadata_json") or data.get("metadata") or {}
if isinstance(meta_raw, str):
try:
meta = json.loads(meta_raw) if meta_raw else {}
except json.JSONDecodeError:
meta = {}
else:
meta = meta_raw if isinstance(meta_raw, dict) else {}
if str(meta.get("trigger") or "") == "pm2_online":
return True
return not end_reason and str(data.get("status") or "") == "active"
def purge_junk_relay_sessions(*, process: str = "", db_path: Path) -> int:
proc = _normalize_process(process) if process else ""
conn = _connect(db_path)
removed = 0
try:
rows = conn.execute(f"SELECT * FROM {_TABLE}").fetchall()
for row in rows:
if proc and str(row["process"] or "") != proc:
continue
if _is_junk_session_row(row):
conn.execute(f"DELETE FROM {_TABLE} WHERE id = ?", (int(row["id"]),))
removed += 1
conn.commit()
finally:
conn.close()
return removed
def _close_session_row(
conn: sqlite3.Connection,
row_id: int,
*,
ended_at: float | None = None,
reason: str = "",
duration_seconds: int | None = None,
) -> None:
row = conn.execute(f"SELECT * FROM {_TABLE} WHERE id = ?", (row_id,)).fetchone()
if not row or row["ended_at"]:
return
end = float(ended_at or time.time())
duration = duration_seconds
if duration is None:
duration = int(max(0, end - float(row["started_at"])))
conn.execute(
f"""
UPDATE {_TABLE}
SET ended_at = ?, duration_seconds = ?, status = 'ended', end_reason = ?, updated_at = ?
WHERE id = ?
""",
(end, int(duration), (reason or "")[:500], end, row_id),
)
process = str(row["process"] or "")
if _ACTIVE_BY_PROCESS.get(process) == row_id:
_ACTIVE_BY_PROCESS.pop(process, None)
def relay_db_path(base_dir: Path) -> Path:
path = base_dir / "runtime" / "youtube_relay" / "sessions.sqlite"
path.parent.mkdir(parents=True, exist_ok=True)
return path
def _covers_dir(base_dir: Path) -> Path:
path = base_dir / "runtime" / "youtube_relay" / "covers"
path.mkdir(parents=True, exist_ok=True)
return path
def _connect(db_path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(str(db_path), check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute(
f"""
CREATE TABLE IF NOT EXISTS {_TABLE} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_uuid TEXT NOT NULL UNIQUE,
process TEXT NOT NULL DEFAULT '',
channel_name TEXT NOT NULL DEFAULT '',
anchor_name TEXT NOT NULL DEFAULT '',
source_url TEXT NOT NULL DEFAULT '',
source_room_id TEXT NOT NULL DEFAULT '',
stream_title TEXT NOT NULL DEFAULT '',
youtube_key_suffix TEXT NOT NULL DEFAULT '',
started_at REAL NOT NULL,
ended_at REAL,
duration_seconds REAL NOT NULL DEFAULT 0,
push_date TEXT NOT NULL DEFAULT '',
cover_path TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
end_reason TEXT NOT NULL DEFAULT '',
metadata_json TEXT NOT NULL DEFAULT '{{}}',
created_at REAL NOT NULL,
updated_at REAL NOT NULL
)
"""
)
conn.execute(
f"CREATE INDEX IF NOT EXISTS idx_relay_sess_proc ON {_TABLE}(process, started_at DESC)"
)
try:
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({_TABLE})").fetchall()}
if "cover_path" not in cols:
conn.execute(f"ALTER TABLE {_TABLE} ADD COLUMN cover_path TEXT NOT NULL DEFAULT ''")
except sqlite3.OperationalError:
pass
conn.commit()
return conn
def _row_to_session(row: sqlite3.Row) -> dict[str, Any]:
data = dict(row)
meta_raw = data.pop("metadata_json", "{}")
try:
data["metadata"] = json.loads(meta_raw) if meta_raw else {}
except json.JSONDecodeError:
data["metadata"] = {}
if data.get("ended_at"):
data["duration_seconds"] = int(float(data.get("duration_seconds") or 0))
elif float(data.get("duration_seconds") or 0) > 0:
data["duration_seconds"] = int(float(data["duration_seconds"]))
else:
data["duration_seconds"] = int(max(0, time.time() - float(data.get("started_at") or time.time())))
data["cover_url"] = (
f"/youtube/relay/sessions/{data['id']}/cover" if data.get("cover_path") else ""
)
return data
def _parse_duration_token(token: str) -> int:
parts = (token or "").strip().split(":")
try:
if len(parts) == 3:
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(float(parts[2]))
if len(parts) == 2:
return int(parts[0]) * 60 + int(float(parts[1]))
except (TypeError, ValueError):
pass
return 0
def _room_from_text(text: str) -> str:
m = _ROOM_RE.search(text or "")
return m.group(1) if m else ""
def _lookup_url(anchor: str, url_lines: str) -> tuple[str, str]:
anchor = (anchor or "").strip()
for line in (url_lines or "").splitlines():
t = line.strip()
if not t or t.startswith("#"):
continue
if anchor and anchor in t:
return t.split("#")[0].strip(), _room_from_text(t)
for line in (url_lines or "").splitlines():
t = line.strip()
if t and not t.startswith("#") and "douyin" in t.lower():
return t.split("#")[0].strip(), _room_from_text(t)
return "", ""
def start_relay_session(
*,
process: str,
anchor_name: str = "",
source_url: str = "",
source_room_id: str = "",
channel_name: str = "",
stream_title: str = "",
youtube_key_suffix: str = "",
metadata: dict[str, Any] | None = None,
capture_cover_url: str = "",
db_path: Path,
base_dir: Path | None = None,
) -> dict[str, Any]:
proc = _normalize_process(process)
now = time.time()
anchor = (anchor_name or "").strip()
if not source_url:
source_url = extract_first_url(stream_title)
if not source_room_id:
source_room_id = extract_source_room_id(source_url, anchor_name, stream_title)
conn = _connect(db_path)
try:
open_rows = conn.execute(
f"SELECT id, anchor_name FROM {_TABLE} WHERE process = ? AND ended_at IS NULL",
(proc,),
).fetchall()
for row in open_rows:
if str(row["anchor_name"] or "").strip() == anchor and anchor:
session = _row_to_session(
conn.execute(f"SELECT * FROM {_TABLE} WHERE id = ?", (int(row["id"]),)).fetchone()
)
conn.commit()
return session
_close_session_row(conn, int(row["id"]), ended_at=now, reason="source_switch")
session_uuid = uuid.uuid4().hex
push_date = _push_date_from_ts(now)
_, title_date = _parse_stream_title(stream_title)
if title_date:
push_date = title_date
title_started = _timestamp_from_stream_title(stream_title)
if title_started:
now = title_started
conn.execute(
f"""
INSERT INTO {_TABLE} (
session_uuid, process, channel_name, anchor_name, source_url, source_room_id,
stream_title, youtube_key_suffix, started_at, duration_seconds, push_date,
cover_path, status, metadata_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, '', 'active', ?, ?, ?)
""",
(
session_uuid,
proc,
channel_name or "",
anchor,
source_url or "",
source_room_id or "",
stream_title or "",
youtube_key_suffix or "",
now,
push_date,
json.dumps(metadata or {}, ensure_ascii=False),
now,
now,
),
)
session_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
_ACTIVE_BY_PROCESS[proc] = session_id
conn.commit()
session = _row_to_session(
conn.execute(f"SELECT * FROM {_TABLE} WHERE id = ?", (session_id,)).fetchone()
)
finally:
conn.close()
cover_url = capture_cover_url or source_url
if cover_url and session.get("id") and base_dir:
threading.Thread(
target=_capture_cover_safe,
args=(int(session["id"]), cover_url, db_path, base_dir),
daemon=True,
).start()
return session
def end_relay_session(
process: str,
*,
anchor_name: str = "",
reason: str = "stopped",
duration_seconds: int | None = None,
db_path: Path,
) -> dict[str, Any] | None:
proc = _normalize_process(process)
conn = _connect(db_path)
try:
if anchor_name.strip():
row = conn.execute(
f"""
SELECT id FROM {_TABLE}
WHERE process = ? AND ended_at IS NULL AND anchor_name = ?
ORDER BY started_at DESC LIMIT 1
""",
(proc, anchor_name.strip()),
).fetchone()
else:
row = conn.execute(
f"""
SELECT id FROM {_TABLE}
WHERE process = ? AND ended_at IS NULL
ORDER BY started_at DESC LIMIT 1
""",
(proc,),
).fetchone()
if not row:
conn.commit()
return None
_close_session_row(
conn,
int(row["id"]),
reason=reason or "ended",
duration_seconds=duration_seconds,
)
conn.commit()
closed = conn.execute(f"SELECT * FROM {_TABLE} WHERE id = ?", (int(row["id"]),)).fetchone()
return _row_to_session(closed) if closed else None
finally:
conn.close()
def close_active_relay_sessions(
process: str,
*,
reason: str = "",
ended_at: float | None = None,
db_path: Path,
) -> list[dict[str, Any]]:
proc = _normalize_process(process)
conn = _connect(db_path)
try:
rows = conn.execute(
f"SELECT id FROM {_TABLE} WHERE process = ? AND ended_at IS NULL ORDER BY started_at DESC",
(proc,),
).fetchall()
for row in rows:
_close_session_row(conn, int(row["id"]), ended_at=ended_at, reason=reason)
conn.commit()
if not rows:
return []
ids = [int(row["id"]) for row in rows]
return [
_row_to_session(item)
for item in conn.execute(
f"SELECT * FROM {_TABLE} WHERE id IN ({','.join('?' * len(ids))})",
ids,
).fetchall()
]
finally:
conn.close()
def on_relay_push_start(
*,
process: str,
anchor_name: str,
record_url: str | None,
stream_title: str,
real_url: str,
channel_name: str = "",
youtube_ini_text: str = "",
db_path: Path,
base_dir: Path | None = None,
) -> dict[str, Any]:
source_url = (record_url or "").strip() or extract_first_url(record_url or "")
if not source_url and real_url:
source_url = real_url
room_id = extract_source_room_id(record_url or "", real_url or "", anchor_name)
return start_relay_session(
process=process,
anchor_name=anchor_name,
source_url=source_url,
source_room_id=room_id,
stream_title=stream_title,
channel_name=channel_name,
youtube_key_suffix=extract_youtube_key_suffix(youtube_ini_text),
metadata={"real_url": real_url, "record_url": record_url or ""},
capture_cover_url=real_url or source_url,
db_path=db_path,
base_dir=base_dir,
)
def on_relay_push_end(
process: str,
*,
anchor_name: str,
reason: str = "push_stopped",
db_path: Path,
) -> dict[str, Any] | None:
return end_relay_session(process, anchor_name=anchor_name, reason=reason, db_path=db_path)
def get_relay_session(session_id: int | str, *, db_path: Path) -> dict[str, Any] | None:
conn = _connect(db_path)
try:
row = conn.execute(
f"SELECT * FROM {_TABLE} WHERE id = ? OR session_uuid = ?",
(session_id, str(session_id)),
).fetchone()
return _row_to_session(row) if row else None
finally:
conn.close()
def sync_relay_from_recent_log(
process: str,
recent_log: str,
*,
channel_name: str = "",
url_lines: str = "",
db_path: Path,
base_dir: Path | None = None,
) -> dict[str, Any] | None:
proc = (process or "youtube").strip()
anchor = ""
duration_token = ""
for line in reversed((recent_log or "").splitlines()):
match = _RECORDING_LINE_RE.search(line)
if match:
anchor = match.group(1).strip(" :?")
duration_token = match.group(2).strip()
break
if not anchor:
if "无录制任务" in (recent_log or "")[-2000:]:
closed = close_active_relay_sessions(proc, reason="idle", db_path=db_path)
return {"closed": closed} if closed else None
return None
source_url, room_id = _resolve_anchor_url(anchor, _build_anchor_url_index(url_lines), url_lines)
if not source_url:
meta = extract_douyin_source_meta(url_lines, recent_log)
source_url = str(meta.get("source_url") or "")
room_id = str(meta.get("source_room_id") or room_id)
row = None
conn = _connect(db_path)
try:
row = conn.execute(
f"""
SELECT id, anchor_name FROM {_TABLE}
WHERE process = ? AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1
""",
(proc,),
).fetchone()
if row and str(row["anchor_name"] or "").strip() == anchor:
if duration_token:
duration = _parse_duration_token(duration_token)
conn.execute(
f"UPDATE {_TABLE} SET duration_seconds = ?, updated_at = ? WHERE id = ?",
(duration, time.time(), int(row["id"])),
)
conn.commit()
return {"updated": int(row["id"])}
finally:
conn.close()
if row:
end_relay_session(proc, anchor_name=str(row["anchor_name"] or ""), reason="source_switch", db_path=db_path)
session = start_relay_session(
process=proc,
anchor_name=anchor,
source_url=source_url,
source_room_id=room_id,
channel_name=channel_name,
metadata={"sync": "recent_log"},
capture_cover_url=source_url,
db_path=db_path,
base_dir=base_dir or db_path.parent.parent.parent,
)
if duration_token and session.get("id"):
duration = _parse_duration_token(duration_token)
conn = _connect(db_path)
try:
conn.execute(
f"UPDATE {_TABLE} SET duration_seconds = ?, updated_at = ? WHERE id = ?",
(duration, time.time(), int(session["id"])),
)
conn.commit()
finally:
conn.close()
return session
def list_relay_sessions(
*,
process: str = "",
q: str = "",
date_from: str = "",
date_to: str = "",
status: str = "",
limit: int = 50,
offset: int = 0,
db_path: Path,
) -> dict[str, Any]:
clauses: list[str] = []
params: list[Any] = []
if process:
clauses.append("process = ?")
params.append(process.strip())
if status == "active":
clauses.append("ended_at IS NULL")
elif status == "ended":
clauses.append("ended_at IS NOT NULL")
if date_from:
clauses.append("push_date >= ?")
params.append(date_from.strip())
if date_to:
clauses.append("push_date <= ?")
params.append(date_to.strip())
if q.strip():
like = f"%{q.strip()}%"
clauses.append(
"(anchor_name LIKE ? OR source_url LIKE ? OR source_room_id LIKE ? OR channel_name LIKE ?)"
)
params.extend([like, like, like, like])
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
safe_limit = max(1, min(int(limit), 200))
safe_offset = max(0, int(offset))
purge_junk_relay_sessions(process=process, db_path=db_path)
conn = _connect(db_path)
try:
rows = conn.execute(
f"SELECT * FROM {_TABLE} {where} ORDER BY started_at DESC LIMIT ? OFFSET ?",
(*params, safe_limit, safe_offset),
).fetchall()
sessions = [_row_to_session(r) for r in rows if _session_has_signal(r)]
total = conn.execute(
f"SELECT COUNT(*) FROM {_TABLE} {where}",
params,
).fetchone()[0]
return {
"sessions": sessions,
"total": int(total),
"limit": safe_limit,
"offset": safe_offset,
}
finally:
conn.close()
def clear_imported_relay_sessions(process: str, *, db_path: Path) -> int:
proc = _normalize_process(process)
conn = _connect(db_path)
try:
cur = conn.execute(
f"DELETE FROM {_TABLE} WHERE process = ? AND end_reason = 'imported_from_log'",
(proc,),
)
conn.commit()
return int(cur.rowcount or 0)
finally:
conn.close()
def _finalize_import_payload(payload: dict[str, Any]) -> dict[str, Any]:
started_at = float(payload.get("started_at") or 0)
duration = int(payload.get("duration_seconds") or 0)
ended_at = payload.get("ended_at")
if ended_at is None and duration > 0 and started_at > 0:
ended_at = started_at + duration
payload["ended_at"] = ended_at
payload["duration_seconds"] = duration
return payload
def _new_import_payload(
*,
process: str,
anchor_name: str,
stream_title: str = "",
started_at: float,
push_date: str = "",
source_url: str = "",
source_room_id: str = "",
duration_seconds: int = 0,
youtube_key_suffix: str = "",
) -> dict[str, Any]:
anchor = (anchor_name or "").strip()
title = (stream_title or "").strip()
if not push_date:
_, push_date = _parse_stream_title(title)
if not push_date and started_at:
push_date = _push_date_from_ts(started_at)
return {
"process": process,
"anchor_name": anchor,
"stream_title": title,
"started_at": started_at,
"push_date": push_date,
"source_url": source_url,
"source_room_id": source_room_id,
"duration_seconds": duration_seconds,
"youtube_key_suffix": youtube_key_suffix,
"ended_at": None,
}
def _persist_import_row(
conn: sqlite3.Connection,
payload: dict[str, Any],
*,
started_at: float,
ended_at: float | None,
duration_seconds: int,
) -> None:
anchor = str(payload.get("anchor_name") or "").strip()
stream_title = str(payload.get("stream_title") or "").strip()
if not anchor and not stream_title:
return
if duration_seconds > 0 and started_at > 0:
computed_end = started_at + duration_seconds
if ended_at is None or ended_at < started_at:
ended_at = computed_end
dedupe_started = _timestamp_from_stream_title(stream_title) or started_at
existing = conn.execute(
f"""
SELECT id FROM {_TABLE}
WHERE process = ? AND anchor_name = ? AND stream_title = ? AND ABS(started_at - ?) < 180
LIMIT 1
""",
(payload.get("process") or "youtube", anchor, stream_title, dedupe_started),
).fetchone()
if not existing and stream_title:
existing = conn.execute(
f"""
SELECT id FROM {_TABLE}
WHERE process = ? AND anchor_name = ? AND stream_title = ?
LIMIT 1
""",
(payload.get("process") or "youtube", anchor, stream_title),
).fetchone()
if existing:
return
now = time.time()
source_url = _clean_douyin_url(str(payload.get("source_url") or ""))
source_room_id = str(
payload.get("source_room_id") or extract_source_room_id(source_url, stream_title, anchor)
)
conn.execute(
f"""
INSERT INTO {_TABLE} (
session_uuid, process, channel_name, anchor_name, source_url, source_room_id,
stream_title, youtube_key_suffix, started_at, ended_at, duration_seconds, push_date,
cover_path, status, end_reason, metadata_json, created_at, updated_at
) VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'ended', 'imported_from_log', ?, ?, ?)
""",
(
uuid.uuid4().hex,
payload.get("process") or "youtube",
anchor,
source_url,
source_room_id,
stream_title,
payload.get("youtube_key_suffix") or "",
started_at,
ended_at,
duration_seconds,
payload.get("push_date") or _push_date_from_ts(started_at),
json.dumps({"import": True}, ensure_ascii=False),
now,
now,
),
)
def import_relay_sessions_from_log(
log_path: Path,
*,
process: str = "youtube",
url_lines: str = "",
youtube_key_suffix: str = "",
db_path: Path,
replace_imported: bool = False,
) -> dict[str, Any]:
path = Path(log_path)
if not path.is_file():
return {"imported": 0, "closed": 0, "error": "日志文件不存在"}
text = path.read_text(encoding="utf-8", errors="replace")
proc = _normalize_process(process)
removed = clear_imported_relay_sessions(proc, db_path=db_path) if replace_imported else 0
purge_junk_relay_sessions(process=proc, db_path=db_path)
url_index = _build_anchor_url_index(url_lines)
log_url_index: dict[str, tuple[str, str]] = {}
for raw_line in text.splitlines():
fail_match = _FAIL_ADDR_RE.search(raw_line)
if fail_match:
url = _clean_douyin_url(fail_match.group(1))
anchor = fail_match.group(2).strip()
if url and anchor:
log_url_index[anchor] = (url, extract_source_room_id(url))
incoming = _INCOMING_URL_RE.search(raw_line)
if incoming:
url = _clean_douyin_url(incoming.group(1))
if url:
log_url_index["__last_incoming__"] = (url, extract_source_room_id(url))
imported = 0
closed = 0
active: dict[str, Any] | None = None
sessions: list[dict[str, Any]] = []
conn = _connect(db_path)
try:
for raw_line in text.splitlines():
line = raw_line.strip()
if not line:
continue
log_ts = _parse_log_line_ts(line)
event_ts = _parse_local_wall_time(line, log_ts) or log_ts
incoming = _INCOMING_URL_RE.search(line)
if incoming:
url = _clean_douyin_url(incoming.group(1))
room_id = extract_source_room_id(url)
log_url_index["__last_incoming__"] = (url, room_id)
label = _ANCHOR_LABEL_RE.search(line)
if label:
log_url_index[label.group(1).strip()] = (url, room_id)
elif active and not active.get("source_url"):
active["source_url"] = url
active["source_room_id"] = room_id
continue
fail_match = _FAIL_ADDR_RE.search(line)
if fail_match:
url = _clean_douyin_url(fail_match.group(1))
anchor = fail_match.group(2).strip()
if url and anchor:
log_url_index[anchor] = (url, extract_source_room_id(url))
continue
push_match = _PUSH_START_RE.search(line)
if push_match:
stream_title = push_match.group(1).strip()
anchor, push_date = _parse_stream_title(stream_title)
started_at = _timestamp_from_stream_title(stream_title) or event_ts or time.time()
if active:
sessions.append(_finalize_import_payload(active))
closed += 1
source_url, source_room_id = _resolve_anchor_url(anchor, url_index, url_lines, log_url_index)
active = _new_import_payload(
process=proc,
anchor_name=anchor,
stream_title=stream_title,
started_at=started_at,
push_date=push_date,
source_url=source_url,
source_room_id=source_room_id,
youtube_key_suffix=youtube_key_suffix,
)
continue
rec_match = _RECORDING_LINE_RE.search(line)
if rec_match:
anchor = rec_match.group(1).strip(" :?")
duration = _parse_duration_token(rec_match.group(2).strip())
if active and str(active.get("anchor_name") or "") == anchor:
active["duration_seconds"] = max(int(active.get("duration_seconds") or 0), duration)
if not active.get("source_url"):
source_url, source_room_id = _resolve_anchor_url(anchor, url_index, url_lines, log_url_index)
active["source_url"] = source_url
active["source_room_id"] = source_room_id
elif active and str(active.get("anchor_name") or "") != anchor:
sessions.append(_finalize_import_payload(active))
closed += 1
started_at = (event_ts or time.time()) - duration if duration else (event_ts or time.time())
source_url, source_room_id = _resolve_anchor_url(anchor, url_index, url_lines, log_url_index)
active = _new_import_payload(
process=proc,
anchor_name=anchor,
started_at=started_at,
source_url=source_url,
source_room_id=source_room_id,
duration_seconds=duration,
youtube_key_suffix=youtube_key_suffix,
)
else:
started_at = (event_ts or time.time()) - duration if duration else (event_ts or time.time())
source_url, source_room_id = _resolve_anchor_url(anchor, url_index, url_lines, log_url_index)
active = _new_import_payload(
process=proc,
anchor_name=anchor,
started_at=started_at,
source_url=source_url,
source_room_id=source_room_id,
duration_seconds=duration,
youtube_key_suffix=youtube_key_suffix,
)
continue
if "推流已彻底停止" in line and active:
if event_ts and int(active.get("duration_seconds") or 0) > 0:
active["ended_at"] = float(active["started_at"]) + int(active["duration_seconds"])
elif event_ts:
active["ended_at"] = event_ts
sessions.append(_finalize_import_payload(active))
active = None
closed += 1
if active:
sessions.append(_finalize_import_payload(active))
for payload in sessions:
if not str(payload.get("anchor_name") or "").strip() and not str(payload.get("stream_title") or "").strip():
continue
if not payload.get("source_url"):
source_url, source_room_id = _resolve_anchor_url(
str(payload.get("anchor_name") or ""),
url_index,
url_lines,
log_url_index,
)
payload["source_url"] = source_url
payload["source_room_id"] = source_room_id or payload.get("source_room_id") or ""
_persist_import_row(
conn,
payload,
started_at=float(payload["started_at"]),
ended_at=payload.get("ended_at"),
duration_seconds=int(payload.get("duration_seconds") or 0),
)
imported += 1
if youtube_key_suffix:
conn.execute(
f"""
UPDATE {_TABLE}
SET youtube_key_suffix = ?, updated_at = ?
WHERE process = ? AND COALESCE(youtube_key_suffix, '') = ''
""",
(youtube_key_suffix, time.time(), proc),
)
conn.commit()
finally:
conn.close()
return {
"imported": imported,
"closed": closed,
"removed": removed,
"process": proc,
"log_path": str(path),
}
def track_process_status_transition(
process: str,
old_status: str,
new_status: str,
*,
db_path: Path,
) -> None:
"""d2ypp2 不在 PM2 状态切换时写会话;保留空实现避免脏数据。"""
return
def _capture_cover_safe(session_id: int, stream_url: str, db_path: Path, base_dir: Path) -> None:
try:
capture_relay_cover(session_id, stream_url, db_path=db_path, base_dir=base_dir)
except Exception:
pass
def capture_relay_cover(
session_id: int,
stream_url: str,
*,
db_path: Path,
base_dir: Path,
) -> str:
url = (stream_url or "").strip()
if not url or not url.startswith("http"):
return ""
covers = _covers_dir(base_dir)
out_path = covers / f"{session_id}.jpg"
headers = (
"User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15\r\n"
"Referer: https://live.douyin.com/\r\n"
)
cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-y",
"-headers",
headers,
"-rw_timeout",
"15000000",
"-i",
url,
"-frames:v",
"1",
"-q:v",
"3",
str(out_path),
]
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=25)
if proc.returncode != 0 or not out_path.is_file() or out_path.stat().st_size < 128:
return ""
conn = _connect(db_path)
try:
conn.execute(
f"UPDATE {_TABLE} SET cover_path = ?, updated_at = ? WHERE id = ?",
(str(out_path), time.time(), session_id),
)
conn.commit()
finally:
conn.close()
return str(out_path)
def get_relay_cover_path(session_id: int | str, *, db_path: Path) -> Path | None:
conn = _connect(db_path)
try:
row = conn.execute(
f"SELECT cover_path FROM {_TABLE} WHERE id = ? OR session_uuid = ?",
(session_id, str(session_id)),
).fetchone()
if not row:
return None
path = Path(str(row["cover_path"] or ""))
return path if path.is_file() else None
finally:
conn.close()