tv-anarchy/Sources/TVAnarchyCore/SmartPlaylist.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

77 lines
3.8 KiB
Swift

import Foundation
/// A saved, rule-based playlist re-evaluated against the library each time it's
/// played (so "Recently added anime, unwatched, 20" always reflects the current
/// library). Library-side only porn has its own collections + freshness, so
/// these rules never include it. Persisted via `SmartPlaylistStore`.
public struct SmartPlaylist: Codable, Sendable, Identifiable, Equatable {
public var id: String
public var name: String
/// nil = all non-porn categories; otherwise a single category.
public var category: String?
public var shuffle: Bool
/// Drop shows that already have a saved resume position (best-effort "new to me").
public var unwatchedOnly: Bool
public var limit: Int
public init(id: String = UUID().uuidString, name: String, category: String? = nil,
shuffle: Bool = true, unwatchedOnly: Bool = false, limit: Int = 30) {
self.id = id; self.name = name; self.category = category
self.shuffle = shuffle; self.unwatchedOnly = unwatchedOnly; self.limit = limit
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString
name = try c.decode(String.self, forKey: .name)
category = try c.decodeIfPresent(String.self, forKey: .category)
shuffle = try c.decodeIfPresent(Bool.self, forKey: .shuffle) ?? true
unwatchedOnly = try c.decodeIfPresent(Bool.self, forKey: .unwatchedOnly) ?? false
limit = try c.decodeIfPresent(Int.self, forKey: .limit) ?? 30
}
/// A human one-liner for the row subtitle, e.g. "anime · unwatched · shuffle · 20".
public var summary: String {
var parts = [category?.capitalized ?? "All"]
if unwatchedOnly { parts.append("unwatched") }
parts.append(shuffle ? "shuffle" : "newest")
parts.append("\(limit)")
return parts.joined(separator: " · ")
}
/// Pure resolver: the shows this rule selects from a library snapshot. Porn is
/// always excluded. `watchedPaths` are black-side paths with a saved position;
/// pass `[]` to ignore the unwatched filter. (Order is shuffled when `shuffle`,
/// so only count/membership are deterministic then newest-first otherwise.)
public func resolve(from shows: [CachedShow], watchedPaths: Set<String>) -> [CachedShow] {
var pool = shows.filter { !LibraryConfig.isAdult(category: $0.category) }
if let category { pool = pool.filter { LibraryConfig.type(of: $0.category) == category } }
if unwatchedOnly {
pool = pool.filter { show in
guard let p = show.episodes.first?.path else { return true }
return !watchedPaths.contains(MediaPaths.toRemote(p))
}
}
pool = shuffle ? pool.shuffled()
: pool.sorted { ($0.addedAt ?? .distantPast) > ($1.addedAt ?? .distantPast) }
return Array(pool.prefix(max(1, limit)))
}
}
public enum SmartPlaylistStore {
private static var url: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".local/state/tv-anarchy/smart-playlists.json")
}
public static func load() -> [SmartPlaylist] {
guard let d = try? Data(contentsOf: url),
let p = try? JSONDecoder().decode([SmartPlaylist].self, from: d) else { return [] }
return p
}
public static func save(_ list: [SmartPlaylist]) {
guard let d = try? JSONEncoder().encode(list) else { return }
try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try? d.write(to: url, options: .atomic)
}
}