From ad67ab8ff4c6142689db7202700a01d27dd2964d Mon Sep 17 00:00:00 2001 From: Mathijs Bernson Date: Sun, 5 Jul 2026 18:29:22 +0200 Subject: [PATCH 1/3] Add additional logging to API client --- CCCApi/Sources/CCCApi/MediaCCCApiClient.swift | 50 +++++++++++-------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift b/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift index 244dc25..12b5b3e 100644 --- a/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift +++ b/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift @@ -6,50 +6,61 @@ // import Foundation +import os.log public final class MediaCCCApiClient { private let session: URLSession private let baseURL = URL(string: "https://api.media.ccc.de/public")! private let decoder = JSONDecoder() + private let logger = Logger(subsystem: "eu.bernson.HackerTube.CCCApi", category: "MediaCCCApiClient") public init(urlSession: URLSession = .shared) { session = urlSession decoder.dateDecodingStrategy = .formatted(CustomISO8601DateFormatter()) } + // MARK: Networking + + /// Performs a GET request for the given URL, logging the request and response, + /// and decodes the response body into the requested type. + private func get(_ url: URL) async throws -> T { + logger.info("GET \(url.absoluteString, privacy: .public)") + do { + let (data, response) = try await session.data(from: url) + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + logger.info("Response code \(statusCode) \(url.absoluteString, privacy: .public) (\(data.count) bytes)") + return try decoder.decode(T.self, from: data) + } catch { + logger.error("GET \(url.absoluteString, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") + throw error + } + } + // MARK: Conferences public func conferences() async throws -> [Conference] { - let (data, _) = try await session.data(from: baseURL.appendingPathComponent("conferences")) - let response = try decoder.decode(ConferencesResponse.self, from: data) + let response: ConferencesResponse = try await get(baseURL.appendingPathComponent("conferences")) return response.conferences } public func conference(acronym: String) async throws -> Conference { - let (data, _) = try await session.data( - from: baseURL.appendingPathComponent("conferences").appendingPathComponent(acronym)) - return try decoder.decode(Conference.self, from: data) + try await get(baseURL.appendingPathComponent("conferences").appendingPathComponent(acronym)) } // MARK: Talks public func talk(id: String) async throws -> Talk { - let (data, _) = try await session.data( - from: baseURL.appendingPathComponent("events").appendingPathComponent(id)) - let response = try decoder.decode(Talk.self, from: data) - return response + try await get(baseURL.appendingPathComponent("events").appendingPathComponent(id)) } public func talks() async throws -> [Talk] { - let (data, _) = try await session.data(from: baseURL.appendingPathComponent("events")) - let response = try decoder.decode(EventsResponse.self, from: data) + let response: EventsResponse = try await get(baseURL.appendingPathComponent("events")) return response.events } public func recentTalks() async throws -> [Talk] { - let (data, _) = try await session.data( - from: baseURL.appendingPathComponent("events").appendingPathComponent("recent")) - let response = try decoder.decode(EventsResponse.self, from: data) + let response: EventsResponse = try await get( + baseURL.appendingPathComponent("events").appendingPathComponent("recent")) return response.events } @@ -57,8 +68,7 @@ public final class MediaCCCApiClient { let url = baseURL.appendingPathComponent("events").appendingPathComponent("popular") var components = URLComponents(url: url, resolvingAgainstBaseURL: true)! components.queryItems = [URLQueryItem(name: "year", value: String(year))] - let (data, _) = try await session.data(from: components.url!) - let response = try decoder.decode(EventsResponse.self, from: data) + let response: EventsResponse = try await get(components.url!) return response.events } @@ -66,17 +76,15 @@ public final class MediaCCCApiClient { let url = baseURL.appendingPathComponent("events").appendingPathComponent("search") var components = URLComponents(url: url, resolvingAgainstBaseURL: true)! components.queryItems = [URLQueryItem(name: "q", value: query)] - let (data, _) = try await session.data(from: components.url!) - let response = try decoder.decode(EventsResponse.self, from: data) + let response: EventsResponse = try await get(components.url!) return response.events } // MARK: Recordings public func recordings(for talk: Talk) async throws -> [Recording] { - let (data, _) = try await session.data( - from: baseURL.appendingPathComponent("events").appendingPathComponent(talk.guid)) - let response = try decoder.decode(TalkExtended.self, from: data) + let response: TalkExtended = try await get( + baseURL.appendingPathComponent("events").appendingPathComponent(talk.guid)) guard let recordings = response.recordings else { return [] } From e020c138b3f2bf8c100694a5024731b1de5fa5dd Mon Sep 17 00:00:00 2001 From: Mathijs Bernson Date: Sun, 5 Jul 2026 19:02:32 +0200 Subject: [PATCH 2/3] Add new calls from voctoweb public API --- CCCApi/Sources/CCCApi/MediaCCCApiClient.swift | 180 +++++++++++++++--- CCCApi/Sources/CCCApi/Models/Page.swift | 43 +++++ CCCApi/Sources/CCCApi/Models/Recording.swift | 13 +- HackerTube/Domain/ApiService.swift | 45 ++++- 4 files changed, 246 insertions(+), 35 deletions(-) create mode 100644 CCCApi/Sources/CCCApi/Models/Page.swift diff --git a/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift b/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift index 12b5b3e..a90ea2a 100644 --- a/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift +++ b/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift @@ -21,70 +21,192 @@ public final class MediaCCCApiClient { // MARK: Networking - /// Performs a GET request for the given URL, logging the request and response, - /// and decodes the response body into the requested type. + /// Performs a GET request for the given URL and decodes the response body, + /// discarding the response metadata. private func get(_ url: URL) async throws -> T { + try await getWithResponse(url).0 + } + + /// Performs a GET request for the given URL, logging the request and response, + /// and decodes the response body into the requested type. Also returns the + /// `HTTPURLResponse` so callers can read pagination headers. + private func getWithResponse(_ url: URL) async throws -> (T, HTTPURLResponse) { logger.info("GET \(url.absoluteString, privacy: .public)") do { let (data, response) = try await session.data(from: url) + let http = response as? HTTPURLResponse + let statusCode = http?.statusCode ?? -1 + logger.info("Response code \(statusCode) \(url.absoluteString, privacy: .public) (\(data.count) bytes)") + let decoded = try decoder.decode(T.self, from: data) + return (decoded, http ?? HTTPURLResponse()) + } catch { + logger.error("GET \(url.absoluteString, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") + throw error + } + } + + /// Performs a form-encoded POST request and decodes the response body. + private func post(_ url: URL, form: [String: String]) async throws -> T { + logger.info("POST \(url.absoluteString, privacy: .public)") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + var components = URLComponents() + components.queryItems = form.map { URLQueryItem(name: $0.key, value: $0.value) } + request.httpBody = components.percentEncodedQuery?.data(using: .utf8) + do { + let (data, response) = try await session.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 logger.info("Response code \(statusCode) \(url.absoluteString, privacy: .public) (\(data.count) bytes)") return try decoder.decode(T.self, from: data) } catch { - logger.error("GET \(url.absoluteString, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") + logger.error("POST \(url.absoluteString, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") throw error } } + /// Builds a URL under the API base from path components and an optional query. + /// Query entries with a `nil` value are omitted. + private func url(path: [String], query: [String: String?] = [:]) -> URL { + let url = path.reduce(baseURL) { $0.appendingPathComponent($1) } + let items = query.compactMap { key, value in + value.map { URLQueryItem(name: key, value: $0) } + } + guard !items.isEmpty else { return url } + var components = URLComponents(url: url, resolvingAgainstBaseURL: true)! + components.queryItems = items.sorted { $0.name < $1.name } + return components.url! + } + + /// Parses the `api-pagination` headers (`Total`, `Per-Page`, `Link`) from a + /// response into pagination metadata for the requested page. + private func paginationInfo( + from response: HTTPURLResponse, page: Int + ) -> (perPage: Int?, total: Int?, nextPage: Int?) { + let total = (response.value(forHTTPHeaderField: "Total")).flatMap(Int.init) + let perPage = (response.value(forHTTPHeaderField: "Per-Page")).flatMap(Int.init) + let hasNext = response.value(forHTTPHeaderField: "Link")?.contains("rel=\"next\"") == true + return (perPage, total, hasNext ? page + 1 : nil) + } + + /// Fetches a page of talks from an events list endpoint and assembles a ``Page``. + private func talkPage(url: URL, page: Int) async throws -> Page { + let (body, response): (EventsResponse, HTTPURLResponse) = try await getWithResponse(url) + let info = paginationInfo(from: response, page: page) + return Page( + items: body.events, currentPage: page, perPage: info.perPage, + totalCount: info.total, nextPage: info.nextPage) + } + // MARK: Conferences - public func conferences() async throws -> [Conference] { - let response: ConferencesResponse = try await get(baseURL.appendingPathComponent("conferences")) + /// Lists conferences. By default only conferences with at least one recorded + /// event are returned. + /// - Parameters: + /// - includeEmpty: Also include conferences without any associated events. + /// - urlContains: Filter to conferences whose website (`link`) contains this substring. + /// - currentlyStreaming: Restrict to conferences that are currently live-streaming. + public func conferences( + includeEmpty: Bool = false, urlContains: String? = nil, currentlyStreaming: Bool = false + ) async throws -> [Conference] { + let url = url( + path: ["conferences"], + query: [ + "include_empty": includeEmpty ? "true" : nil, + "url_contains": urlContains, + "currently_streaming": currentlyStreaming ? "true" : nil, + ]) + let response: ConferencesResponse = try await get(url) return response.conferences } + /// Lists the conferences that have the most recent events. + /// - Parameter limit: Number of conferences to return (server-clamped to 1–30). + public func recentConferences(limit: Int = 5) async throws -> [Conference] { + let url = url(path: ["conferences", "recent"], query: ["limit": String(limit)]) + let response: ConferencesResponse = try await get(url) + return response.conferences + } + + /// Fetches a single conference by numeric id or acronym. public func conference(acronym: String) async throws -> Conference { - try await get(baseURL.appendingPathComponent("conferences").appendingPathComponent(acronym)) + try await get(url(path: ["conferences", acronym])) } // MARK: Talks public func talk(id: String) async throws -> Talk { - try await get(baseURL.appendingPathComponent("events").appendingPathComponent(id)) + try await get(url(path: ["events", id])) } - public func talks() async throws -> [Talk] { - let response: EventsResponse = try await get(baseURL.appendingPathComponent("events")) - return response.events + // Note: the list endpoints below are backed by the `api-pagination` helper with a + // fixed server-side page size (the controller passes an explicit `per_page:`, which + // overrides the query param), so only `page` is adjustable. Search is the exception + // — its controller reads `per_page` from the query directly. + + /// Lists all events, paginated (fixed server page size of 50). + public func talks(page: Int = 1) async throws -> Page { + let url = url(path: ["events"], query: ["page": String(page)]) + return try await talkPage(url: url, page: page) } - public func recentTalks() async throws -> [Talk] { - let response: EventsResponse = try await get( - baseURL.appendingPathComponent("events").appendingPathComponent("recent")) - return response.events + /// Lists recently released events, paginated (fixed server page size of 100). + public func recentTalks(page: Int = 1) async throws -> Page { + let url = url(path: ["events", "recent"], query: ["page": String(page)]) + return try await talkPage(url: url, page: page) } - public func popularTalks(in year: Int) async throws -> [Talk] { - let url = baseURL.appendingPathComponent("events").appendingPathComponent("popular") - var components = URLComponents(url: url, resolvingAgainstBaseURL: true)! - components.queryItems = [URLQueryItem(name: "year", value: String(year))] - let response: EventsResponse = try await get(components.url!) + /// Lists promoted (editorially featured) events. Not paginated; up to 100 events. + public func promotedTalks() async throws -> [Talk] { + let response: EventsResponse = try await get(url(path: ["events", "promoted"])) return response.events } - public func searchTalks(query: String) async throws -> [Talk] { - let url = baseURL.appendingPathComponent("events").appendingPathComponent("search") - var components = URLComponents(url: url, resolvingAgainstBaseURL: true)! - components.queryItems = [URLQueryItem(name: "q", value: query)] - let response: EventsResponse = try await get(components.url!) - return response.events + /// Lists the most-viewed events for a year, paginated (fixed server page size of 50). + public func popularTalks(in year: Int, page: Int = 1) async throws -> Page { + let url = url(path: ["events", "popular"], query: ["year": String(year), "page": String(page)]) + return try await talkPage(url: url, page: page) + } + + /// Lists the least-viewed events for a year, paginated (fixed server page size of 50). + public func unpopularTalks(in year: Int, page: Int = 1) async throws -> Page { + let url = url(path: ["events", "unpopular"], query: ["year": String(year), "page": String(page)]) + return try await talkPage(url: url, page: page) + } + + /// Full-text event search, paginated. `perPage` defaults server-side to 25 (clamped 1–256). + public func searchTalks(query: String, page: Int = 1, perPage: Int? = nil) async throws -> Page { + let url = url( + path: ["events", "search"], + query: ["q": query, "page": String(page), "per_page": perPage.map(String.init)]) + return try await talkPage(url: url, page: page) } // MARK: Recordings + /// Lists all recordings, paginated (fixed server page size of 50). + public func recordings(page: Int = 1) async throws -> Page { + let url = url(path: ["recordings"], query: ["page": String(page)]) + let (body, response): (RecordingsResponse, HTTPURLResponse) = try await getWithResponse(url) + let info = paginationInfo(from: response, page: page) + return Page( + items: body.recordings, currentPage: page, perPage: info.perPage, + totalCount: info.total, nextPage: info.nextPage) + } + + /// Fetches a single recording by its numeric id. + public func recording(id: Int) async throws -> Recording { + try await get(url(path: ["recordings", String(id)])) + } + + /// Fetches the recordings embedded in an event and returns the ones playable on + /// Apple platforms, HD and video first. + /// + /// Recordings arrive embedded in the event `show` response, so this is a single + /// request. The MIME filtering below overlaps with `RecordingChooser` in the app + /// and should eventually be consolidated there. public func recordings(for talk: Talk) async throws -> [Recording] { - let response: TalkExtended = try await get( - baseURL.appendingPathComponent("events").appendingPathComponent(talk.guid)) + let response: TalkExtended = try await get(url(path: ["events", talk.guid])) guard let recordings = response.recordings else { return [] } @@ -104,3 +226,7 @@ public final class MediaCCCApiClient { }) } } + +private struct CountResponse: Decodable { + let status: String? +} diff --git a/CCCApi/Sources/CCCApi/Models/Page.swift b/CCCApi/Sources/CCCApi/Models/Page.swift new file mode 100644 index 0000000..c70eec1 --- /dev/null +++ b/CCCApi/Sources/CCCApi/Models/Page.swift @@ -0,0 +1,43 @@ +// +// Page.swift +// CCCApi +// +// Created by Mathijs Bernson on 05/07/2026. +// + +import Foundation + +/// A single page of results from a paginated API endpoint. +/// +/// The media.ccc.de API paginates list endpoints using the `api-pagination` gem, +/// which returns pagination metadata in the HTTP response headers (`Total`, +/// `Per-Page`, and an RFC-5988 `Link` header) rather than in the response body. +/// This type combines the decoded items with that parsed header metadata. +public struct Page: Sendable where Element: Sendable { + /// The items on this page. + public let items: [Element] + /// The page number that was requested (1-based). + public let currentPage: Int + /// The page size reported by the server, if present. + public let perPage: Int? + /// The total number of items across all pages, if present. + public let totalCount: Int? + /// The next page number, or `nil` when there are no more pages. + /// + /// This is `currentPage + 1` when the server's `Link` header advertises a + /// `rel="next"` relation. + public let nextPage: Int? + + /// Whether another page of results is available. + public var hasNextPage: Bool { nextPage != nil } + + public init( + items: [Element], currentPage: Int, perPage: Int?, totalCount: Int?, nextPage: Int? + ) { + self.items = items + self.currentPage = currentPage + self.perPage = perPage + self.totalCount = totalCount + self.nextPage = nextPage + } +} diff --git a/CCCApi/Sources/CCCApi/Models/Recording.swift b/CCCApi/Sources/CCCApi/Models/Recording.swift index a196efa..da93b71 100644 --- a/CCCApi/Sources/CCCApi/Models/Recording.swift +++ b/CCCApi/Sources/CCCApi/Models/Recording.swift @@ -7,6 +7,10 @@ import Foundation +struct RecordingsResponse: Decodable { + let recordings: [Recording] +} + /// A recording is a file that belongs to a talk (event). /// These can be video or audio recordings of the talk in different formats and languages (live-translation), subtitle tracks as srt or slides as pdf. public struct Recording: Decodable, Identifiable, Equatable, Sendable { @@ -19,6 +23,8 @@ public struct Recording: Decodable, Identifiable, Equatable, Sendable { public let filename: String public let state: String public let folder: String + /// A human-readable label for the recording (e.g. quality/format), if provided. + public let label: String? public let isHighQuality: Bool public let width: Int? public let height: Int? @@ -40,8 +46,8 @@ public struct Recording: Decodable, Identifiable, Equatable, Sendable { init( size: Int?, length: TimeInterval?, mimeType: String, language: String, filename: String, - state: String, folder: String, isHighQuality: Bool, width: Int?, height: Int?, - updatedAt: Date, recordingURL: URL, url: URL, eventURL: URL, conferenceURL: URL + state: String, folder: String, label: String? = nil, isHighQuality: Bool, width: Int?, + height: Int?, updatedAt: Date, recordingURL: URL, url: URL, eventURL: URL, conferenceURL: URL ) { self.size = size self.length = length @@ -50,6 +56,7 @@ public struct Recording: Decodable, Identifiable, Equatable, Sendable { self.filename = filename self.state = state self.folder = folder + self.label = label self.isHighQuality = isHighQuality self.width = width self.height = height @@ -68,6 +75,7 @@ public struct Recording: Decodable, Identifiable, Equatable, Sendable { case filename case state case folder + case label case isHighQuality = "high_quality" case width case height @@ -88,6 +96,7 @@ public struct Recording: Decodable, Identifiable, Equatable, Sendable { filename = try container.decode(String.self, forKey: .filename) state = try container.decode(String.self, forKey: .state) folder = try container.decode(String.self, forKey: .folder) + label = try container.decodeIfPresent(String.self, forKey: .label) isHighQuality = try container.decode(Bool.self, forKey: .isHighQuality) width = try container.decode(Int?.self, forKey: .width) height = try container.decode(Int?.self, forKey: .height) diff --git a/HackerTube/Domain/ApiService.swift b/HackerTube/Domain/ApiService.swift index 45ff76a..43f83d2 100644 --- a/HackerTube/Domain/ApiService.swift +++ b/HackerTube/Domain/ApiService.swift @@ -22,6 +22,10 @@ class ApiService { try await client.conferences() } + func recentConferences(limit: Int = 5) async throws -> [Conference] { + try await client.recentConferences(limit: limit) + } + func conference(acronym: String) async throws -> Conference { try await client.conference(acronym: acronym) } @@ -32,20 +36,45 @@ class ApiService { try await client.talk(id: id) } -// func talks() async throws -> [Talk] { -// try await client.talks() -// } + // Array-returning convenience wrappers (page 1) used by the current views. + // Prefer the paginated variants below for infinite scroll. func recentTalks() async throws -> [Talk] { - try await client.recentTalks() + try await client.recentTalks().items } func popularTalks(in year: Int) async throws -> [Talk] { - try await client.popularTalks(in: year) + try await client.popularTalks(in: year).items } func searchTalks(query: String) async throws -> [Talk] { - try await client.searchTalks(query: query) + try await client.searchTalks(query: query).items + } + + func promotedTalks() async throws -> [Talk] { + try await client.promotedTalks() + } + + // Paginated variants for future infinite-scroll support. + + func talks(page: Int = 1) async throws -> Page { + try await client.talks(page: page) + } + + func recentTalks(page: Int) async throws -> Page { + try await client.recentTalks(page: page) + } + + func popularTalks(in year: Int, page: Int) async throws -> Page { + try await client.popularTalks(in: year, page: page) + } + + func unpopularTalks(in year: Int, page: Int = 1) async throws -> Page { + try await client.unpopularTalks(in: year, page: page) + } + + func searchTalks(query: String, page: Int, perPage: Int? = nil) async throws -> Page { + try await client.searchTalks(query: query, page: page, perPage: perPage) } // MARK: Recordings @@ -53,4 +82,8 @@ class ApiService { func recordings(for talk: Talk) async throws -> [Recording] { try await client.recordings(for: talk) } + + func recording(id: Int) async throws -> Recording { + try await client.recording(id: id) + } } From e493b286a55f5ba3279d6431349cb6fac3a02a81 Mon Sep 17 00:00:00 2001 From: Mathijs Bernson Date: Sun, 5 Jul 2026 19:46:19 +0200 Subject: [PATCH 3/3] Sync API models with the voctoweb implementation --- CCCApi/Sources/CCCApi/Models/Conference.swift | 23 ++++++++++++------ CCCApi/Sources/CCCApi/Models/Examples.swift | 9 ++++--- CCCApi/Sources/CCCApi/Models/Recording.swift | 21 +++++++++------- CCCApi/Sources/CCCApi/Models/Talk.swift | 24 ++++++++----------- HackerTube/Features/Talk/TalkCell.swift | 15 ++++++++---- 5 files changed, 53 insertions(+), 39 deletions(-) diff --git a/CCCApi/Sources/CCCApi/Models/Conference.swift b/CCCApi/Sources/CCCApi/Models/Conference.swift index c31ab1e..da8b2fe 100644 --- a/CCCApi/Sources/CCCApi/Models/Conference.swift +++ b/CCCApi/Sources/CCCApi/Models/Conference.swift @@ -16,10 +16,12 @@ public struct Conference: Decodable, Identifiable, Sendable { public let acronym: String public let slug: String public let title: String + public let createdAt: Date? public let updatedAt: Date? public let eventLastReleasedAt: Date? public let events: [Talk]? public let link: URL? + public let scheduleURL: URL? public let description: String? public let aspectRatio: AspectRatio? public let webgenLocation: String @@ -31,18 +33,21 @@ public struct Conference: Decodable, Identifiable, Sendable { public var id: String { slug } init( - acronym: String, slug: String, title: String, updatedAt: Date?, - eventLastReleasedAt: Date? = nil, events: [Talk]? = nil, link: URL? = nil, - description: String? = nil, aspectRatio: AspectRatio? = nil, webgenLocation: String, + acronym: String, slug: String, title: String, createdAt: Date? = nil, + updatedAt: Date?, eventLastReleasedAt: Date? = nil, events: [Talk]? = nil, + link: URL? = nil, scheduleURL: URL? = nil, description: String? = nil, + aspectRatio: AspectRatio? = nil, webgenLocation: String, url: URL, logoURL: URL, imagesURL: URL? = nil, recordingsURL: URL? = nil ) { self.acronym = acronym self.slug = slug self.title = title + self.createdAt = createdAt self.updatedAt = updatedAt self.eventLastReleasedAt = eventLastReleasedAt self.events = events self.link = link + self.scheduleURL = scheduleURL self.description = description self.aspectRatio = aspectRatio self.webgenLocation = webgenLocation @@ -56,10 +61,12 @@ public struct Conference: Decodable, Identifiable, Sendable { case acronym case slug case title + case createdAt = "created_at" case updatedAt = "updated_at" case eventLastReleasedAt = "event_last_released_at" case events case link + case scheduleURL = "schedule_url" case description case aspectRatio = "aspect_ratio" case webgenLocation = "webgen_location" @@ -74,12 +81,14 @@ public struct Conference: Decodable, Identifiable, Sendable { acronym = try container.decode(String.self, forKey: .acronym) slug = try container.decode(String.self, forKey: .slug) - title = try container.decode(String.self, forKey: .title) - updatedAt = try container.decode(Date?.self, forKey: .updatedAt) - eventLastReleasedAt = try container.decode(Date?.self, forKey: .eventLastReleasedAt) + title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" + createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) + updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) + eventLastReleasedAt = try container.decodeIfPresent(Date.self, forKey: .eventLastReleasedAt) events = try? container.decode([Event].self, forKey: .events) link = try? container.decode(URL?.self, forKey: .link) - description = try container.decode(String?.self, forKey: .description) + scheduleURL = try? container.decode(URL?.self, forKey: .scheduleURL) + description = try container.decodeIfPresent(String.self, forKey: .description) aspectRatio = try? container.decode(AspectRatio.self, forKey: .aspectRatio) webgenLocation = try container.decode(String.self, forKey: .webgenLocation) url = try container.decode(URL.self, forKey: .url) diff --git a/CCCApi/Sources/CCCApi/Models/Examples.swift b/CCCApi/Sources/CCCApi/Models/Examples.swift index f3db10b..9ed8260 100644 --- a/CCCApi/Sources/CCCApi/Models/Examples.swift +++ b/CCCApi/Sources/CCCApi/Models/Examples.swift @@ -51,7 +51,6 @@ extension Talk { date: try! Date("2022-07-26T16:00:00Z", strategy: .iso8601), releaseDate: try! Date("2022-07-26T00:00:00Z", strategy: .iso8601), updatedAt: try! Date("2022-07-29T17:15:05Z", strategy: .iso8601), - length: 1066, duration: 1066, conferenceTitle: "May Contain Hackers 2022", conferenceURL: URL(string: "https://api.media.ccc.de/public/conferences/MCH2022")!, @@ -81,7 +80,7 @@ extension Talk { extension Recording { public static let example = Recording( - size: 461, + size: Measurement(value: 461, unit: .megabytes), length: 1066, mimeType: "video/mp4", language: "eng", @@ -135,7 +134,7 @@ extension Array where Element == RelatedTalk { extension Array where Element == Recording { public static let example: [Recording] = [ Recording( - size: 16, + size: Measurement(value: 16, unit: .megabytes), length: 1066, mimeType: "audio/mpeg", language: "eng", @@ -157,7 +156,7 @@ extension Array where Element == Recording { conferenceURL: URL(string: "https://api.media.ccc.de/public/conferences/MCH2022")! ), Recording( - size: 101, + size: Measurement(value: 101, unit: .megabytes), length: 1066, mimeType: "video/mp4", language: "eng", @@ -179,7 +178,7 @@ extension Array where Element == Recording { conferenceURL: URL(string: "https://api.media.ccc.de/public/conferences/MCH2022")! ), Recording( - size: 461, + size: Measurement(value: 461, unit: .megabytes), length: 1066, mimeType: "video/mp4", language: "eng", diff --git a/CCCApi/Sources/CCCApi/Models/Recording.swift b/CCCApi/Sources/CCCApi/Models/Recording.swift index da93b71..274e63a 100644 --- a/CCCApi/Sources/CCCApi/Models/Recording.swift +++ b/CCCApi/Sources/CCCApi/Models/Recording.swift @@ -14,8 +14,8 @@ struct RecordingsResponse: Decodable { /// A recording is a file that belongs to a talk (event). /// These can be video or audio recordings of the talk in different formats and languages (live-translation), subtitle tracks as srt or slides as pdf. public struct Recording: Decodable, Identifiable, Equatable, Sendable { - /// approximate file size in megabytes - public let size: Int? + /// Approximate file size, reported by the API in megabytes. + public let size: Measurement? /// duration in seconds public let length: TimeInterval? public let mimeType: String @@ -28,7 +28,7 @@ public struct Recording: Decodable, Identifiable, Equatable, Sendable { public let isHighQuality: Bool public let width: Int? public let height: Int? - public let updatedAt: Date + public let updatedAt: Date? public let url: URL public let recordingURL: URL public let eventURL: URL @@ -45,9 +45,10 @@ public struct Recording: Decodable, Identifiable, Equatable, Sendable { } init( - size: Int?, length: TimeInterval?, mimeType: String, language: String, filename: String, - state: String, folder: String, label: String? = nil, isHighQuality: Bool, width: Int?, - height: Int?, updatedAt: Date, recordingURL: URL, url: URL, eventURL: URL, conferenceURL: URL + size: Measurement?, length: TimeInterval?, mimeType: String, + language: String, filename: String, state: String, folder: String, label: String? = nil, + isHighQuality: Bool, width: Int?, height: Int?, updatedAt: Date?, recordingURL: URL, + url: URL, eventURL: URL, conferenceURL: URL ) { self.size = size self.length = length @@ -89,7 +90,11 @@ public struct Recording: Decodable, Identifiable, Equatable, Sendable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - size = try container.decode(Int?.self, forKey: .size) + if let sizeMegabytes = try? container.decode(Double.self, forKey: .size) { + size = Measurement(value: sizeMegabytes, unit: .megabytes) + } else { + size = nil + } length = try container.decode(TimeInterval?.self, forKey: .length) mimeType = try container.decode(String.self, forKey: .mimeType) language = try container.decode(String.self, forKey: .language) @@ -100,7 +105,7 @@ public struct Recording: Decodable, Identifiable, Equatable, Sendable { isHighQuality = try container.decode(Bool.self, forKey: .isHighQuality) width = try container.decode(Int?.self, forKey: .width) height = try container.decode(Int?.self, forKey: .height) - updatedAt = try container.decode(Date.self, forKey: .updatedAt) + updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) url = try container.decode(URL.self, forKey: .url) recordingURL = try container.decode(URL.self, forKey: .recordingURL) eventURL = try container.decode(URL.self, forKey: .eventURL) diff --git a/CCCApi/Sources/CCCApi/Models/Talk.swift b/CCCApi/Sources/CCCApi/Models/Talk.swift index d0be255..42a930f 100644 --- a/CCCApi/Sources/CCCApi/Models/Talk.swift +++ b/CCCApi/Sources/CCCApi/Models/Talk.swift @@ -28,11 +28,10 @@ public struct Talk: Decodable, Identifiable, Equatable, Sendable { public let persons: [String] public let tags: [String] public let viewCount: Int - public let isPromoted: Bool + public let isPromoted: Bool? public let date: Date? public let releaseDate: Date? - public let updatedAt: Date - public let length: TimeInterval + public let updatedAt: Date? public let duration: TimeInterval public let conferenceTitle: String public let conferenceURL: URL @@ -47,8 +46,8 @@ public struct Talk: Decodable, Identifiable, Equatable, Sendable { init( guid: String, title: String, subtitle: String?, slug: String, link: URL?, description: String?, originalLanguage: String?, persons: [String], tags: [String], - viewCount: Int, isPromoted: Bool, date: Date?, releaseDate: Date, updatedAt: Date, - length: TimeInterval, duration: TimeInterval, conferenceTitle: String, conferenceURL: URL, + viewCount: Int, isPromoted: Bool?, date: Date?, releaseDate: Date?, updatedAt: Date?, + duration: TimeInterval, conferenceTitle: String, conferenceURL: URL, thumbURL: URL?, posterURL: URL, timelineURL: URL, thumbnailsURL: URL, frontendLink: URL, url: URL, related: [RelatedTalk] ) { @@ -66,7 +65,6 @@ public struct Talk: Decodable, Identifiable, Equatable, Sendable { self.date = date self.releaseDate = releaseDate self.updatedAt = updatedAt - self.length = length self.duration = duration self.conferenceTitle = conferenceTitle self.conferenceURL = conferenceURL @@ -94,7 +92,6 @@ public struct Talk: Decodable, Identifiable, Equatable, Sendable { case date case releaseDate = "release_date" case updatedAt = "updated_at" - case length case duration case conferenceTitle = "conference_title" case conferenceURL = "conference_url" @@ -117,16 +114,15 @@ public struct Talk: Decodable, Identifiable, Equatable, Sendable { link = try? container.decode(URL?.self, forKey: .link) description = try container.decode(String?.self, forKey: .description) originalLanguage = try container.decode(String?.self, forKey: .originalLanguage) - persons = try container.decode([String].self, forKey: .persons) + persons = try container.decodeIfPresent([String].self, forKey: .persons) ?? [] tags = try container.decode([String].self, forKey: .tags) - viewCount = try container.decode(Int.self, forKey: .viewCount) - isPromoted = try container.decode(Bool.self, forKey: .isPromoted) + viewCount = try container.decodeIfPresent(Int.self, forKey: .viewCount) ?? 0 + isPromoted = try container.decodeIfPresent(Bool.self, forKey: .isPromoted) date = try container.decode(Date?.self, forKey: .date) releaseDate = try container.decode(Date?.self, forKey: .releaseDate) - updatedAt = try container.decode(Date.self, forKey: .updatedAt) - length = try container.decode(TimeInterval.self, forKey: .length) + updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) duration = try container.decode(TimeInterval.self, forKey: .duration) - conferenceTitle = try container.decode(String.self, forKey: .conferenceTitle) + conferenceTitle = try container.decodeIfPresent(String.self, forKey: .conferenceTitle) ?? "" conferenceURL = try container.decode(URL.self, forKey: .conferenceURL) thumbURL = try? container.decode(URL?.self, forKey: .thumbURL) posterURL = try? container.decode(URL.self, forKey: .posterURL) @@ -155,7 +151,7 @@ struct TalkExtended: Decodable { public struct RelatedTalk: Decodable, Equatable, Sendable { let eventID: Int let eventGUID: String - let weight: Int + let weight: Int? enum CodingKeys: String, CodingKey { case eventID = "event_id" diff --git a/HackerTube/Features/Talk/TalkCell.swift b/HackerTube/Features/Talk/TalkCell.swift index 37acdae..a1ce8f0 100644 --- a/HackerTube/Features/Talk/TalkCell.swift +++ b/HackerTube/Features/Talk/TalkCell.swift @@ -13,11 +13,16 @@ struct TalkCell: View { let talk: Talk var subtitle: Text { - Text(talk.conferenceTitle) + - Text(verbatim: " • ") + - Text("\(talk.viewCount) views") + - Text(verbatim: " • ") + - Text(talk.releaseDate ?? talk.updatedAt, format: .relative(presentation: .named)) + let releaseDate: String? = talk.releaseDate.flatMap { $0.formatted(.relative(presentation: .named)) } + return Text( + [ + talk.conferenceTitle, + "\(talk.viewCount.formatted()) views", + releaseDate, + ] + .compactMap { $0 } + .joined(separator: " • ") + ) } @Environment(\.horizontalSizeClass) var horizontalSizeClass