Stabilize Nuke cache key across rotating thumbnail URL tokens

This commit is contained in:
Arkadiusz Fal
2026-04-23 18:22:03 +02:00
parent 20b88a811e
commit 664eeadba2
2 changed files with 34 additions and 5 deletions

View File

@@ -13,8 +13,24 @@ import Nuke
final class ImageLoadingService: Sendable {
static let shared = ImageLoadingService()
private let pipelineDelegate = TokenStrippingPipelineDelegate()
private init() {}
/// Returns a cache-stable key for an image URL by stripping query params
/// that rotate per-request (e.g. Yattee-server signed `token`). Images
/// whose path matches but token differs share a cache entry.
nonisolated static func cacheKey(for url: URL) -> String {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return url.absoluteString
}
if let items = components.queryItems {
let filtered = items.filter { $0.name != "token" }
components.queryItems = filtered.isEmpty ? nil : filtered
}
return components.url?.absoluteString ?? url.absoluteString
}
/// Configure the shared ImagePipeline with app-specific settings.
/// Call this once at app launch.
func configure() {
@@ -53,8 +69,10 @@ final class ImageLoadingService: Sendable {
// Use default URLSession-based data loader
config.dataLoader = DataLoader(configuration: .default)
// Set as shared pipeline
ImagePipeline.shared = ImagePipeline(configuration: config)
// Set as shared pipeline with a delegate that normalizes cache keys
// so signed thumbnail URLs whose only difference is a rotating `token`
// query param share a single cache entry.
ImagePipeline.shared = ImagePipeline(configuration: config, delegate: pipelineDelegate)
LoggingService.shared.info(
"Image pipeline configured",
@@ -99,3 +117,12 @@ final class ImageLoadingService: Sendable {
return ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file)
}
}
/// Pipeline delegate that normalizes Nuke's cache key by stripping per-request
/// query params (`token`). Prevents churn when Yattee-server re-signs URLs.
private final class TokenStrippingPipelineDelegate: ImagePipelineDelegate {
func cacheKey(for request: ImageRequest, pipeline _: ImagePipeline) -> String? {
guard let url = request.url else { return nil }
return ImageLoadingService.cacheKey(for: url)
}
}

View File

@@ -2136,7 +2136,7 @@ struct VideoInfoView: View {
return
}
let videoID = base.id.videoID
// Skip if already loaded
guard loadedVideoDetails[videoID] == nil else {
isLoadingVideoDetails = false
@@ -2206,8 +2206,10 @@ struct VideoInfoView: View {
/// payloads from Nuke's cache.
@MainActor
private func invalidateStaleThumbnails(old: Video, new: Video) {
let newURLs = Set(new.thumbnails.map(\.url))
for thumb in old.thumbnails where !newURLs.contains(thumb.url) {
// Compare by normalized cache key so URLs that only differ in a
// rotating signing `token` aren't considered stale and evicted.
let newKeys = Set(new.thumbnails.map { ImageLoadingService.cacheKey(for: $0.url) })
for thumb in old.thumbnails where !newKeys.contains(ImageLoadingService.cacheKey(for: thumb.url)) {
ImageLoadingService.shared.removeCachedImage(for: thumb.url)
}
}