140 lines
8.3 KiB
Swift
140 lines
8.3 KiB
Swift
import Foundation
|
|
|
|
/// User-facing app preferences that aren't host/franchise specific. Persisted to
|
|
/// `~/.local/state/tv-anarchy/settings.json`. Kept deliberately small — one file,
|
|
/// tolerant decode, sensible defaults — so adding a preference is one field here
|
|
/// plus its wiring, no migration.
|
|
public struct AppSettings: Codable, Sendable, Equatable {
|
|
/// Master runtime switch for the whole adult feature (the runtime peer of the
|
|
/// `ENABLE_ADULT` compile flag). False by default — the app ships with adult
|
|
/// content concealed. Flipped by the discreet "hidden" eye icon in the
|
|
/// sidebar; when true, the Adult tab appears and the porn collections become
|
|
/// available in the queue popover. The two settings below only take effect
|
|
/// while this is on.
|
|
public var pornFeature: Bool
|
|
/// When false (the default), the Home screen hides everything from the `porn`
|
|
/// media dir — its category rails, plus any adult item in Continue Watching or
|
|
/// Recently Added. Independent of the Library tab's own porn browse toggle, so
|
|
/// you can deliberately browse adult content in Library without it leaking onto
|
|
/// the landing screen.
|
|
public var surfaceAdultOnHome: Bool
|
|
/// When true, the Adult tab is a full adult-only Home — Continue Watching and
|
|
/// Recently Added rails (adult-only) above the collections grid — rather than
|
|
/// just the collections browser. Independent of `surfaceAdultOnHome`, which
|
|
/// governs the *main* Home.
|
|
public var switchToAdultOnlyHome: Bool
|
|
/// When true, hovering a poster plays a short muted preview clip (built on
|
|
/// black, seeked past the intro, cached). Off by default — it triggers ffmpeg
|
|
/// work on black, so it's opt-in.
|
|
public var hoverPreviews: Bool
|
|
/// When true (the default), the Mac's system transport — media keys, Control
|
|
/// Center, lock screen, AirPods — drives whatever TVAnarchy is playing, and
|
|
/// Now Playing reflects it. Turn off to let the media keys fall through to
|
|
/// other apps. (Part B media-control forwarding; see NowPlayingController.)
|
|
public var forwardMediaKeys: Bool
|
|
/// Offline cache (cellphone/laptop devices): pull the next `offlineEpisodes`
|
|
/// unwatched episodes of the most-recent `offlineShows` shows to local disk.
|
|
/// Off by default — it rsyncs from black. `offlineFromContinueWatching` picks
|
|
/// the source rail (continue-watching vs recently-added).
|
|
public var offlineEpisodes: Int
|
|
public var offlineShows: Int
|
|
public var offlineFromContinueWatching: Bool
|
|
/// When true (default), dynamically combine split/duplicate library entries of
|
|
/// one show into a single show via the cheap clusterer + the local-LLM reasoner
|
|
/// (the Dandadan case). Decisions are cached on disk, so it runs once per cluster.
|
|
public var combineSplitShows: Bool
|
|
/// When true, use the local MLX LLM (a separate download) to reason over
|
|
/// ambiguous show clusters. Off by default — the shipped `DeterministicGrouper`
|
|
/// (year + episode/season structure, zero MB) handles the real cases; the LLM is
|
|
/// an optional enhancement for the rare same-year ambiguous tail.
|
|
public var useLLMGrouper: Bool
|
|
/// Notify (Notification Center + in-app banner) when a download is ready to watch
|
|
/// or gets stuck. On by default.
|
|
public var notifyDownloads: Bool
|
|
/// Library folder → type id (`tv`/`anime`/`movies`/`porn`/…). Empty = identity
|
|
/// (a folder's type is its own name, the historical convention). Overriding
|
|
/// lets a differently-named folder be classified, and — because `porn` is the
|
|
/// adult type — lets any folder be marked adult without renaming it on disk.
|
|
public var folderTypes: [String: String]
|
|
/// The configurable type catalog — the editable, expandable default the folder
|
|
/// picker draws from. Seeded with `LibraryTypes.defaults`; the user can rename,
|
|
/// add, remove, or flag types adult.
|
|
public var libraryTypes: [LibraryType]
|
|
|
|
public init(pornFeature: Bool = false, surfaceAdultOnHome: Bool = false,
|
|
switchToAdultOnlyHome: Bool = false, hoverPreviews: Bool = false,
|
|
forwardMediaKeys: Bool = true, offlineEpisodes: Int = 3,
|
|
offlineShows: Int = 5, offlineFromContinueWatching: Bool = true,
|
|
combineSplitShows: Bool = true, useLLMGrouper: Bool = false,
|
|
notifyDownloads: Bool = true, folderTypes: [String: String] = [:],
|
|
libraryTypes: [LibraryType] = LibraryTypes.defaults) {
|
|
self.pornFeature = pornFeature
|
|
self.surfaceAdultOnHome = surfaceAdultOnHome
|
|
self.switchToAdultOnlyHome = switchToAdultOnlyHome
|
|
self.hoverPreviews = hoverPreviews
|
|
self.forwardMediaKeys = forwardMediaKeys
|
|
self.offlineEpisodes = offlineEpisodes
|
|
self.offlineShows = offlineShows
|
|
self.offlineFromContinueWatching = offlineFromContinueWatching
|
|
self.combineSplitShows = combineSplitShows
|
|
self.useLLMGrouper = useLLMGrouper
|
|
self.notifyDownloads = notifyDownloads
|
|
self.folderTypes = folderTypes
|
|
self.libraryTypes = libraryTypes
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
pornFeature = try c.decodeIfPresent(Bool.self, forKey: .pornFeature) ?? false
|
|
surfaceAdultOnHome = try c.decodeIfPresent(Bool.self, forKey: .surfaceAdultOnHome) ?? false
|
|
switchToAdultOnlyHome = try c.decodeIfPresent(Bool.self, forKey: .switchToAdultOnlyHome) ?? false
|
|
hoverPreviews = try c.decodeIfPresent(Bool.self, forKey: .hoverPreviews) ?? false
|
|
forwardMediaKeys = try c.decodeIfPresent(Bool.self, forKey: .forwardMediaKeys) ?? true
|
|
offlineEpisodes = try c.decodeIfPresent(Int.self, forKey: .offlineEpisodes) ?? 3
|
|
offlineShows = try c.decodeIfPresent(Int.self, forKey: .offlineShows) ?? 5
|
|
offlineFromContinueWatching = try c.decodeIfPresent(Bool.self, forKey: .offlineFromContinueWatching) ?? true
|
|
combineSplitShows = try c.decodeIfPresent(Bool.self, forKey: .combineSplitShows) ?? true
|
|
useLLMGrouper = try c.decodeIfPresent(Bool.self, forKey: .useLLMGrouper) ?? false
|
|
notifyDownloads = try c.decodeIfPresent(Bool.self, forKey: .notifyDownloads) ?? true
|
|
folderTypes = try c.decodeIfPresent([String: String].self, forKey: .folderTypes) ?? [:]
|
|
// Absent/empty → the default catalog, so an old settings file (or one that
|
|
// never edited types) gets the full set rather than an empty picker.
|
|
let decoded = try c.decodeIfPresent([LibraryType].self, forKey: .libraryTypes) ?? []
|
|
libraryTypes = decoded.isEmpty ? LibraryTypes.defaults : decoded
|
|
}
|
|
}
|
|
|
|
public enum SettingsStore {
|
|
/// State directory, overridable via `TV_ANARCHY_STATE_DIR` (mirrors
|
|
/// PlaylistController's stores) so tests redirect to a temp dir and never touch
|
|
/// the user's real settings.
|
|
private static var url: URL {
|
|
let base: URL
|
|
if let dir = ProcessInfo.processInfo.environment["TV_ANARCHY_STATE_DIR"], !dir.isEmpty {
|
|
base = URL(fileURLWithPath: dir, isDirectory: true)
|
|
} else {
|
|
base = FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent(".local/state/tv-anarchy")
|
|
}
|
|
return base.appendingPathComponent("settings.json")
|
|
}
|
|
|
|
public static func load() -> AppSettings {
|
|
guard let d = try? Data(contentsOf: url),
|
|
let s = try? JSONDecoder().decode(AppSettings.self, from: d) else {
|
|
let s = AppSettings(); LibraryConfig.update(folderTypes: s.folderTypes, types: s.libraryTypes); return s
|
|
}
|
|
LibraryConfig.update(folderTypes: s.folderTypes, types: s.libraryTypes) // keep the in-memory config in sync
|
|
return s
|
|
}
|
|
|
|
public static func save(_ s: AppSettings) {
|
|
// Update the cache from the SAME struct being persisted — the single
|
|
// chokepoint, so the folder→type config can never desync from disk.
|
|
LibraryConfig.update(folderTypes: s.folderTypes, types: s.libraryTypes)
|
|
guard let d = try? JSONEncoder().encode(s) else { return }
|
|
try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
try? d.write(to: url, options: .atomic)
|
|
}
|
|
}
|