// // Swiftfin is subject to the terms of the Mozilla Public // License, v2.0. If a copy of the MPL was not distributed with this // file, you can obtain one at https://mozilla.org/MPL/2.0/. // // Copyright (c) 2025 Jellyfin & Jellyfin Contributors // import Foundation /// Xtream Codes API Client for Live TV and VOD streams class XtreamAPIClient { private let server: XtreamServer init(server: XtreamServer) { self.server = server } // MARK: - Authentication /// Server info response from Xtream API struct ServerInfo: Codable { let userInfo: UserInfo? let serverInfo: ServerDetails? struct UserInfo: Codable { let username: String? let password: String? let message: String? let auth: Int? let status: String? let exp_date: String? let isTrial: String? let activeCons: String? let createdAt: String? let maxConnections: String? enum CodingKeys: String, CodingKey { case username case password case message case auth case status case exp_date case isTrial = "is_trial" case activeCons = "active_cons" case createdAt = "created_at" case maxConnections = "max_connections" } } struct ServerDetails: Codable { let url: String? let port: String? let httpsPort: String? let serverProtocol: String? let rtmpPort: String? let timezone: String? let timestampNow: Int? let timeNow: String? enum CodingKeys: String, CodingKey { case url case port case timezone case httpsPort = "https_port" case serverProtocol = "server_protocol" case rtmpPort = "rtmp_port" case timestampNow = "timestamp_now" case timeNow = "time_now" } } enum CodingKeys: String, CodingKey { case userInfo = "user_info" case serverInfo = "server_info" } } /// Test connection to Xtream server and authenticate func testConnection() async throws -> ServerInfo { guard let url = server.authenticatedURL(parameters: []) else { throw XtreamAPIError.invalidURL } var request = URLRequest(url: url) request.httpMethod = "GET" request.timeoutInterval = 10 let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw XtreamAPIError.invalidResponse } guard httpResponse.statusCode == 200 else { throw XtreamAPIError.httpError(statusCode: httpResponse.statusCode) } let serverInfo = try JSONDecoder().decode(ServerInfo.self, from: data) // Check if authentication was successful if let auth = serverInfo.userInfo?.auth, auth == 0 { throw XtreamAPIError.authenticationFailed(message: serverInfo.userInfo?.message ?? "Authentication failed") } return serverInfo } // MARK: - Live Channels struct LiveCategory: Codable, Identifiable { let categoryId: String let categoryName: String let parentId: Int? var id: String { categoryId } enum CodingKeys: String, CodingKey { case categoryId = "category_id" case categoryName = "category_name" case parentId = "parent_id" } } struct LiveChannel: Codable, Identifiable { let num: Int? let name: String? let streamType: String? let streamId: Int? let streamIcon: String? let epgChannelId: String? let added: String? let categoryId: String? let customSid: String? let tvArchive: Int? let directSource: String? let tvArchiveDuration: Int? var id: Int { streamId ?? 0 } enum CodingKeys: String, CodingKey { case num case name case added case streamType = "stream_type" case streamId = "stream_id" case streamIcon = "stream_icon" case epgChannelId = "epg_channel_id" case categoryId = "category_id" case customSid = "custom_sid" case tvArchive = "tv_archive" case directSource = "direct_source" case tvArchiveDuration = "tv_archive_duration" } } /// Get live TV categories func getLiveCategories() async throws -> [LiveCategory] { guard let url = server.authenticatedURL(parameters: [ URLQueryItem(name: "action", value: "get_live_categories"), ]) else { throw XtreamAPIError.invalidURL } let (data, _) = try await URLSession.shared.data(from: url) return try JSONDecoder().decode([LiveCategory].self, from: data) } /// Get live TV channels func getLiveChannels(categoryId: String? = nil) async throws -> [LiveChannel] { var parameters = [URLQueryItem(name: "action", value: "get_live_streams")] if let categoryId { parameters.append(URLQueryItem(name: "category_id", value: categoryId)) } guard let url = server.authenticatedURL(parameters: parameters) else { throw XtreamAPIError.invalidURL } let (data, _) = try await URLSession.shared.data(from: url) return try JSONDecoder().decode([LiveChannel].self, from: data) } /// Get live stream URL for a channel func getLiveStreamURL(streamId: Int) -> URL? { let urlString = "\(server.url.absoluteString)/live/\(server.username)/\(server.password)/\(streamId).ts" return URL(string: urlString) } } // MARK: - Errors enum XtreamAPIError: LocalizedError { case invalidURL case invalidResponse case httpError(statusCode: Int) case authenticationFailed(message: String) var errorDescription: String? { switch self { case .invalidURL: return "Invalid Xtream server URL" case .invalidResponse: return "Invalid response from Xtream server" case let .httpError(statusCode): return "HTTP error: \(statusCode)" case let .authenticationFailed(message): return "Authentication failed: \(message)" } } }