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}