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>
This commit is contained in:
Natalie 2026-06-30 00:56:59 -04:00
parent ee7efad888
commit eb0d75a126
2 changed files with 100 additions and 0 deletions

View file

@ -28,6 +28,11 @@ struct GoonCollectionView: View {
/// Paths confirmed present in the offline cache (seeded from the index, then
/// updated as downloads complete).
@State private var offlinePaths: Set<String> = []
/// Probed clip durations (seconds), filled lazily in the background per path.
@State private var durations: [String: Double] = [:]
/// Re-keys the duration probe: changes when clips load or the filter settles.
private var probeKey: String { "\(clips.count)#\(filter)" }
private var shown: [PornClip] {
let q = filter.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
@ -37,6 +42,9 @@ struct GoonCollectionView: View {
private var queued: [PornClip] { clips.filter { playlist.isQueued(path: $0.path) } }
/// Total known runtime of the queued clips (sum of probed durations).
private var queuedSeconds: Double { queued.compactMap { durations[$0.path] }.reduce(0, +) }
var body: some View {
VStack(spacing: 0) {
header
@ -45,6 +53,7 @@ struct GoonCollectionView: View {
}
.frame(minWidth: 580, minHeight: 540)
.task { await load() }
.task(id: probeKey) { await probeVisible() }
}
// MARK: header
@ -109,6 +118,12 @@ struct GoonCollectionView: View {
}
Spacer()
if queuedSeconds > 0 {
Text(DurationProbe.format(queuedSeconds))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.help("Total runtime of queued clips")
}
Button { playQueued() } label: {
Label("Play \(queued.count) queued", systemImage: "play.fill")
}
@ -168,6 +183,10 @@ struct GoonCollectionView: View {
VStack(alignment: .leading, spacing: 2) {
Text(clip.title).font(.callout).lineLimit(2)
HStack(spacing: 8) {
if let secs = durations[clip.path] {
Label(DurationProbe.format(secs), systemImage: "clock")
.font(.caption2.monospacedDigit()).foregroundStyle(.secondary)
}
if clip.fresh {
Label("fresh", systemImage: "sparkles")
.font(.caption2).foregroundStyle(.green)
@ -221,6 +240,22 @@ struct GoonCollectionView: View {
loading = false
}
/// Fill durations for the currently-shown clips in one background SSH batch.
/// Debounced so fast filter typing doesn't spawn an ssh per keystroke; capped
/// so the "all" collection (hundreds of clips) can't launch an unbounded walk.
private func probeVisible() async {
try? await Task.sleep(for: .milliseconds(350))
if Task.isCancelled { return }
let need = Array(Set(shown.map(\.path)).subtracting(durations.keys)).prefix(400)
guard !need.isEmpty else { return }
let targets = Array(need)
let probed = await Task.detached(priority: .utility) {
DurationProbe.probe(paths: targets)
}.value
if Task.isCancelled || probed.isEmpty { return }
durations.merge(probed) { _, new in new }
}
private func toggleQueue(_ clip: PornClip) {
if playlist.isQueued(path: clip.path) {
playlist.removeFromQueue(path: clip.path)

View file

@ -0,0 +1,65 @@
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)
}
}