77 lines
3.8 KiB
Swift
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)
|
|
}
|
|
}
|