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 folder→type /// 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 = ["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 `//` 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) } } }