Files
simvision/KSPlayer-main/Demo/SwiftUI/Shared/MovieModel.swift
Michael Simard 872354b834 Initial commit: SimVision tvOS streaming app
Features:
- VOD library with movie grouping and version detection
- TV show library with season/episode organization
- TMDB integration for trending shows and recently aired episodes
- Recent releases section with TMDB release date sorting
- Watch history tracking with continue watching
- Playlist caching (12-hour TTL) for offline support
- M3U playlist parsing with XStream API support
- Authentication with credential storage

Technical:
- SwiftUI for tvOS
- Actor-based services for thread safety
- Persistent caching for playlists, TMDB data, and watch history
- KSPlayer integration for video playback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:12:08 -06:00

307 lines
10 KiB
Swift

//
// MovieModel.swift
// TracyPlayer
//
// Created by kintan on 2023/2/2.
//
import CoreData
import CoreMedia
import Foundation
import KSPlayer
#if canImport(UIKit)
import UIKit
#endif
class MEOptions: KSOptions {
#if os(iOS)
static var isUseDisplayLayer = true
#else
static var isUseDisplayLayer = false
#endif
override init() {
super.init()
}
override func process(assetTrack: some MediaPlayerTrack) {
super.process(assetTrack: assetTrack)
}
override func isUseDisplayLayer() -> Bool {
MEOptions.isUseDisplayLayer && display == .plane
}
}
@objc(MovieModel)
public class MovieModel: NSManagedObject, Codable {
enum CodingKeys: String, CodingKey {
case name, url, httpReferer, httpUserAgent
}
public required convenience init(from decoder: Decoder) throws {
self.init(context: PersistenceController.shared.viewContext)
let values = try decoder.container(keyedBy: CodingKeys.self)
url = try values.decode(URL.self, forKey: .url)
name = try values.decode(String.self, forKey: .name)
httpReferer = try values.decode(String.self, forKey: .httpReferer)
httpUserAgent = try values.decode(String.self, forKey: .httpUserAgent)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(url, forKey: .url)
try container.encode(name, forKey: .name)
try container.encode(httpReferer, forKey: .httpReferer)
try container.encode(httpUserAgent, forKey: .httpUserAgent)
}
}
extension MovieModel {
convenience init(context: NSManagedObjectContext = PersistenceController.shared.viewContext, url: URL) {
self.init(context: context, url: url, name: url.lastPathComponent)
}
convenience init(context: NSManagedObjectContext = PersistenceController.shared.viewContext, url: URL, name: String, extinf: [String: String]? = nil) {
self.init(context: context)
self.name = name
self.url = url
setExt(info: extinf)
}
func setExt(info: [String: String]? = nil) {
let logo = info?["tvg-logo"].flatMap { URL(string: $0) }
if logo != self.logo {
self.logo = logo
}
let language = info?["tvg-language"]
if language != self.language {
self.language = language
}
let country = info?["tvg-country"]
if country != self.country {
self.country = country
}
let group = info?["group-title"]
if group != self.group {
self.group = group
}
let tvgID = info?["tvg-id"]
if tvgID != self.tvgID {
self.tvgID = tvgID
}
let httpReferer = info?["http-referrer"] ?? info?["http-referer"]
if httpReferer != self.httpReferer {
self.httpReferer = httpReferer
}
let httpUserAgent = info?["http-user-agent"]
if httpUserAgent != self.httpUserAgent {
self.httpUserAgent = httpUserAgent
}
}
}
extension M3UModel {
convenience init(context: NSManagedObjectContext = PersistenceController.shared.viewContext, url: URL, name: String? = nil) {
self.init(context: context)
self.name = name ?? url.lastPathComponent
m3uURL = url
}
func delete() {
guard let context = managedObjectContext, let m3uURL else {
return
}
context.delete(self)
let request = M3UModel.fetchRequest()
request.predicate = NSPredicate(format: "m3uURL == %@", m3uURL.description)
do {
if let array = try? context.fetch(request), array.isEmpty {
let movieRequest = NSFetchRequest<MovieModel>(entityName: "MovieModel")
movieRequest.predicate = NSPredicate(format: "m3uURL == %@", m3uURL.description)
for model in try context.fetch(movieRequest) {
context.delete(model)
}
// let deleteRequest = NSBatchDeleteRequest(fetchRequest: movieRequest)
// _ = try? context.execute(deleteRequest)
}
try context.save()
} catch {
KSLog(level: .error, error.localizedDescription)
}
}
func getMovieModels() async -> [MovieModel] {
let viewContext = managedObjectContext ?? PersistenceController.shared.viewContext
let m3uURL = await viewContext.perform {
self.m3uURL
}
guard let m3uURL else {
return []
}
return await viewContext.perform {
let request = NSFetchRequest<MovieModel>(entityName: "MovieModel")
request.predicate = NSPredicate(format: "m3uURL == %@", m3uURL.description)
request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
return (try? viewContext.fetch(request)) ?? []
}
}
func parsePlaylist() async throws -> [MovieModel] {
let array = await getMovieModels()
let viewContext = managedObjectContext ?? PersistenceController.shared.viewContext
let m3uURL = await viewContext.perform {
self.m3uURL
}
guard let m3uURL else {
return []
}
let result = try await m3uURL.parsePlaylist()
guard result.count > 0 else {
delete()
return []
}
return await viewContext.perform {
var dic = [URL?: MovieModel]()
for model in array {
if let oldModel = dic[model.url] {
if oldModel.playmodel == nil {
viewContext.delete(oldModel)
dic[model.url] = model
} else {
viewContext.delete(model)
}
} else {
dic[model.url] = model
}
}
let models = result.map { name, url, extinf -> MovieModel in
if let model = dic[url] {
dic.removeValue(forKey: url)
if name != model.name {
model.name = name
}
model.setExt(info: extinf)
return model
} else {
let model = MovieModel(context: viewContext, url: url, name: name, extinf: extinf)
model.m3uURL = self.m3uURL
return model
}
}
if self.count != Int32(models.count) {
self.count = Int32(models.count)
}
viewContext.perform {
if viewContext.hasChanges {
for model in dic.values {
viewContext.delete(model)
}
try? viewContext.save()
}
}
return models
}
}
}
extension MovieModel {
static var playTimeRequest: NSFetchRequest<MovieModel> {
let request = MovieModel.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(
keyPath: \MovieModel.playmodel?.playTime,
ascending: false
),
]
request.predicate = NSPredicate(format: "playmodel.playTime != nil")
request.fetchLimit = 20
return request
}
public var isFavorite: Bool {
get {
playmodel?.isFavorite ?? false
}
set {
let playmodel = getPlaymodel()
playmodel.isFavorite = newValue
}
}
func getPlaymodel() -> PlayModel {
if let playmodel {
return playmodel
}
let model = PlayModel()
playmodel = model
model.save()
return model
}
}
extension NSManagedObject {
func save() {
guard let context = managedObjectContext else {
return
}
context.perform {
do {
try context.save()
} catch {}
}
}
}
extension PlayModel {
convenience init() {
self.init(context: PersistenceController.shared.viewContext)
}
}
extension KSVideoPlayerView {
init(url: URL) {
let request = NSFetchRequest<MovieModel>(entityName: "MovieModel")
request.predicate = NSPredicate(format: "url == %@", url.description)
let model = (try? PersistenceController.shared.viewContext.fetch(request).first) ?? MovieModel(url: url)
self.init(model: model)
}
init(model: MovieModel) {
let url = model.url!
let options = MEOptions()
#if DEBUG
if url.lastPathComponent == "h264.mp4" {
// options.videoFilters = ["hflip", "vflip"]
// options.hardwareDecode = false
options.startPlayTime = 13
} else if url.lastPathComponent == "vr.mp4" {
options.display = .vr
} else if url.lastPathComponent == "mjpeg.flac" {
// options.videoDisable = true
options.syncDecodeAudio = true
} else if url.lastPathComponent == "subrip.mkv" {
options.asynchronousDecompression = false
options.videoFilters.append("yadif_videotoolbox=mode=\(MEOptions.yadifMode):parity=-1:deint=1")
} else if url.lastPathComponent == "big_buck_bunny.mp4" {
options.startPlayTime = 25
} else if url.lastPathComponent == "bipbopall.m3u8" {
#if os(macOS)
let moviesDirectory = try? FileManager.default.url(for: .moviesDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
options.outputURL = moviesDirectory?.appendingPathComponent("recording.mov")
#endif
}
#endif
options.referer = model.httpReferer
if let httpUserAgent = model.httpUserAgent {
options.userAgent = httpUserAgent
}
let playmodel = model.getPlaymodel()
playmodel.playTime = Date()
if playmodel.duration > 0, playmodel.current > 0, playmodel.duration > playmodel.current + 120 {
options.startPlayTime = TimeInterval(playmodel.current)
}
playmodel.save()
model.save()
self.init(url: url, options: options, title: model.name)
}
}