Files
yattee/Yattee/Services/ImageLoading/ImageLoadingService.swift
Arkadiusz Fal 9412ca916c Add performance logging and background diagnostics
Performance Logging (Developer settings) logs a resource sample every 5s
in the foreground and background: CPU by area and thread, wakeups, GPU,
memory, disk, activity counters as rates (mpv property updates, player
time publishes, renders, saves, network, images, CloudKit events, log
entries, main run-loop wakeups), SwiftUI body evaluations of the player
views, and app/player/battery context. Each foreground/background
transition logs a summary of the period. MetricKit metrics and
diagnostics (CPU exceptions, hangs) are logged whenever logging is on.

Background diagnostics (iOS) log lifecycle, audio session and power
events with an app/player/render snapshot and a heartbeat that detects
suspension gaps and main-thread stalls. Logs are persisted per session
in Caches/Logs and the previous session is included in exports, so logs
survive the app being swiped away.
2026-10-24 19:36:51 +02:00

136 lines
5.1 KiB
Swift

//
// ImageLoadingService.swift
// Yattee
//
// Configures Nuke's ImagePipeline for app-wide image loading.
//
import Foundation
import Nuke
/// Configures and manages the Nuke image loading pipeline.
@MainActor
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() {
// Memory cache limits (platform-specific)
#if os(tvOS)
let memoryCacheLimit = 50 * 1024 * 1024 // 50 MB
#else
let memoryCacheLimit = 100 * 1024 * 1024 // 100 MB
#endif
// Disk cache limits (platform-specific)
#if os(tvOS)
let diskCacheLimit = 100 * 1024 * 1024 // 100 MB
#elseif os(macOS)
let diskCacheLimit = 500 * 1024 * 1024 // 500 MB
#else
let diskCacheLimit = 300 * 1024 * 1024 // 300 MB (iOS)
#endif
// Create data cache
let dataCache: DataCache? = {
let cache = try? DataCache(name: "com.yattee.images")
cache?.sizeLimit = diskCacheLimit
return cache
}()
// Create image cache (memory)
let imageCache = Nuke.ImageCache()
imageCache.costLimit = memoryCacheLimit
// Configure pipeline
var config = ImagePipeline.Configuration()
config.dataCache = dataCache
config.imageCache = imageCache
// Use default URLSession-based data loader
config.dataLoader = DataLoader(configuration: .default)
// 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",
category: .imageLoading,
details: "Memory: \(memoryCacheLimit / 1024 / 1024)MB, Disk: \(diskCacheLimit / 1024 / 1024)MB"
)
}
/// Warm the image cache for the given URL so a subsequent `LazyImage`
/// display hits the cache instantly. Returns after the image is cached
/// or the operation fails (silently). Bounded by `timeout` seconds.
nonisolated func prefetchImage(for url: URL, timeout: TimeInterval = 3) async {
await withTaskGroup(of: Void.self) { group in
group.addTask {
_ = try? await ImagePipeline.shared.image(for: url)
}
group.addTask {
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
}
await group.next()
group.cancelAll()
}
}
/// Remove a specific URL from both the memory and disk image caches.
/// Use when a previously cached URL is known to return stale or broken
/// data (e.g. an expired proxied thumbnail URL).
func removeCachedImage(for url: URL) {
let request = ImageRequest(url: url)
ImagePipeline.shared.cache.removeCachedImage(for: request)
ImagePipeline.shared.cache.removeCachedData(for: request)
}
/// Clear all image caches (memory and disk).
func clearCache() {
// Clear memory cache
ImagePipeline.shared.cache.removeAll()
// Clear disk cache
if let dataCache = ImagePipeline.shared.configuration.dataCache as? DataCache {
dataCache.removeAll()
}
LoggingService.shared.info("Image cache cleared", category: .imageLoading)
}
}
/// 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)
}
func imageTask(_ task: ImageTask, didCompleteWithResult result: Result<ImageResponse, ImagePipeline.Error>, pipeline _: ImagePipeline) {
guard PerfCounter.enabled, case let .success(response) = result else { return }
PerfCounter.count(response.cacheType == nil ? "image.loadFromNetwork" : "image.loadFromCache")
}
}