100 lines
5.7 KiB
Swift
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 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<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) }
|
|
}
|
|
}
|