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 `\t\0`. /// /// Injection note: `$p` is a double-quoted variable expansion, so the shell /// does not re-evaluate the path's contents — a filename containing `$(…)` or /// backticks is passed verbatim, not executed (and the path arrives as stdin /// *data*, never on a command line). `--` terminates ffprobe's option parsing /// so a path beginning with `-` can't be read as a flag. 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[.. 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) } }