tv-anarchy/Sources/TVAnarchyCore/DurationProbe.swift
Natalie eb0d75a126 feat(adult): ⏱ show clip length in collection detail list
Each clip row in the adult collection detail view now shows its runtime, and
the header shows total runtime of the queued set (for planning a session of a
given length). Durations are probed in one background SSH batch via ffprobe on
black (NUL-delimited paths over stdin, so the eporner filenames with spaces/
quotes/brackets pass verbatim), debounced on filter and capped at 400 per batch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:56:59 -04:00

65 lines
3.1 KiB
Swift

import Foundation
/// Probes video durations (seconds) for black-side library paths via a single
/// SSH connection that runs `ffprobe` per file on the storage host. Paths are
/// sent NUL-delimited on stdin, so arbitrary filenames (the eporner scrapes have
/// spaces, quotes, and `[brackets]`) pass verbatim with zero shell quoting.
///
/// Used to show clip length in the adult collection detail list. Best-effort:
/// an unreachable host or a per-file probe failure just omits that path from the
/// result. Blocking always call off the main actor.
public enum DurationProbe {
/// Reads NUL-delimited paths from stdin; for each, emits `<seconds>\t<path>\0`.
private static let remoteScript =
"while IFS= read -r -d '' p; do "
+ "d=$(ffprobe -v error -show_entries format=duration -of csv=p=0 \"$p\" 2>/dev/null); "
+ "printf '%s\\t%s\\0' \"${d:-}\" \"$p\"; done"
/// Returns `[path: seconds]` for paths that resolved to a positive duration.
/// `timeout` caps the whole batch (the remote walk can spin up sleeping disks).
public static func probe(paths: [String], timeout: TimeInterval = 90) -> [String: Double] {
guard !paths.isEmpty, let host = DevicesConfig.storageSSHEndpoints().first else { return [:] }
let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
p.arguments = ["-o", "ConnectTimeout=6", "-o", "BatchMode=yes", host, remoteScript]
let inPipe = Pipe(), outPipe = Pipe()
p.standardInput = inPipe
p.standardOutput = outPipe
p.standardError = Pipe()
do { try p.run() } catch { return [:] }
// Feed NUL-joined paths, then close stdin so the remote read loop ends.
let payload = paths.map { $0 + "\0" }.joined()
inPipe.fileHandleForWriting.write(Data(payload.utf8))
try? inPipe.fileHandleForWriting.close()
// Watchdog: terminate an overrunning batch (closes the pipe read returns).
let killer = DispatchWorkItem { if p.isRunning { p.terminate() } }
DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: killer)
let data = outPipe.fileHandleForReading.readDataToEndOfFile()
p.waitUntilExit()
killer.cancel()
var out: [String: Double] = [:]
let text = String(decoding: data, as: UTF8.self)
for record in text.split(separator: "\0", omittingEmptySubsequences: true) {
guard let tab = record.firstIndex(of: "\t") else { continue }
let durStr = record[..<tab]
let path = String(record[record.index(after: tab)...])
if let secs = Double(durStr.trimmingCharacters(in: .whitespacesAndNewlines)), secs > 0 {
out[path] = secs
}
}
return out
}
/// `1:23:45` / `12:34` / `0:09` from a duration in seconds.
public static func format(_ seconds: Double) -> String {
let s = Int(seconds.rounded())
let h = s / 3600, m = (s % 3600) / 60, sec = s % 60
return h > 0 ? String(format: "%d:%02d:%02d", h, m, sec)
: String(format: "%d:%02d", m, sec)
}
}