From d31c41cb447c35d3f8adac6a22b3d1e872bf15a8 Mon Sep 17 00:00:00 2001 From: Mathijs Bernson Date: Wed, 4 Nov 2026 20:56:52 +0100 Subject: [PATCH 1/2] Configure URLSession caching for remote images --- .../Extensions/URLSession+Extensions.swift | 24 ++++++++++++++ .../Conferences/ConferenceThumbnail.swift | 1 + HackerTube/Features/Talk/TalkListItem.swift | 1 + .../Features/Talk/TalkMetadataFactory.swift | 2 +- HackerTube/Features/Talk/TalkThumbnail.swift | 1 + HackerTube/SVGImageView.swift | 2 +- HackerTube/ViewModifiers/Backport.swift | 32 +++++++++++++++++++ 7 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 HackerTube/Extensions/URLSession+Extensions.swift create mode 100644 HackerTube/ViewModifiers/Backport.swift diff --git a/HackerTube/Extensions/URLSession+Extensions.swift b/HackerTube/Extensions/URLSession+Extensions.swift new file mode 100644 index 0000000..d15d4e0 --- /dev/null +++ b/HackerTube/Extensions/URLSession+Extensions.swift @@ -0,0 +1,24 @@ +// +// URLSession+Extensions.swift +// HackerTube +// +// Created by Mathijs Bernson on 04/11/2026. +// + +import Foundation +import OSLog + +extension URLSession { + /// URLSession with a cache intended for talk thumbnails, conference logos, etcetera. + static let remoteImages: URLSession = { + let directory = URL.cachesDirectory.appending(path: "RemoteImages", directoryHint: .isDirectory) + let cache = URLCache( + memoryCapacity: 20_000_000, // 20 megabytes + diskCapacity: 100_000_000, // 100 megabytes + directory: directory + ) + let configuration = URLSessionConfiguration.default + configuration.urlCache = cache + return URLSession(configuration: configuration) + }() +} diff --git a/HackerTube/Features/Conferences/ConferenceThumbnail.swift b/HackerTube/Features/Conferences/ConferenceThumbnail.swift index ab8b3d8..b5534dc 100644 --- a/HackerTube/Features/Conferences/ConferenceThumbnail.swift +++ b/HackerTube/Features/Conferences/ConferenceThumbnail.swift @@ -43,6 +43,7 @@ struct ConferenceThumbnail: View { ProgressView() } } + .backport.asyncImageURLSession(.remoteImages) } } diff --git a/HackerTube/Features/Talk/TalkListItem.swift b/HackerTube/Features/Talk/TalkListItem.swift index 81e468a..31dde02 100644 --- a/HackerTube/Features/Talk/TalkListItem.swift +++ b/HackerTube/Features/Talk/TalkListItem.swift @@ -20,6 +20,7 @@ struct TalkListItem: View { } placeholder: { ProgressView() } + .backport.asyncImageURLSession(.remoteImages) .frame(width: width, height: width * (9 / 16)) } diff --git a/HackerTube/Features/Talk/TalkMetadataFactory.swift b/HackerTube/Features/Talk/TalkMetadataFactory.swift index 85c4e14..1079500 100644 --- a/HackerTube/Features/Talk/TalkMetadataFactory.swift +++ b/HackerTube/Features/Talk/TalkMetadataFactory.swift @@ -63,7 +63,7 @@ struct TalkMetadataFactory { } func fetchImageData(forURL url: URL) async throws -> Data? { - let (imageData, _) = try await URLSession.shared.data(from: url) + let (imageData, _) = try await URLSession.remoteImages.data(from: url) return imageData } } diff --git a/HackerTube/Features/Talk/TalkThumbnail.swift b/HackerTube/Features/Talk/TalkThumbnail.swift index 7142954..6da8c4d 100644 --- a/HackerTube/Features/Talk/TalkThumbnail.swift +++ b/HackerTube/Features/Talk/TalkThumbnail.swift @@ -32,6 +32,7 @@ struct TalkThumbnail: View { ProgressView() } } + .backport.asyncImageURLSession(.remoteImages) .frame(maxWidth: .infinity, maxHeight: .infinity) .aspectRatio(16 / 9, contentMode: .fit) .id(id) diff --git a/HackerTube/SVGImageView.swift b/HackerTube/SVGImageView.swift index bedd558..1b681de 100644 --- a/HackerTube/SVGImageView.swift +++ b/HackerTube/SVGImageView.swift @@ -56,7 +56,7 @@ struct SVGImageView: View { didFail = false do { - let (data, _) = try await URLSession.shared.data(from: url) + let (data, _) = try await URLSession.remoteImages.data(from: url) try Task.checkCancellation() let rendered = try await rasterizeSVG(data: data, size: size, scale: displayScale) cache[key] = rendered diff --git a/HackerTube/ViewModifiers/Backport.swift b/HackerTube/ViewModifiers/Backport.swift new file mode 100644 index 0000000..bcf8918 --- /dev/null +++ b/HackerTube/ViewModifiers/Backport.swift @@ -0,0 +1,32 @@ +// +// Backport.swift +// HackerTube +// +// Created by Mathijs Bernson on 04/11/2026. +// + +import Foundation +import SwiftUI + +// Thanks to: https://davedelong.com/blog/2021/10/09/simplifying-backwards-compatibility-in-swift/ +struct Backport { + let content: Content + + init(_ content: Content) { + self.content = content + } +} + +extension View { + var backport: Backport { Backport(self) } +} + +extension Backport where Content: View { + @ViewBuilder func asyncImageURLSession(_ urlSession: URLSession) -> some View { + if #available(iOS 27, tvOS 27, visionOS 27, *) { + content.asyncImageURLSession(urlSession) + } else { + content + } + } +} From f8b6a3ddf28c0d54ba4e9774d9d57aa622eb396a Mon Sep 17 00:00:00 2001 From: Mathijs Bernson Date: Wed, 4 Nov 2026 21:15:39 +0100 Subject: [PATCH 2/2] Configure URLSession caching for media.ccc.de API --- HackerTube/Domain/ApiService.swift | 2 +- HackerTube/Extensions/URLSession+Extensions.swift | 15 +++++++++++++++ HackerTube/Features/Talk/TalkViewModel.swift | 2 +- TopShelf/ContentProvider.swift | 2 +- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/HackerTube/Domain/ApiService.swift b/HackerTube/Domain/ApiService.swift index d18bdc8..17766d9 100644 --- a/HackerTube/Domain/ApiService.swift +++ b/HackerTube/Domain/ApiService.swift @@ -13,7 +13,7 @@ class ApiService { private let client: MediaCCCApiClient init() { - client = .init() + client = .init(urlSession: .mediaCCCApi) } // MARK: Conferences diff --git a/HackerTube/Extensions/URLSession+Extensions.swift b/HackerTube/Extensions/URLSession+Extensions.swift index d15d4e0..8913ee4 100644 --- a/HackerTube/Extensions/URLSession+Extensions.swift +++ b/HackerTube/Extensions/URLSession+Extensions.swift @@ -21,4 +21,19 @@ extension URLSession { configuration.urlCache = cache return URLSession(configuration: configuration) }() + + /// URLSession for making calls to the `media.ccc.de` API + static let mediaCCCApi: URLSession = { + // The API answers with `Cache-Control: max-age=0, must-revalidate` and an ETag, so every request is + // revalidated. The larger cache makes sure the 300 KB listing pages survive until the `304 Not Modified`. + let directory = URL.cachesDirectory.appending(path: "MediaCCCApi", directoryHint: .isDirectory) + let cache = URLCache( + memoryCapacity: 10_000_000, // 10 megabytes + diskCapacity: 50_000_000, // 50 megabytes + directory: directory + ) + let configuration = URLSessionConfiguration.default + configuration.urlCache = cache + return URLSession(configuration: configuration) + }() } diff --git a/HackerTube/Features/Talk/TalkViewModel.swift b/HackerTube/Features/Talk/TalkViewModel.swift index 1efc038..753f6d9 100644 --- a/HackerTube/Features/Talk/TalkViewModel.swift +++ b/HackerTube/Features/Talk/TalkViewModel.swift @@ -26,7 +26,7 @@ class TalkViewModel { private let mediaAnalyzer: MediaAnalyzer init() { - client = .init() + client = .init(urlSession: .mediaCCCApi) mediaAnalyzer = .init() } diff --git a/TopShelf/ContentProvider.swift b/TopShelf/ContentProvider.swift index 45373dc..853b358 100644 --- a/TopShelf/ContentProvider.swift +++ b/TopShelf/ContentProvider.swift @@ -13,7 +13,7 @@ class ContentProvider: TVTopShelfContentProvider { override func loadTopShelfContent() async -> TVTopShelfContent? { do { - let api = MediaCCCApiClient() + let api = MediaCCCApiClient(urlSession: .mediaCCCApi) async let recentTalks = api.recentTalks() let currentYear = Calendar.current.component(.year, from: .now) async let popularTalks = api.popularTalks(in: currentYear)