Tapping a collection card on the Adult page now opens a detail view listing every clip in that collection (goon, pmv, etc) instead of silently firing the whole set at a host with nothing cached. Each row shows: - queued state as a tickable checklist (build a session clip by clip) - freshness / last-played - offline-cached state, with a per-clip download-to-offline button Plus a title filter (find e.g. a specific 'brain rot'/gooner clip), queue-all- fresh, download-all-queued-offline, and play-queued. Downloads land in the offline cache where the new star/trash row controls manage them. Quick-play the old fire path stays on the card context menu. Core: PornCollectionService.clips()/title() expose the full per-collection clip list with freshness; PlaylistController gains single-item checklist queue ops (isQueued/addToQueue/removeFromQueue) and pornClips(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
316 lines
15 KiB
Swift
316 lines
15 KiB
Swift
import Foundation
|
|
|
|
#if ENABLE_ADULT
|
|
|
|
/// One porn collection with live fresh/total counts, surfaced in the Adult tab
|
|
/// and the play-queue popover. `id == name`.
|
|
public struct PornCollection: Identifiable, Sendable, Equatable, Decodable {
|
|
public let name: String
|
|
public let desc: String
|
|
public let fresh: Int
|
|
public let total: Int
|
|
public var id: String { name }
|
|
public init(name: String, desc: String, fresh: Int, total: Int) {
|
|
self.name = name; self.desc = desc; self.fresh = fresh; self.total = total
|
|
}
|
|
}
|
|
|
|
/// One clip in a collection's detail listing: its black-side path, a cleaned
|
|
/// display title, freshness (unplayed within the window), and last-played date.
|
|
/// Drives the per-collection checklist view (queue + offline download).
|
|
public struct PornClip: Identifiable, Sendable, Equatable, Hashable {
|
|
public let path: String
|
|
public let title: String
|
|
public let fresh: Bool
|
|
public let lastPlayed: Date?
|
|
public var id: String { path }
|
|
public init(path: String, title: String, fresh: Bool, lastPlayed: Date?) {
|
|
self.path = path; self.title = title; self.fresh = fresh; self.lastPlayed = lastPlayed
|
|
}
|
|
}
|
|
|
|
/// A named, virtual playlist over the porn pool: an *include* filter (match any
|
|
/// substring) with an optional *exclude*, layered on top of the global SKIP
|
|
/// floor. A file can belong to several collections; nothing is moved on disk.
|
|
/// Ported verbatim (data, not code) from portable-net-tv's `porn-rotation.py`.
|
|
public struct PornCollectionSpec: Sendable {
|
|
public let name: String
|
|
public let desc: String
|
|
public let include: [String]
|
|
public let exclude: [String]
|
|
public init(_ name: String, _ desc: String, include: [String], exclude: [String] = []) {
|
|
self.name = name; self.desc = desc; self.include = include; self.exclude = exclude
|
|
}
|
|
}
|
|
|
|
/// Native, in-process port of portable-net-tv's `porn-rotation.py` freshness
|
|
/// rotation — no runtime shell-out. The file pool comes from the library index
|
|
/// (loose porn files land there as one-file movies under the `porn` category),
|
|
/// so it works offline and reuses black's fast index instead of re-walking NFS.
|
|
/// The pure resolvers (floor / collection / dedup / freshness) are split from the
|
|
/// play-log IO so they're unit-testable without a media mount.
|
|
///
|
|
/// Freshness is per *file* (basename → last-played ISO), in a single global
|
|
/// play-log, so a clip watched under `goon` won't resurface under `pmv` tomorrow.
|
|
public enum PornCollectionService {
|
|
// MARK: - Definitions (ported from porn-rotation.py)
|
|
|
|
/// The hard floor: titles never surfaced regardless of freshness or
|
|
/// collection. No-JOI rule; cuck/chastity excluded (eporner's "chastity" tag
|
|
/// skews heavily cuckhold — caged/keyholder/chastity are the same cluster).
|
|
static let skipSubstrings = [
|
|
"joi", "cei", "jerk off instruction",
|
|
"cuck", "chastity", "keyholder", "caged",
|
|
]
|
|
|
|
static let videoExts: Set<String> = [".mp4", ".mkv", ".m4v", ".avi", ".webm", ".wmv", ".mov"]
|
|
|
|
/// Each collection: include substrings (OR-matched, lowercased); a candidate
|
|
/// joins if it matches ANY include term (or include is empty → the whole
|
|
/// pool). `exclude` drops matches within the collection only. Order is the
|
|
/// display order. Kept in sync with `porn-rotation.py`'s COLLECTIONS dict.
|
|
static let specs: [PornCollectionSpec] = [
|
|
.init("all", "the whole library (no include filter)", include: []),
|
|
.init("goon", "gooning / edging encouragement",
|
|
include: ["goon", "edge", "edging", "encouragement", "mantra", "trance"]),
|
|
.init("pmv", "PMV / HMV music edits",
|
|
include: ["pmv", "hmv", "music video", "cock hero"]),
|
|
.init("sissy", "sissy / trap / femboy",
|
|
include: ["sissy", "sissif", "trap", "femboy", "feminiz"]),
|
|
.init("bbc", "BBC / interracial / split-screen",
|
|
include: ["bbc", "interracial", "blacked", "split screen", "splitscreen"]),
|
|
.init("futa", "futa / futanari",
|
|
include: ["futa", "futanari", "dickgirl"]),
|
|
.init("anime", "hentai / 3D / game-parody animation",
|
|
include: ["hentai", "uncensored", "animated", "animation", "parody",
|
|
"cartoon", "3d porn", "sfm", "anime"]),
|
|
.init("comp", "compilations / split-screen montages",
|
|
include: ["compilation", "split screen", "splitscreen", " comp ", "cumpilation"]),
|
|
.init("breeding", "breeding / creampie",
|
|
include: ["breed", "breeding", "creampie", "impregnat", "cum inside"]),
|
|
.init("gangbang", "group / gangbang / orgy",
|
|
include: ["gangbang", "gang bang", "orgy", "threesome", "foursome", "bukkake"]),
|
|
.init("milf", "milf / mommy / mature",
|
|
include: ["milf", "stepmom", "step mom", "mommy", "mature", "cougar"]),
|
|
.init("cosplay", "cosplay", include: ["cosplay"]),
|
|
.init("asmr", "ASMR / ear / whisper",
|
|
include: ["asmr", "ear lick", "ear licking", "whisper", "moans in your ear"]),
|
|
.init("naruto", "Naruto parody",
|
|
include: ["naruto", "hinata", "tsunade", "sakura", "kushina", "boruto"]),
|
|
.init("overwatch", "Overwatch parody",
|
|
include: ["overwatch", "d.va", "dva", "widowmaker", "mercy", "tracer", "pharah"]),
|
|
]
|
|
|
|
/// Default freshness window: a file unplayed for this many days is "fresh".
|
|
public static let defaultDays = 7
|
|
|
|
// MARK: - Pure resolvers (unit-tested without a mount)
|
|
|
|
/// Eporner exports the same clip at several qualities; the basename carries
|
|
/// the stable id in [brackets]. Grouped by it for de-duplication.
|
|
static let epornerID = try! NSRegularExpression(pattern: #"\[([0-9A-Za-z]{6,})\]"#)
|
|
|
|
static func passesFloor(_ basename: String) -> Bool {
|
|
let low = basename.lowercased()
|
|
return !skipSubstrings.contains { low.contains($0) }
|
|
}
|
|
|
|
static func isPlayable(_ basename: String) -> Bool {
|
|
let low = basename.lowercased()
|
|
guard videoExts.contains(where: { low.hasSuffix($0) }) else { return false }
|
|
// Skip in-flight downloads (.part/.tmp, or eporner's `.mp4_<n>` chunks).
|
|
return !(low.hasSuffix(".part") || low.hasSuffix(".tmp") || low.contains(".mp4_"))
|
|
}
|
|
|
|
static func epornerKey(_ basename: String) -> String? {
|
|
let range = NSRange(basename.startIndex..., in: basename)
|
|
guard let m = epornerID.firstMatch(in: basename, range: range),
|
|
let r = Range(m.range(at: 1), in: basename) else { return nil }
|
|
return String(basename[r])
|
|
}
|
|
|
|
/// De-dupe eporner re-exports by their [id], keeping the FIRST seen. The index
|
|
/// carries no file size, so we can't replicate the python's "keep largest" —
|
|
/// counts still match (one entry per id), only the chosen representative may
|
|
/// differ. Non-eporner files (no id) pass through untouched.
|
|
static func dedupById(_ paths: [String]) -> [String] {
|
|
var seenIDs = Set<String>()
|
|
var out: [String] = []
|
|
for p in paths {
|
|
let base = (p as NSString).lastPathComponent
|
|
if let key = epornerKey(base) {
|
|
if seenIDs.insert(key).inserted { out.append(p) }
|
|
} else {
|
|
out.append(p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
static func inCollection(_ spec: PornCollectionSpec, basename: String) -> Bool {
|
|
let low = basename.lowercased()
|
|
if !spec.include.isEmpty, !spec.include.contains(where: { low.contains($0) }) { return false }
|
|
if spec.exclude.contains(where: { low.contains($0) }) { return false }
|
|
return true
|
|
}
|
|
|
|
/// True if `basename` was never played, or last played more than `days` ago.
|
|
static func isFresh(_ basename: String, log: [String: String], days: Int, now: Date) -> Bool {
|
|
guard let ts = log[basename], let last = ISO8601.date(ts) else { return true }
|
|
return last < now.addingTimeInterval(-Double(days) * 86_400)
|
|
}
|
|
|
|
/// Floor-filtered, deduped, playable files from a raw library pool. The single
|
|
/// candidate set every collection filters over.
|
|
static func candidates(pool: [String]) -> [String] {
|
|
dedupById(pool.filter {
|
|
let base = ($0 as NSString).lastPathComponent
|
|
return isPlayable(base) && passesFloor(base)
|
|
})
|
|
}
|
|
|
|
// MARK: - Public API (drives the UI; `pool` = porn paths from the library index)
|
|
|
|
/// The defined collections with fresh/total counts over `pool`. Empty `pool`
|
|
/// (index not yet loaded) → all-zero rows, which the UI renders as disabled.
|
|
public static func collections(pool: [String], days: Int = defaultDays) -> [PornCollection] {
|
|
let log = PornPlayLog.load()
|
|
let now = Date()
|
|
let cands = candidates(pool: pool)
|
|
return specs.map { spec in
|
|
let inSpec = cands.filter { inCollection(spec, basename: ($0 as NSString).lastPathComponent) }
|
|
let fresh = inSpec.filter { isFresh(($0 as NSString).lastPathComponent, log: log, days: days, now: now) }
|
|
return PornCollection(name: spec.name, desc: spec.desc, fresh: fresh.count, total: inSpec.count)
|
|
}
|
|
}
|
|
|
|
/// Fresh, shuffled file paths for a collection, marking them played so the
|
|
/// freshness state advances (playback happens app-side, not here).
|
|
public static func freshPaths(pool: [String], collection: String,
|
|
count: Int, days: Int = defaultDays) -> [String] {
|
|
guard let spec = specs.first(where: { $0.name == collection }) else { return [] }
|
|
let log = PornPlayLog.load()
|
|
let now = Date()
|
|
let fresh = candidates(pool: pool)
|
|
.filter { inCollection(spec, basename: ($0 as NSString).lastPathComponent) }
|
|
.filter { isFresh(($0 as NSString).lastPathComponent, log: log, days: days, now: now) }
|
|
let pick = Array(fresh.shuffled().prefix(count))
|
|
if !pick.isEmpty { markPlayed(pick) }
|
|
return pick
|
|
}
|
|
|
|
/// Stamp paths played now, persisting the shared freshness log.
|
|
public static func markPlayed(_ paths: [String]) {
|
|
guard !paths.isEmpty else { return }
|
|
var log = PornPlayLog.load()
|
|
let stamp = ISO8601.string(Date())
|
|
for p in paths { log[(p as NSString).lastPathComponent] = stamp }
|
|
PornPlayLog.save(log)
|
|
}
|
|
|
|
/// Cleaned display title for a clip path: drops the `EPORNER.COM - [id]`
|
|
/// scrape prefix and the extension. Mirrors PlaylistController.prettyPornTitle
|
|
/// so the collection detail list and the live queue read identically.
|
|
public static func title(forPath path: String) -> String {
|
|
var t = ((path as NSString).lastPathComponent as NSString).deletingPathExtension
|
|
if let r = t.range(of: #"^EPORNER\.COM - \[[0-9A-Za-z]+\]\s*"#, options: .regularExpression) {
|
|
t.removeSubrange(r)
|
|
}
|
|
return t.isEmpty ? "clip" : t
|
|
}
|
|
|
|
/// Every clip in a collection (not just the fresh ones), with per-file
|
|
/// freshness + last-played, fresh-first then alphabetical. Backs the detail
|
|
/// checklist so the user can see the whole collection and pick what to queue
|
|
/// or download offline. Pure over `pool` (the adult library index) + play-log.
|
|
public static func clips(pool: [String], collection: String, days: Int = defaultDays) -> [PornClip] {
|
|
guard let spec = specs.first(where: { $0.name == collection }) else { return [] }
|
|
let log = PornPlayLog.load()
|
|
let now = Date()
|
|
let inSpec = candidates(pool: pool)
|
|
.filter { inCollection(spec, basename: ($0 as NSString).lastPathComponent) }
|
|
let mapped = inSpec.map { p -> PornClip in
|
|
let base = (p as NSString).lastPathComponent
|
|
return PornClip(path: p, title: title(forPath: p),
|
|
fresh: isFresh(base, log: log, days: days, now: now),
|
|
lastPlayed: log[base].flatMap { ISO8601.date($0) })
|
|
}
|
|
return mapped.sorted { a, b in
|
|
if a.fresh != b.fresh { return a.fresh && !b.fresh }
|
|
return a.title.localizedCaseInsensitiveCompare(b.title) == .orderedAscending
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Per-file porn play-log (basename → last-played ISO). App-owned at
|
|
/// `~/.local/state/tv-anarchy/porn-plays.json`; on first use it imports the
|
|
/// legacy portable-net-tv log so freshness history isn't lost when the runtime
|
|
/// shell-out is retired. `TV_ANARCHY_STATE_DIR` redirects it for tests (mirrors
|
|
/// QueueStore), so tests never touch the real log.
|
|
enum PornPlayLog {
|
|
private static var stateDir: URL {
|
|
if let dir = ProcessInfo.processInfo.environment["TV_ANARCHY_STATE_DIR"], !dir.isEmpty {
|
|
return URL(fileURLWithPath: dir, isDirectory: true)
|
|
}
|
|
return FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent(".local/state/tv-anarchy")
|
|
}
|
|
private static var url: URL { stateDir.appendingPathComponent("porn-plays.json") }
|
|
private static var legacyURL: URL {
|
|
FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent(".local/state/portable-net-tv/porn-plays.json")
|
|
}
|
|
|
|
static func load() -> [String: String] {
|
|
if let d = try? Data(contentsOf: url),
|
|
let log = try? JSONDecoder().decode([String: String].self, from: d) { return log }
|
|
// One-time import: seed from the legacy script's log so the don't-replay
|
|
// history carries over. Only when our own file doesn't exist yet.
|
|
if let d = try? Data(contentsOf: legacyURL),
|
|
let log = try? JSONDecoder().decode([String: String].self, from: d) {
|
|
save(log)
|
|
return log
|
|
}
|
|
return [:]
|
|
}
|
|
|
|
static func save(_ log: [String: String]) {
|
|
guard let d = try? JSONEncoder().encode(log) else { return }
|
|
try? FileManager.default.createDirectory(at: stateDir, withIntermediateDirectories: true)
|
|
try? d.write(to: url, options: .atomic)
|
|
}
|
|
}
|
|
|
|
/// ISO-8601 helper for the play-log. The python log is written by
|
|
/// `datetime.now(timezone.utc).isoformat()` → **microsecond** precision
|
|
/// (`2026-05-29T19:59:47.649910+00:00`), which `ISO8601DateFormatter`'s
|
|
/// `.withFractionalSeconds` rejects (it accepts only 3 fractional digits). Since
|
|
/// freshness compares at day granularity, sub-second precision is irrelevant: we
|
|
/// parse just the `yyyy-MM-dd'T'HH:mm:ss` prefix in UTC, tolerant of any
|
|
/// fractional part and timezone suffix. That retroactively rescues the imported
|
|
/// legacy timestamps — no data migration needed.
|
|
enum ISO8601 {
|
|
private static func utcFormatter(_ fmt: String) -> DateFormatter {
|
|
let f = DateFormatter()
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
f.timeZone = TimeZone(identifier: "UTC")
|
|
f.dateFormat = fmt
|
|
return f
|
|
}
|
|
/// We write the same microsecond-style UTC stamp the python uses, so a log
|
|
/// shared with (or re-read by) the legacy tool stays format-compatible.
|
|
private static let writer = utcFormatter("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'+00:00'")
|
|
private static let secondsParser = utcFormatter("yyyy-MM-dd'T'HH:mm:ss")
|
|
|
|
static func string(_ date: Date) -> String { writer.string(from: date) }
|
|
|
|
/// Parse to the second, ignoring any fractional/offset tail. Treats the
|
|
/// wall-clock as UTC (the python always writes UTC), which is exact for the
|
|
/// day-granular freshness comparison.
|
|
static func date(_ s: String) -> Date? {
|
|
let head = s.prefix(19) // "yyyy-MM-ddTHH:mm:ss"
|
|
return secondsParser.date(from: String(head))
|
|
}
|
|
}
|
|
|
|
#endif
|