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>
71 lines
2.3 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|