tv-anarchy/Sources/TVAnarchyCore/Library/LibraryConfig.swift
Natalie b44b5a2d1a feat(@applications): add adult content browsing tab
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-09 19:51:12 -07:00

100 lines
5.7 KiB
Swift

import Foundation
/// A library "type" the kind of content a top-level media folder holds. The
/// list is a configurable, expandable DEFAULT (see `LibraryTypes.defaults`): the
/// user can rename, add, or remove types in Setup. `adult` is just a property of
/// a type (the `porn` default has it), so adult gating follows whatever types are
/// flagged adult it isn't porn-specific.
public struct LibraryType: Identifiable, Sendable, Hashable, Codable {
/// Stable key (matches the folder convention, e.g. "tv"). Immutable once made.
public let id: String
/// Display name, editable (e.g. "TV").
public var label: String
/// Whether content of this type is adult (hidden/gated by default).
public var adult: Bool
public init(id: String, label: String, adult: Bool = false) {
self.id = id; self.label = label; self.adult = adult
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
label = try c.decodeIfPresent(String.self, forKey: .label) ?? id.capitalized
adult = try c.decodeIfPresent(Bool.self, forKey: .adult) ?? false
}
}
/// The seed catalog of library types the editable default the app ships with.
/// Runtime reads (label / adult / order / the live list) go through `LibraryConfig`,
/// which serves the user's edited list when present and this otherwise.
public enum LibraryTypes {
public static let defaults: [LibraryType] = [
.init(id: "tv", label: "TV"),
.init(id: "movies", label: "Movies"),
.init(id: "anime", label: "Anime"),
.init(id: "cartoons", label: "Cartoons"),
.init(id: "collections", label: "Collections"),
.init(id: "unsorted", label: "Unsorted"),
.init(id: "misc", label: "Misc"),
.init(id: "porn", label: "Porn", adult: true),
]
/// Slugify a user-entered type name into a stable id (lowercased, spaces
/// dashes, stripped of anything but a-z0-9-). Used when adding a custom type.
public static func slug(_ name: String) -> String {
let lowered = name.lowercased().replacingOccurrences(of: " ", with: "-")
return String(lowered.unicodeScalars.filter { "abcdefghijklmnopqrstuvwxyz0123456789-".unicodeScalars.contains($0) })
}
}
/// The runtime library config the configurable type list, the foldertype
/// mapping, and the derived adult-set cached in memory. `isAdult` / `type(of:)`
/// / `label` are read constantly during SwiftUI renders, so a per-read disk load
/// would be a real perf problem. The cache is written ONLY by `SettingsStore`
/// (on every load/save), never by call sites: no public refresh to forget, and
/// because `save()` is synchronous it's fresh before `@Observable` notifies.
public enum LibraryConfig {
/// The active type catalog (the user's edited list, or the defaults).
nonisolated(unsafe) public private(set) static var types: [LibraryType] = LibraryTypes.defaults
/// folder name assigned type id. Empty = identity (folder name *is* its type).
nonisolated(unsafe) static var folderTypes: [String: String] = [:]
/// Raw folder names whose resolved type is adult the path/category gate.
nonisolated(unsafe) static var adultFolders: Set<String> = ["porn"]
/// id type, for O(1) label/adult lookups. Rebuilt on every `update`.
nonisolated(unsafe) private static var byID: [String: LibraryType] =
Dictionary(LibraryTypes.defaults.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
/// Resolve a raw folder name to its configured type id (identity when unmapped).
/// The grouping/display key never persisted as a folder name.
public static func type(of category: String) -> String { folderTypes[category] ?? category }
/// Display label for a type id (capitalized fallback for unknown/custom ids).
public static func label(_ typeID: String) -> String { byID[typeID]?.label ?? typeID.capitalized }
/// True when this *type id* is adult.
public static func isAdultType(_ typeID: String) -> Bool { byID[typeID]?.adult ?? false }
/// Display order index; unknown types sort after the catalog, alphabetically.
public static func order(_ typeID: String) -> Int { types.firstIndex { $0.id == typeID } ?? types.count }
/// True when a raw folder/category resolves to an adult type.
public static func isAdult(category: String) -> Bool { adultFolders.contains(category) }
/// True when `path` lives under an adult folder an exact `/<folder>/` path
/// component match (never a filename substring). With the default config this
/// is byte-identical to the old `path.contains("/porn/")`. Used where there's
/// no category to test (ContinueItem / QueueItem).
public static func isAdult(path: String) -> Bool { adultFolders.contains { path.contains("/\($0)/") } }
/// Recompute the cache from settings. Called only by `SettingsStore`. An empty
/// `types` list falls back to the defaults so the app is never type-less.
static func update(folderTypes ft: [String: String], types t: [LibraryType]) {
types = t.isEmpty ? LibraryTypes.defaults : t
byID = Dictionary(types.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
folderTypes = ft
// A folder is adult iff its resolved type is adult. Candidates are every
// explicitly-mapped folder plus the adult type names themselves (so a
// literal adult-named folder is adult unless it's been re-typed away).
let adultIDs = Set(types.filter(\.adult).map(\.id))
let candidates = Set(ft.keys).union(adultIDs)
adultFolders = candidates.filter { adultIDs.contains(ft[$0] ?? $0) }
}
}