550 lines
16 KiB
Swift
550 lines
16 KiB
Swift
import Foundation
|
|
|
|
struct DeviceCodeResponse: Decodable {
|
|
let code: String
|
|
let expiresAt: String?
|
|
let pollIntervalSeconds: Int?
|
|
let linkUrl: String?
|
|
let qrPayload: String?
|
|
}
|
|
|
|
enum PollResult {
|
|
case pending
|
|
case expired
|
|
case alreadyLinked
|
|
case linked(refreshToken: String, viewerName: String)
|
|
}
|
|
|
|
struct HomePayload: Decodable {
|
|
let continueWatching: [MediaItem]
|
|
let spotlight: [MediaItem]?
|
|
let shelves: [ShelfRow]
|
|
let favorites: [MediaItem]?
|
|
}
|
|
|
|
struct ShelfRow: Decodable, Identifiable {
|
|
let id: String
|
|
let title: String
|
|
let name: String?
|
|
let kind: String?
|
|
let slug: String?
|
|
let items: [MediaItem]
|
|
}
|
|
|
|
struct MediaItem: Codable, Identifiable, Hashable {
|
|
let type: String
|
|
let id: String
|
|
let title: String
|
|
let subtitle: String?
|
|
let year: Int?
|
|
let posterUrl: String?
|
|
let backdropUrl: String?
|
|
let logoUrl: String?
|
|
let mediaFileId: String?
|
|
let seriesId: String?
|
|
let positionMs: Int64?
|
|
let durationMs: Int64?
|
|
let progressId: String?
|
|
let badge: String?
|
|
}
|
|
|
|
struct PlayResult: Decodable {
|
|
let streamUrl: String
|
|
let sessionId: String?
|
|
let mediaFileId: String?
|
|
let defaultSubtitle: DefaultSubtitle?
|
|
}
|
|
|
|
struct DefaultSubtitle: Decodable {
|
|
let fileId: Int?
|
|
let source: String?
|
|
let language: String?
|
|
let fileName: String?
|
|
let format: String?
|
|
let contentBase64: String
|
|
let label: String?
|
|
}
|
|
|
|
struct ViewerProfile: Decodable, Identifiable, Hashable {
|
|
let id: String
|
|
let name: String
|
|
let avatarId: String?
|
|
let isOwner: Bool?
|
|
let isKids: Bool?
|
|
let hasPin: Bool?
|
|
let showAvatar: Bool?
|
|
let hasCustomAvatar: Bool?
|
|
let avatarUrl: String?
|
|
|
|
var showsAvatar: Bool { showAvatar ?? true }
|
|
var hasCustomPhoto: Bool { hasCustomAvatar == true }
|
|
}
|
|
|
|
struct ProfilesPayload: Decodable {
|
|
let profiles: [ViewerProfile]
|
|
let activeProfileId: String?
|
|
let canManage: Bool?
|
|
}
|
|
|
|
struct ClientMe: Decodable {
|
|
struct Iptv: Decodable { let enabled: Bool? }
|
|
struct Events: Decodable { let enabled: Bool? }
|
|
let iptv: Iptv?
|
|
let events: Events?
|
|
let profile: ViewerProfile?
|
|
let deviceId: String?
|
|
|
|
var iptvEnabled: Bool { iptv?.enabled == true }
|
|
var eventsEnabled: Bool { events?.enabled == true }
|
|
}
|
|
|
|
struct LinkedDevice: Decodable, Identifiable, Hashable {
|
|
let id: String
|
|
let name: String
|
|
let platform: String?
|
|
let lastSeenAt: String?
|
|
let createdAt: String?
|
|
let current: Bool?
|
|
}
|
|
|
|
struct DevicesPayload: Decodable {
|
|
let devices: [LinkedDevice]
|
|
}
|
|
|
|
struct ApproveDeviceResult: Decodable {
|
|
let ok: Bool?
|
|
let deviceName: String?
|
|
let platform: String?
|
|
}
|
|
|
|
struct MediaFileInfo: Decodable, Identifiable, Hashable {
|
|
let id: String
|
|
let label: String?
|
|
let resolution: String?
|
|
let videoCodec: String?
|
|
let audioCodec: String?
|
|
let hdrType: String?
|
|
let nodeOnline: Bool?
|
|
let nodeName: String?
|
|
}
|
|
|
|
struct MovieDetail: Decodable {
|
|
let id: String
|
|
let title: String
|
|
let overview: String?
|
|
let year: Int?
|
|
let posterUrl: String?
|
|
let backdropUrl: String?
|
|
let logoUrl: String?
|
|
let runtime: Int?
|
|
let genres: [String]?
|
|
let rating: String?
|
|
let cast: [String]?
|
|
let director: [String]?
|
|
let mediaFiles: [MediaFileInfo]?
|
|
|
|
var preferredMediaFileId: String? {
|
|
mediaFiles?.first(where: { $0.nodeOnline != false })?.id ?? mediaFiles?.first?.id
|
|
}
|
|
}
|
|
|
|
struct SeriesDetail: Decodable {
|
|
let id: String
|
|
let title: String
|
|
let overview: String?
|
|
let year: Int?
|
|
let posterUrl: String?
|
|
let backdropUrl: String?
|
|
let logoUrl: String?
|
|
let genres: [String]?
|
|
let rating: String?
|
|
let cast: [String]?
|
|
let director: [String]?
|
|
let seasons: [Season]?
|
|
}
|
|
|
|
struct Season: Decodable, Identifiable {
|
|
var id: Int { seasonNumber }
|
|
let seasonNumber: Int
|
|
let episodes: [Episode]?
|
|
}
|
|
|
|
struct Episode: Decodable, Identifiable {
|
|
let id: String
|
|
let title: String?
|
|
let episodeNumber: Int?
|
|
let seasonNumber: Int?
|
|
let overview: String?
|
|
let stillUrl: String?
|
|
let mediaFileId: String?
|
|
let available: Bool?
|
|
let runtime: Int?
|
|
let completed: Bool?
|
|
let positionMs: Int64?
|
|
let durationMs: Int64?
|
|
|
|
var progressFraction: CGFloat {
|
|
if completed == true { return 1 }
|
|
guard let dur = durationMs, dur > 0, let pos = positionMs else { return 0 }
|
|
return CGFloat(pos) / CGFloat(dur)
|
|
}
|
|
|
|
var resumeMs: Int64 {
|
|
if completed == true { return 0 }
|
|
return max(0, positionMs ?? 0)
|
|
}
|
|
}
|
|
|
|
struct SearchPayload: Decodable {
|
|
let results: [MediaItem]
|
|
}
|
|
|
|
struct BrowsePayload: Decodable {
|
|
let id: String?
|
|
let title: String?
|
|
let kind: String?
|
|
let total: Int?
|
|
let offset: Int?
|
|
let items: [MediaItem]
|
|
}
|
|
|
|
struct SubtitleHit: Decodable, Identifiable, Hashable {
|
|
var id: String { "\(source ?? "x")-\(fileId)" }
|
|
let fileId: Int
|
|
let language: String?
|
|
let release: String?
|
|
let fileName: String?
|
|
let downloadCount: Int?
|
|
let hearingImpaired: Bool?
|
|
let source: String?
|
|
let uploader: String?
|
|
}
|
|
|
|
struct SubtitleSearchResult: Decodable {
|
|
let configured: Bool?
|
|
let imdbId: String?
|
|
let title: String?
|
|
let results: [SubtitleHit]
|
|
}
|
|
|
|
struct FetchedSubtitle: Decodable {
|
|
let fileId: Int?
|
|
let source: String?
|
|
let fileName: String?
|
|
let format: String?
|
|
let contentBase64: String
|
|
}
|
|
|
|
struct IptvCategory: Decodable, Identifiable, Hashable {
|
|
let id: String
|
|
let name: String
|
|
let channelCount: Int?
|
|
}
|
|
|
|
struct IptvChannel: Decodable, Identifiable, Hashable {
|
|
var id: String { streamId }
|
|
let streamId: String
|
|
let name: String
|
|
let logoUrl: String?
|
|
let number: Int?
|
|
let categoryId: String?
|
|
let nowTitle: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case streamId, name, logoUrl, number, categoryId, nowTitle
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
streamId = try decodeFlexibleString(c, forKey: .streamId)
|
|
name = try c.decode(String.self, forKey: .name)
|
|
logoUrl = try c.decodeIfPresent(String.self, forKey: .logoUrl)
|
|
number = try c.decodeIfPresent(Int.self, forKey: .number)
|
|
categoryId = try c.decodeIfPresent(String.self, forKey: .categoryId)
|
|
nowTitle = try c.decodeIfPresent(String.self, forKey: .nowTitle)
|
|
}
|
|
}
|
|
|
|
struct ClearKeyEntry: Decodable, Hashable {
|
|
let kid: String
|
|
let key: String
|
|
}
|
|
|
|
struct DrmInfo: Decodable, Hashable {
|
|
let type: String
|
|
let keys: [ClearKeyEntry]
|
|
|
|
enum CodingKeys: String, CodingKey { case type, keys }
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
type = try c.decodeIfPresent(String.self, forKey: .type) ?? "clearkey"
|
|
keys = try c.decodeIfPresent([ClearKeyEntry].self, forKey: .keys) ?? []
|
|
}
|
|
}
|
|
|
|
struct IptvPlayInfo: Decodable {
|
|
let streamId: String
|
|
let name: String
|
|
let logoUrl: String?
|
|
let streamUrl: String
|
|
let format: String?
|
|
let fallbackStreamUrl: String?
|
|
let fallbackFormat: String?
|
|
let drm: DrmInfo?
|
|
let watchSessionId: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case streamId, name, logoUrl, streamUrl, format
|
|
case fallbackStreamUrl, fallbackFormat, drm, watchSessionId
|
|
case eventId
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
if let sid = try? decodeFlexibleString(c, forKey: .streamId) {
|
|
streamId = sid
|
|
} else if let eid = try c.decodeIfPresent(String.self, forKey: .eventId) {
|
|
streamId = eid
|
|
} else {
|
|
streamId = ""
|
|
}
|
|
name = try c.decode(String.self, forKey: .name)
|
|
logoUrl = try c.decodeIfPresent(String.self, forKey: .logoUrl)
|
|
streamUrl = try c.decode(String.self, forKey: .streamUrl)
|
|
format = try c.decodeIfPresent(String.self, forKey: .format)
|
|
fallbackStreamUrl = try c.decodeIfPresent(String.self, forKey: .fallbackStreamUrl)
|
|
fallbackFormat = try c.decodeIfPresent(String.self, forKey: .fallbackFormat)
|
|
drm = try c.decodeIfPresent(DrmInfo.self, forKey: .drm)
|
|
watchSessionId = try c.decodeIfPresent(String.self, forKey: .watchSessionId)
|
|
}
|
|
|
|
var clearKeyEntries: [ClearKeyEntry] {
|
|
guard let drm, drm.type.lowercased() == "clearkey", !drm.keys.isEmpty else { return [] }
|
|
return drm.keys
|
|
}
|
|
|
|
var needsClearKey: Bool { !clearKeyEntries.isEmpty }
|
|
}
|
|
|
|
struct EpgProgram: Decodable, Hashable {
|
|
let title: String
|
|
let start: String?
|
|
let end: String?
|
|
}
|
|
|
|
struct IptvEpg: Decodable, Hashable {
|
|
let streamId: String
|
|
let channelName: String
|
|
let logoUrl: String?
|
|
let now: EpgProgram?
|
|
let next: EpgProgram?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case streamId, channelName, logoUrl, now, next
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
streamId = (try? decodeFlexibleString(c, forKey: .streamId)) ?? ""
|
|
channelName = try c.decode(String.self, forKey: .channelName)
|
|
logoUrl = try c.decodeIfPresent(String.self, forKey: .logoUrl)
|
|
now = try c.decodeIfPresent(EpgProgram.self, forKey: .now)
|
|
next = try c.decodeIfPresent(EpgProgram.self, forKey: .next)
|
|
}
|
|
}
|
|
|
|
struct ScheduleEventsPayload: Decodable {
|
|
let generatedAt: String?
|
|
let events: [ScheduleEvent]
|
|
let providers: [String]?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case generatedAt, events, providers
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
generatedAt = try c.decodeIfPresent(String.self, forKey: .generatedAt)
|
|
events = try c.decodeIfPresent([ScheduleEvent].self, forKey: .events) ?? []
|
|
providers = try c.decodeIfPresent([String].self, forKey: .providers)
|
|
}
|
|
}
|
|
|
|
struct ScheduleEvent: Decodable, Identifiable, Hashable {
|
|
let id: String
|
|
let name: String
|
|
let description: String?
|
|
let provider: String
|
|
let category: String?
|
|
let eventType: String?
|
|
let subtitle: String?
|
|
let channel: String?
|
|
let network: String?
|
|
let isLive: Bool?
|
|
let phase: String?
|
|
let playable: Bool?
|
|
let status: String?
|
|
let start: String
|
|
let stop: String
|
|
let startLocal: String?
|
|
let stopLocal: String?
|
|
let imageLandscape: String?
|
|
let imageBackdrop: String?
|
|
let imagePortrait: String?
|
|
let logoUrl: String?
|
|
let images: [String]?
|
|
let audioLanguages: [String]?
|
|
|
|
var isPlayable: Bool { playable == true }
|
|
var phaseValue: String { (phase ?? "upcoming").lowercased() }
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, name, description, provider, category, eventType, subtitle
|
|
case channel, network, isLive, phase, playable, status
|
|
case start, stop, startLocal, stopLocal
|
|
case imageLandscape, imageBackdrop, imagePortrait, logoUrl, images, audioLanguages
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
id = try c.decode(String.self, forKey: .id)
|
|
name = try c.decode(String.self, forKey: .name)
|
|
description = try c.decodeIfPresent(String.self, forKey: .description)
|
|
provider = try c.decodeIfPresent(String.self, forKey: .provider) ?? "unknown"
|
|
category = try c.decodeIfPresent(String.self, forKey: .category)
|
|
eventType = try c.decodeIfPresent(String.self, forKey: .eventType)
|
|
subtitle = try c.decodeIfPresent(String.self, forKey: .subtitle)
|
|
channel = try c.decodeIfPresent(String.self, forKey: .channel)
|
|
network = try c.decodeIfPresent(String.self, forKey: .network)
|
|
isLive = try c.decodeIfPresent(Bool.self, forKey: .isLive)
|
|
phase = try c.decodeIfPresent(String.self, forKey: .phase)
|
|
playable = try c.decodeIfPresent(Bool.self, forKey: .playable)
|
|
status = try c.decodeIfPresent(String.self, forKey: .status)
|
|
start = try c.decodeIfPresent(String.self, forKey: .start) ?? ""
|
|
stop = try c.decodeIfPresent(String.self, forKey: .stop) ?? ""
|
|
startLocal = try c.decodeIfPresent(String.self, forKey: .startLocal)
|
|
stopLocal = try c.decodeIfPresent(String.self, forKey: .stopLocal)
|
|
imageLandscape = try c.decodeIfPresent(String.self, forKey: .imageLandscape)
|
|
imageBackdrop = try c.decodeIfPresent(String.self, forKey: .imageBackdrop)
|
|
imagePortrait = try c.decodeIfPresent(String.self, forKey: .imagePortrait)
|
|
logoUrl = try c.decodeIfPresent(String.self, forKey: .logoUrl)
|
|
images = try c.decodeIfPresent([String].self, forKey: .images)
|
|
audioLanguages = try c.decodeIfPresent([String].self, forKey: .audioLanguages)
|
|
}
|
|
|
|
init(
|
|
id: String,
|
|
name: String,
|
|
description: String?,
|
|
provider: String,
|
|
category: String?,
|
|
eventType: String?,
|
|
subtitle: String?,
|
|
channel: String?,
|
|
network: String?,
|
|
isLive: Bool?,
|
|
phase: String?,
|
|
playable: Bool?,
|
|
status: String?,
|
|
start: String,
|
|
stop: String,
|
|
startLocal: String?,
|
|
stopLocal: String?,
|
|
imageLandscape: String?,
|
|
imageBackdrop: String?,
|
|
imagePortrait: String?,
|
|
logoUrl: String?,
|
|
images: [String]?,
|
|
audioLanguages: [String]?
|
|
) {
|
|
self.id = id
|
|
self.name = name
|
|
self.description = description
|
|
self.provider = provider
|
|
self.category = category
|
|
self.eventType = eventType
|
|
self.subtitle = subtitle
|
|
self.channel = channel
|
|
self.network = network
|
|
self.isLive = isLive
|
|
self.phase = phase
|
|
self.playable = playable
|
|
self.status = status
|
|
self.start = start
|
|
self.stop = stop
|
|
self.startLocal = startLocal
|
|
self.stopLocal = stopLocal
|
|
self.imageLandscape = imageLandscape
|
|
self.imageBackdrop = imageBackdrop
|
|
self.imagePortrait = imagePortrait
|
|
self.logoUrl = logoUrl
|
|
self.images = images
|
|
self.audioLanguages = audioLanguages
|
|
}
|
|
|
|
func withImages(
|
|
landscape: String? = nil,
|
|
backdrop: String? = nil,
|
|
portrait: String? = nil,
|
|
logo: String? = nil,
|
|
imgs: [String]? = nil,
|
|
channel: String? = nil,
|
|
network: String? = nil
|
|
) -> ScheduleEvent {
|
|
ScheduleEvent(
|
|
id: id,
|
|
name: name,
|
|
description: description,
|
|
provider: provider,
|
|
category: category,
|
|
eventType: eventType,
|
|
subtitle: subtitle,
|
|
channel: channel ?? self.channel,
|
|
network: network ?? self.network,
|
|
isLive: isLive,
|
|
phase: phase,
|
|
playable: playable,
|
|
status: status,
|
|
start: start,
|
|
stop: stop,
|
|
startLocal: startLocal,
|
|
stopLocal: stopLocal,
|
|
imageLandscape: landscape ?? imageLandscape,
|
|
imageBackdrop: backdrop ?? imageBackdrop,
|
|
imagePortrait: portrait ?? imagePortrait,
|
|
logoUrl: logo ?? logoUrl,
|
|
images: imgs ?? images,
|
|
audioLanguages: audioLanguages
|
|
)
|
|
}
|
|
}
|
|
|
|
/// API streamId kan String (`"123"` / `"custom:uuid"`) of legacy Int zijn.
|
|
func decodeFlexibleString<K: CodingKey>(_ c: KeyedDecodingContainer<K>, forKey key: K) throws -> String {
|
|
if let s = try? c.decode(String.self, forKey: key) { return s }
|
|
if let i = try? c.decode(Int.self, forKey: key) { return String(i) }
|
|
if let d = try? c.decode(Double.self, forKey: key) { return String(Int(d)) }
|
|
throw DecodingError.dataCorruptedError(forKey: key, in: c, debugDescription: "Verwacht string of int id")
|
|
}
|
|
|
|
struct ShelfPref: Codable, Identifiable, Hashable {
|
|
var id: String { shelfId }
|
|
let shelfId: String
|
|
let name: String
|
|
let slug: String?
|
|
let kind: String?
|
|
var visible: Bool
|
|
var sortOrder: Int
|
|
}
|
|
|
|
struct FavoriteToggleResult: Decodable {
|
|
let favorited: Bool
|
|
let items: [MediaItem]?
|
|
}
|
|
|
|
struct ApiErrorBody: Decodable {
|
|
struct ErrorObj: Decodable { let message: String? }
|
|
let error: ErrorObj?
|
|
}
|
|
|