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>
65 lines
3.1 KiB
Swift
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)
|
|
}
|
|
}
|