Restore channel list decoding for string/custom IDs, URL-encode play paths, parse DRM/heartbeat, and ship Events tab (0.5.0/12) with best-effort VLC ClearKey.
421 lines
11 KiB
Swift
421 lines
11 KiB
Swift
import Foundation
|
||
import CoreGraphics
|
||
|
||
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? }
|
||
let iptv: Iptv?
|
||
let profile: ViewerProfile?
|
||
let deviceId: String?
|
||
|
||
var iptvEnabled: Bool { iptv?.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 NextEpisode: Decodable, Identifiable, Hashable {
|
||
let id: String
|
||
let seriesId: String
|
||
let seasonNumber: Int?
|
||
let episodeNumber: Int?
|
||
let title: String?
|
||
let seriesTitle: String?
|
||
let subtitle: String?
|
||
let mediaFileId: String
|
||
let posterUrl: String?
|
||
}
|
||
|
||
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?
|
||
let nextTitle: String?
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case streamId, name, logoUrl, number, categoryId, nowTitle, nextTitle
|
||
}
|
||
|
||
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)
|
||
nextTitle = try c.decodeIfPresent(String.self, forKey: .nextTitle)
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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)
|
||
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)
|
||
}
|
||
}
|
||
|
||
struct ClearKeyEntry: Decodable, Hashable {
|
||
let kid: String
|
||
let key: String
|
||
}
|
||
|
||
struct DrmInfo: Decodable, Hashable {
|
||
let type: String
|
||
let keys: [ClearKeyEntry]
|
||
}
|
||
|
||
struct EpgProgram: Decodable, Hashable {
|
||
let title: String
|
||
let start: String?
|
||
let end: String?
|
||
|
||
private static func parseISO(_ raw: String?) -> Date? {
|
||
guard let raw, !raw.isEmpty else { return nil }
|
||
if let d = ISO8601DateFormatter.vmcFractional.date(from: raw) { return d }
|
||
return ISO8601DateFormatter.vmcBasic.date(from: raw)
|
||
}
|
||
|
||
var progress: CGFloat {
|
||
guard let s = Self.parseISO(start), let e = Self.parseISO(end) else { return 0 }
|
||
let total = e.timeIntervalSince(s)
|
||
guard total > 0 else { return 0 }
|
||
return CGFloat(min(1, max(0, Date().timeIntervalSince(s) / total)))
|
||
}
|
||
|
||
var timeRangeLabel: String? {
|
||
guard let s = Self.parseISO(start), let e = Self.parseISO(end) else { return nil }
|
||
let f = DateFormatter()
|
||
f.locale = Locale(identifier: "nl_NL")
|
||
f.dateFormat = "HH:mm"
|
||
return "\(f.string(from: s)) – \(f.string(from: e))"
|
||
}
|
||
}
|
||
|
||
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(streamId: String, channelName: String, logoUrl: String?, now: EpgProgram?, next: EpgProgram?) {
|
||
self.streamId = streamId
|
||
self.channelName = channelName
|
||
self.logoUrl = logoUrl
|
||
self.now = now
|
||
self.next = 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)
|
||
}
|
||
}
|
||
|
||
/// 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")
|
||
}
|
||
|
||
extension ISO8601DateFormatter {
|
||
static let vmcFractional: ISO8601DateFormatter = {
|
||
let f = ISO8601DateFormatter()
|
||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||
return f
|
||
}()
|
||
|
||
static let vmcBasic: ISO8601DateFormatter = {
|
||
let f = ISO8601DateFormatter()
|
||
f.formatOptions = [.withInternetDateTime]
|
||
return f
|
||
}()
|
||
}
|
||
|
||
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?
|
||
}
|
||
|
||
struct PlayerTrack: Identifiable, Hashable {
|
||
let id: Int32
|
||
let name: String
|
||
}
|