tv-anarchy/Sources/TVAnarchyCore/LogController.swift
Natalie 92b38b1bae refactor(tv-anarchy): rename PlumTV→TVAnarchy and land session work
Renames Sources/PlumTV→TVAnarchy and PlumTVCore→TVAnarchyCore (the rename
the auto-commit service couldn't stage — it git-add'd the old, now-gone
paths and aborted every cycle), and commits the accumulated work:

- Library: black-built index fast path (LibraryIndex + scanFromIndex) with
  NFS-walk fallback; incremental --add on download-complete; mtime staleness
  gate; loose-file series-collapse fix; determinate scan/index progress.
- Cover art: keyless TVmaze cartoon-vs-live-action disambiguation (type/year).
- Player: sleep timer (timed + end-of-episode); visibility-gated polling.
- Home: Continue Watching cover art + live refresh; Recently Added; adult gate.
- Logs: multi-line selection + copy; truncated giant tx-list errors.
- Hover previews (opt-in) via black ffmpeg + scp.

Also gitignores foreign project trees (governor/mcp/fleet/recommender) that
sit in this directory but belong to their own repos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:04:22 -07:00

71 lines
2.3 KiB
Swift

import Foundation
import Observation
/// Backs the Logs tab: tails `/tmp/tvanarchy.log`, parses lines into entries, and
/// exposes a text + level filter. Polls a couple times a second so new lines
/// appear live.
@Observable
@MainActor
public final class LogController {
public struct Entry: Identifiable, Sendable, Equatable {
public let id: Int
public let time: String
public let level: Log.Level
public let text: String
}
public var query: String = ""
public var levels: Set<Log.Level> = Set(Log.Level.allCases)
public private(set) var entries: [Entry] = []
private var task: Task<Void, Never>?
public init() {}
public func start() {
reload()
task?.cancel()
task = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2))
self?.reload()
}
}
}
public func stop() { task?.cancel() }
public func reload() {
entries = Self.parse(Log.tail())
}
public func clear() {
Log.clear()
entries = []
}
/// Filtered by enabled levels + a case-insensitive substring query.
public var filtered: [Entry] {
let q = query.trimmingCharacters(in: .whitespaces).lowercased()
return entries.filter { e in
levels.contains(e.level) && (q.isEmpty || e.text.lowercased().contains(q))
}
}
public func toggle(_ level: Log.Level) {
if levels.contains(level) { levels.remove(level) } else { levels.insert(level) }
}
public var errorCount: Int { entries.filter { $0.level == .error }.count }
/// Parse `HH:mm:ss.SSS [LEVEL] message`; unparseable lines become INFO.
static func parse(_ lines: [String]) -> [Entry] {
lines.enumerated().map { i, line in
let parts = line.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: false).map(String.init)
if parts.count == 3, parts[1].hasPrefix("["), parts[1].hasSuffix("]"),
let lvl = Log.Level(rawValue: String(parts[1].dropFirst().dropLast())) {
return Entry(id: i, time: parts[0], level: lvl, text: parts[2])
}
return Entry(id: i, time: "", level: .info, text: line)
}
}
}