"""qBittorrent log parser. Handles the standard qBittorrent log format: (YYYY/MM/DD HH:MM:SS) [Level] Message text (YYYY/MM/DD HH:MM:SS) Message text (no explicit level) The level field is optional — qBittorrent omits it for Normal/Info messages in some versions. Parenthesised timestamp is the format fingerprint. """ from __future__ import annotations import re from datetime import datetime, timezone from typing import Iterator from app.ingest.base import ( SourceState, apply_patterns, detect_severity, make_entry_id, now_iso, ) from app.services.models import LogPattern, RetrievedEntry # (2026/05/09 14:23:01) [Warning] Tracker 'http://...' is not working. # (2026/05/09 14:23:01) qBittorrent v5.0.3 started _LINE_RE = re.compile( r"^\((?P\d{4}[/-]\d{2}[/-]\d{2}\s+\d{2}:\d{2}:\d{2})\)" r"(?:\s+\[(?P[^\]]+)\])?" r"\s+(?P.*)$" ) _LEVEL_MAP = { "normal": "INFO", "info": "INFO", "warning": "WARN", "critical": "CRITICAL", } def _parse_ts(ts_str: str) -> tuple[str, str]: """Return (raw, iso). Handles both YYYY/MM/DD and YYYY-MM-DD.""" normalized = ts_str.replace("/", "-") try: dt = datetime.strptime(normalized, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc) return ts_str, dt.isoformat() except ValueError: return ts_str, "" def is_qbit_log(first_line: str) -> bool: return bool(_LINE_RE.match(first_line.strip())) def parse( lines: Iterator[str], source_id: str, compiled_patterns: list[tuple[LogPattern, object]], ingest_time: str | None = None, ) -> Iterator[RetrievedEntry]: ingest_time = ingest_time or now_iso() state = SourceState() pending_text: str | None = None pending_meta: dict = {} def _emit(text: str, meta: dict) -> RetrievedEntry: repeat, out_of_order = state.observe(text, meta.get("ts_iso")) matched = apply_patterns(text, compiled_patterns) return RetrievedEntry( entry_id=make_entry_id(source_id, state.sequence, text), source_id=source_id, sequence=state.sequence, timestamp_raw=meta.get("ts_raw", ""), timestamp_iso=meta.get("ts_iso", ""), ingest_time=ingest_time, severity=meta.get("severity"), repeat_count=repeat, out_of_order=out_of_order, matched_patterns=matched, text=text, ) for raw_line in lines: line = raw_line.rstrip("\n") m = _LINE_RE.match(line) if m: if pending_text is not None: yield _emit(pending_text, pending_meta) ts_raw, ts_iso = _parse_ts(m.group("ts")) level_raw = (m.group("level") or "").lower() severity = _LEVEL_MAP.get(level_raw) or detect_severity(m.group("msg")) pending_meta = { "ts_raw": ts_raw, "ts_iso": ts_iso, "severity": severity, } pending_text = m.group("msg") elif pending_text is not None: # Continuation line (Qt stack trace or wrapped message) pending_text += "\n" + line.strip() if pending_text is not None: yield _emit(pending_text, pending_meta)