From eb0d75a126e0569230f68d5b122569c2e68c87c2 Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 30 Jun 2026 00:56:59 -0400 Subject: [PATCH] =?UTF-8?q?feat(adult):=20=E2=8F=B1=20show=20clip=20length?= =?UTF-8?q?=20in=20collection=20detail=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Sources/TVAnarchy/GoonCollectionView.swift | 35 ++++++++++++ Sources/TVAnarchyCore/DurationProbe.swift | 65 ++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 Sources/TVAnarchyCore/DurationProbe.swift diff --git a/Sources/TVAnarchy/GoonCollectionView.swift b/Sources/TVAnarchy/GoonCollectionView.swift index cfee6a0..bae1efb 100644 --- a/Sources/TVAnarchy/GoonCollectionView.swift +++ b/Sources/TVAnarchy/GoonCollectionView.swift @@ -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 = [] + /// 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) diff --git a/Sources/TVAnarchyCore/DurationProbe.swift b/Sources/TVAnarchyCore/DurationProbe.swift new file mode 100644 index 0000000..215bf0e --- /dev/null +++ b/Sources/TVAnarchyCore/DurationProbe.swift @@ -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 `\t\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[.. 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) + } +}