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]? let positionMs: Int64? let durationMs: Int64? let completed: Bool? var preferredMediaFileId: String? { mediaFiles?.first(where: { $0.nodeOnline != false })?.id ?? mediaFiles?.first?.id } var resumeMs: Int64 { if completed == true { return 0 } return max(0, positionMs ?? 0) } } 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? let live: Bool? enum CodingKeys: String, CodingKey { case streamId, name, logoUrl, streamUrl, format case fallbackStreamUrl, fallbackFormat, drm, watchSessionId case eventId, live } 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) live = try c.decodeIfPresent(Bool.self, forKey: .live) } 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 sportBucket: String? let sportLabel: 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() } var resolvedSportBucket: String { let b = (sportBucket ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return b.isEmpty ? "other" : b } var resolvedSportLabel: String { if let s = sportLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty { return s } return EventSport.label(for: resolvedSportBucket) } 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 sportBucket, sportLabel 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) sportBucket = try c.decodeIfPresent(String.self, forKey: .sportBucket) sportLabel = try c.decodeIfPresent(String.self, forKey: .sportLabel) 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?, sportBucket: String? = nil, sportLabel: String? = nil, 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.sportBucket = sportBucket self.sportLabel = sportLabel 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, sportBucket: sportBucket, sportLabel: sportLabel, imageLandscape: landscape ?? imageLandscape, imageBackdrop: backdrop ?? imageBackdrop, imagePortrait: portrait ?? imagePortrait, logoUrl: logo ?? logoUrl, images: imgs ?? images, audioLanguages: audioLanguages ) } } enum EventSport { static let order = [ "football", "racing", "tennis", "golf", "darts", "snooker", "basketball", "waterpolo", "sailing", "hockey", "cycling", "other", ] static func label(for bucket: String) -> String { switch bucket.lowercased() { case "football": return "Voetbal" case "racing": return "Racing" case "tennis": return "Tennis" case "golf": return "Golf" case "darts": return "Darts" case "snooker": return "Snooker" case "basketball": return "Basketbal" case "waterpolo": return "Waterpolo" case "sailing": return "Zeilen" case "hockey": return "Hockey" case "cycling": return "Wielrennen" default: return "Overig" } } } /// API streamId kan String (`"123"` / `"custom:uuid"`) of legacy Int zijn. func decodeFlexibleString(_ c: KeyedDecodingContainer, 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? } // MARK: - Event hubs (F1 / Viaplay) struct EventHub: Decodable, Identifiable, Hashable { let id: String let label: String let subtitle: String? } struct F1Meeting: Decodable, Identifiable, Hashable { var id: String { meetingKey } let meetingKey: String let title: String let meetingNumber: Int? let location: String? let startMs: Double? let endMs: Double? let imageUrl: String? enum CodingKeys: String, CodingKey { case meetingKey, title, meetingNumber, location, startMs, endMs, imageUrl } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) meetingKey = try decodeFlexibleString(c, forKey: .meetingKey) title = try c.decodeIfPresent(String.self, forKey: .title) ?? "" meetingNumber = try? c.decodeIfPresent(Int.self, forKey: .meetingNumber) location = try c.decodeIfPresent(String.self, forKey: .location) startMs = try? c.decodeIfPresent(Double.self, forKey: .startMs) endMs = try? c.decodeIfPresent(Double.self, forKey: .endMs) imageUrl = try c.decodeIfPresent(String.self, forKey: .imageUrl) } } struct F1ReplayGroup: Decodable, Identifiable, Hashable { var id: String { meetingKey } let meetingKey: String let title: String let location: String? let meetingNumber: Int? let imageUrl: String? let latestStart: String? let sessions: [ScheduleEvent] enum CodingKeys: String, CodingKey { case meetingKey, title, location, meetingNumber, imageUrl, latestStart, sessions } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) meetingKey = try decodeFlexibleString(c, forKey: .meetingKey) title = try c.decodeIfPresent(String.self, forKey: .title) ?? "" location = try c.decodeIfPresent(String.self, forKey: .location) meetingNumber = try? c.decodeIfPresent(Int.self, forKey: .meetingNumber) imageUrl = try c.decodeIfPresent(String.self, forKey: .imageUrl) latestStart = try c.decodeIfPresent(String.self, forKey: .latestStart) sessions = try c.decodeIfPresent([ScheduleEvent].self, forKey: .sessions) ?? [] } } struct F1HubPayload: Decodable { let live: [ScheduleEvent] let upcoming: [ScheduleEvent] let recentReplays: [ScheduleEvent] let replayGroups: [F1ReplayGroup] let meetings: [F1Meeting] let nextMeeting: F1Meeting? enum CodingKeys: String, CodingKey { case live, upcoming, recentReplays, replayGroups, meetings, nextMeeting } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) live = try c.decodeIfPresent([ScheduleEvent].self, forKey: .live) ?? [] upcoming = try c.decodeIfPresent([ScheduleEvent].self, forKey: .upcoming) ?? [] recentReplays = try c.decodeIfPresent([ScheduleEvent].self, forKey: .recentReplays) ?? [] replayGroups = try c.decodeIfPresent([F1ReplayGroup].self, forKey: .replayGroups) ?? [] meetings = try c.decodeIfPresent([F1Meeting].self, forKey: .meetings) ?? [] nextMeeting = try c.decodeIfPresent(F1Meeting.self, forKey: .nextMeeting) } } struct F1MeetingDetail: Decodable { let meeting: F1Meeting? let sessions: [ScheduleEvent] let replays: [ScheduleEvent] let upcoming: [ScheduleEvent] let live: [ScheduleEvent] enum CodingKeys: String, CodingKey { case meeting, sessions, replays, upcoming, live } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) meeting = try c.decodeIfPresent(F1Meeting.self, forKey: .meeting) sessions = try c.decodeIfPresent([ScheduleEvent].self, forKey: .sessions) ?? [] replays = try c.decodeIfPresent([ScheduleEvent].self, forKey: .replays) ?? [] upcoming = try c.decodeIfPresent([ScheduleEvent].self, forKey: .upcoming) ?? [] live = try c.decodeIfPresent([ScheduleEvent].self, forKey: .live) ?? [] } } struct ViaplaySport: Decodable, Identifiable, Hashable { let id: String let label: String let count: Int? } struct ViaplayHubItem: Decodable, Identifiable, Hashable { let id: String let guid: String let title: String let subtitle: String? let imageUrl: String? let kind: String? let openAs: String? let playable: Bool? let durationLabel: String? let start: String? let badge: String? let pageUrl: String? var opensAsEvent: Bool { (openAs ?? "").lowercased() == "event" } } struct ViaplayHubRail: Decodable, Identifiable, Hashable { let id: String let title: String let items: [ViaplayHubItem] enum CodingKeys: String, CodingKey { case id, title, items } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) id = try c.decode(String.self, forKey: .id) title = try c.decodeIfPresent(String.self, forKey: .title) ?? "" items = (try? c.decodeIfPresent([ViaplayHubItem].self, forKey: .items)) ?? [] } } struct ViaplayHubPayload: Decodable { let live: [ScheduleEvent] let today: [ScheduleEvent] let weekend: [ScheduleEvent] let later: [ScheduleEvent] let catchup: [ScheduleEvent] let sports: [ViaplaySport] let events: [ScheduleEvent] let rails: [ViaplayHubRail] enum CodingKeys: String, CodingKey { case live, today, weekend, later, catchup, sports, events, rails } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) live = try c.decodeIfPresent([ScheduleEvent].self, forKey: .live) ?? [] today = try c.decodeIfPresent([ScheduleEvent].self, forKey: .today) ?? [] weekend = try c.decodeIfPresent([ScheduleEvent].self, forKey: .weekend) ?? [] later = try c.decodeIfPresent([ScheduleEvent].self, forKey: .later) ?? [] catchup = try c.decodeIfPresent([ScheduleEvent].self, forKey: .catchup) ?? [] sports = try c.decodeIfPresent([ViaplaySport].self, forKey: .sports) ?? [] events = try c.decodeIfPresent([ScheduleEvent].self, forKey: .events) ?? [] rails = try c.decodeIfPresent([ViaplayHubRail].self, forKey: .rails) ?? [] } }