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>
307 lines
10 KiB
Swift
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)
|
|
}
|
|
}
|