diff --git a/Yattee/Services/ImageLoading/ImageLoadingService.swift b/Yattee/Services/ImageLoading/ImageLoadingService.swift index 525baa05..f008ce1d 100644 --- a/Yattee/Services/ImageLoading/ImageLoadingService.swift +++ b/Yattee/Services/ImageLoading/ImageLoadingService.swift @@ -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) + } +} diff --git a/Yattee/Views/Video/VideoInfoView.swift b/Yattee/Views/Video/VideoInfoView.swift index 454449ba..a08072b6 100644 --- a/Yattee/Views/Video/VideoInfoView.swift +++ b/Yattee/Views/Video/VideoInfoView.swift @@ -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) } }