Parent directory

APIClient.swift

11896 bytes
  1import Foundation
  2import ytrssilShared
  3
  4struct Video: Decodable, Identifiable, Sendable {
  5    let videoID: String
  6    let channelName: String
  7    let title: String
  8    let publishedTimestamp: Date
  9    let watchTimestamp: Date?
 10    let duration: Int
 11    let progress: Int
 12    let isShort: Bool
 13    let isLive: Bool
 14    let downloadedAt: Date?
 15    let downloadStatus: String?
 16    let downloadError: String?
 17
 18    var id: String { videoID }
 19
 20    enum CodingKeys: String, CodingKey {
 21        case videoID = "video_id"
 22        case channelName = "channel_name"
 23        case title
 24        case publishedTimestamp = "published_timestamp"
 25        case watchTimestamp = "watch_timestamp"
 26        case duration
 27        case progress
 28        case isShort = "short"
 29        case isLive = "is_live"
 30        case downloadedAt = "downloaded_at"
 31        case downloadStatus = "download_status"
 32        case downloadError = "download_error"
 33    }
 34}
 35
 36struct VideoListResponse: Decodable, Sendable {
 37    let videos: [Video]
 38}
 39
 40struct Channel: Decodable, Identifiable, Sendable {
 41    let channelID: String
 42    let name: String
 43    let subscribed: Bool
 44    let unwatchedCount: Int
 45    let imageURL: String
 46    var enableShorts: Bool
 47
 48    var id: String { channelID }
 49
 50    enum CodingKeys: String, CodingKey {
 51        case channelID = "channel_id"
 52        case name
 53        case subscribed
 54        case unwatchedCount = "unwatched_count"
 55        case imageURL = "image_url"
 56        case enableShorts = "enable_shorts"
 57    }
 58}
 59
 60private struct ChannelListResponse: Decodable, Sendable {
 61    let channels: [Channel]
 62}
 63
 64struct APIClient: Sendable {
 65    func fetchNewVideos(using configuration: ServerConfiguration) async throws -> [Video] {
 66        let response: VideoListResponse = try await request(
 67            endpoint: configuration.endpoint(path: "api/videos/new"),
 68            method: "GET",
 69            configuration: configuration
 70        )
 71        return response.videos
 72    }
 73
 74    func fetchWatchedVideos(page: Int, using configuration: ServerConfiguration) async throws -> [Video] {
 75        var components = URLComponents(url: configuration.endpoint(path: "api/videos/watched"), resolvingAgainstBaseURL: false)
 76        components?.queryItems = [URLQueryItem(name: "page", value: String(max(page, 1)))]
 77
 78        guard let endpoint = components?.url else {
 79            throw APIError.invalidURL
 80        }
 81
 82        let response: VideoListResponse = try await request(
 83            endpoint: endpoint,
 84            method: "GET",
 85            configuration: configuration
 86        )
 87        return response.videos
 88    }
 89
 90    func fetchVideos(using configuration: ServerConfiguration) async throws {
 91        let _: MessageResponse = try await request(
 92            endpoint: configuration.endpoint(path: "api/fetch"),
 93            method: "POST",
 94            configuration: configuration
 95        )
 96    }
 97
 98    func addCustomVideo(videoID: String, using configuration: ServerConfiguration) async throws {
 99        let requestBody = AddVideoRequest(
100            videoID: videoID.trimmingCharacters(in: .whitespacesAndNewlines)
101        )
102        let _: MessageResponse = try await request(
103            endpoint: configuration.endpoint(path: "api/videos"),
104            method: "POST",
105            configuration: configuration,
106            body: try JSONEncoder().encode(requestBody)
107        )
108    }
109
110    func fetchChannels(using configuration: ServerConfiguration) async throws -> [Channel] {
111        let response: ChannelListResponse = try await request(
112            endpoint: configuration.endpoint(path: "api/channels"),
113            method: "GET",
114            configuration: configuration
115        )
116        return response.channels
117    }
118
119    func subscribeToChannel(channelID: String, using configuration: ServerConfiguration) async throws -> Channel {
120        try await request(
121            endpoint: configuration.endpoint(path: "api/channels/subscribe"),
122            method: "POST",
123            configuration: configuration,
124            body: try JSONEncoder().encode(SubscribeRequest(channelID: channelID))
125        )
126    }
127
128    func unsubscribeFromChannel(channelID: String, using configuration: ServerConfiguration) async throws {
129        let _: MessageResponse = try await request(
130            endpoint: configuration.endpoint(path: "api/channels/\(channelID)/unsubscribe"),
131            method: "POST",
132            configuration: configuration
133        )
134    }
135
136    func fetchVideo(videoID: String, using configuration: ServerConfiguration) async throws -> Video {
137        let response: VideoResponse = try await request(
138            endpoint: configuration.endpoint(path: "api/videos/\(videoID)"),
139            method: "GET",
140            configuration: configuration
141        )
142        return response.video
143    }
144
145    func startVideoDownload(
146        videoID: String,
147        format: Int,
148        using configuration: ServerConfiguration
149    ) async throws {
150        let _: MessageResponse = try await request(
151            endpoint: configuration.endpoint(path: "api/videos/\(videoID)/download"),
152            method: "POST",
153            configuration: configuration,
154            body: try JSONEncoder().encode(DownloadRequest(format: format))
155        )
156    }
157
158    func downloadVideoFile(videoID: String, using configuration: ServerConfiguration) async throws -> URL {
159        var request = URLRequest(url: configuration.endpoint(path: "api/videos/\(videoID)/file"))
160        request.httpMethod = "GET"
161        request.setValue(configuration.token, forHTTPHeaderField: "Authorization")
162        request.setValue("application/octet-stream", forHTTPHeaderField: "Accept")
163
164        let (temporaryURL, response) = try await URLSession.shared.download(for: request)
165
166        guard let httpResponse = response as? HTTPURLResponse else {
167            throw APIError.invalidResponse
168        }
169
170        guard (200...299).contains(httpResponse.statusCode) else {
171            let data = (try? Data(contentsOf: temporaryURL)) ?? Data()
172            let errorResponse = try? decoder().decode(ErrorResponse.self, from: data)
173            throw APIError.server(statusCode: httpResponse.statusCode, message: errorResponse?.error)
174        }
175
176        let destinationURL = FileManager.default.temporaryDirectory
177            .appendingPathComponent("\(videoID)-\(UUID().uuidString)")
178            .appendingPathExtension("mp4")
179
180        do {
181            try FileManager.default.moveItem(at: temporaryURL, to: destinationURL)
182            return destinationURL
183        } catch {
184            throw APIError.fileDownloadFailed
185        }
186    }
187
188    func setShortsEnabled(
189        _ enabled: Bool,
190        for channelID: String,
191        using configuration: ServerConfiguration
192    ) async throws {
193        var components = URLComponents(
194            url: configuration.endpoint(path: "api/channels/\(channelID)/shorts"),
195            resolvingAgainstBaseURL: false
196        )
197        components?.queryItems = [URLQueryItem(name: "enable", value: String(enabled))]
198
199        guard let endpoint = components?.url else {
200            throw APIError.invalidURL
201        }
202
203        let _: MessageResponse = try await request(
204            endpoint: endpoint,
205            method: "POST",
206            configuration: configuration
207        )
208    }
209
210    func markWatched(videoID: String, using configuration: ServerConfiguration) async throws {
211        let _: MessageResponse = try await request(
212            endpoint: configuration.endpoint(path: "api/videos/\(videoID)/watch"),
213            method: "POST",
214            configuration: configuration
215        )
216    }
217
218    func markUnwatched(videoID: String, using configuration: ServerConfiguration) async throws {
219        let _: MessageResponse = try await request(
220            endpoint: configuration.endpoint(path: "api/videos/\(videoID)/unwatch"),
221            method: "POST",
222            configuration: configuration
223        )
224    }
225
226    func updateVideoProgress(
227        videoID: String,
228        progress: String,
229        using configuration: ServerConfiguration
230    ) async throws -> Video {
231        let requestBody = ProgressRequest(
232            progress: progress.trimmingCharacters(in: .whitespacesAndNewlines)
233        )
234        let response: VideoResponse = try await request(
235            endpoint: configuration.endpoint(path: "api/videos/\(videoID)/progress"),
236            method: "POST",
237            configuration: configuration,
238            body: try JSONEncoder().encode(requestBody)
239        )
240        return response.video
241    }
242
243    private func request<Response: Decodable>(
244        endpoint: URL,
245        method: String,
246        configuration: ServerConfiguration,
247        body: Data? = nil
248    ) async throws -> Response {
249        var request = URLRequest(url: endpoint)
250        request.httpMethod = method
251        request.setValue(configuration.token, forHTTPHeaderField: "Authorization")
252        request.setValue("application/json", forHTTPHeaderField: "Accept")
253        request.httpBody = body
254        if body != nil {
255            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
256        }
257
258        let (data, response) = try await URLSession.shared.data(for: request)
259
260        guard let httpResponse = response as? HTTPURLResponse else {
261            throw APIError.invalidResponse
262        }
263
264        guard (200...299).contains(httpResponse.statusCode) else {
265            let errorResponse = try? decoder().decode(ErrorResponse.self, from: data)
266            throw APIError.server(statusCode: httpResponse.statusCode, message: errorResponse?.error)
267        }
268
269        do {
270            return try decoder().decode(Response.self, from: data)
271        } catch {
272            throw APIError.invalidResponse
273        }
274    }
275
276    private func decoder() -> JSONDecoder {
277        let decoder = JSONDecoder()
278        decoder.dateDecodingStrategy = .custom { decoder in
279            let container = try decoder.singleValueContainer()
280            let value = try container.decode(String.self)
281
282            let fractionalFormatter = ISO8601DateFormatter()
283            fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
284            if let date = fractionalFormatter.date(from: value) {
285                return date
286            }
287
288            let formatter = ISO8601DateFormatter()
289            formatter.formatOptions = [.withInternetDateTime]
290            guard let date = formatter.date(from: value) else {
291                throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid RFC 3339 timestamp")
292            }
293            return date
294        }
295        return decoder
296    }
297}
298
299private struct MessageResponse: Decodable {
300    let msg: String
301}
302
303private struct VideoResponse: Decodable {
304    let video: Video
305}
306
307private struct ProgressRequest: Encodable {
308    let progress: String
309}
310
311private struct DownloadRequest: Encodable {
312    let format: Int
313}
314
315private struct AddVideoRequest: Encodable {
316    let videoID: String
317
318    enum CodingKeys: String, CodingKey {
319        case videoID = "video_id"
320    }
321}
322
323private struct SubscribeRequest: Encodable {
324    let channelID: String
325
326    enum CodingKeys: String, CodingKey {
327        case channelID = "channel_id"
328    }
329}
330
331private struct ErrorResponse: Decodable {
332    let error: String
333}
334
335enum APIError: LocalizedError {
336    case invalidURL
337    case invalidResponse
338    case fileDownloadFailed
339    case server(statusCode: Int, message: String?)
340
341    var errorDescription: String? {
342        switch self {
343        case .invalidURL:
344            "The server URL is invalid."
345        case .invalidResponse:
346            "The server returned an invalid response."
347        case .fileDownloadFailed:
348            "The downloaded file could not be prepared for export."
349        case let .server(statusCode, message):
350            message ?? "The server returned HTTP \(statusCode)."
351        }
352    }
353}