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 = [".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_` 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() 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